mirror of
https://github.com/emsesp/EMS-ESP32.git
synced 2026-05-11 08:25:51 +00:00
Compare commits
992 Commits
v3.8.0
...
55b893362c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55b893362c | ||
|
|
48f1928327 | ||
|
|
dc793f145d | ||
|
|
77e0b7d89c | ||
|
|
cec19860bc | ||
|
|
898acb90d0 | ||
|
|
a074ac732d | ||
|
|
c71526b95c | ||
|
|
f035e31dcf | ||
|
|
35192b9dde | ||
|
|
b5ac637231 | ||
|
|
4e0ef8b0e3 | ||
|
|
c32ee4dfb5 | ||
|
|
accfeab6fa | ||
|
|
d98f3cc8c5 | ||
|
|
342f238983 | ||
|
|
73f8ea0fc5 | ||
|
|
f2bd1ff575 | ||
|
|
b3ec23d6bd | ||
|
|
1fc7fa4720 | ||
|
|
58ae058465 | ||
|
|
7ece395d1b | ||
|
|
4bb876031e | ||
|
|
e4f129db04 | ||
|
|
90038e08dc | ||
|
|
4c30e930cf | ||
|
|
dd69e02f6b | ||
|
|
f2ccf1953d | ||
|
|
0e133840c9 | ||
|
|
e12b277472 | ||
|
|
dc7b1809c1 | ||
|
|
8c2146ff55 | ||
|
|
882d412409 | ||
|
|
3f88a9469c | ||
|
|
3ac7d43a81 | ||
|
|
d12446b6d9 | ||
|
|
865fb3a967 | ||
|
|
48538222b2 | ||
|
|
8d44f61517 | ||
|
|
0e4f6f4209 | ||
|
|
a2823563bf | ||
|
|
353cdb324d | ||
|
|
216f799db1 | ||
|
|
4d6f080263 | ||
|
|
b9d124618c | ||
|
|
ab5d9e8d36 | ||
|
|
4cdeecd952 | ||
|
|
59b3933cb6 | ||
|
|
179ddcb348 | ||
|
|
efa2c8fc4b | ||
|
|
c958c7d61a | ||
|
|
35fca9c450 | ||
|
|
61962fbc07 | ||
|
|
39e724befe | ||
|
|
43bb77b095 | ||
|
|
28e1e46586 | ||
|
|
2f5b879652 | ||
|
|
16930fe8ca | ||
|
|
1db1b6e524 | ||
|
|
43eba7a010 | ||
|
|
49278bdea4 | ||
|
|
2405e11af2 | ||
|
|
23b6894484 | ||
|
|
a837c9398c | ||
|
|
ea484e15f9 | ||
|
|
cce99a8b1d | ||
|
|
f29faafd78 | ||
|
|
c3eafbcd85 | ||
|
|
65e18ab4e2 | ||
|
|
8bb16ed3a7 | ||
|
|
e685284f72 | ||
|
|
86d2805642 | ||
|
|
29b98a15a4 | ||
|
|
ba9df92b12 | ||
|
|
0eac1c9bf9 | ||
|
|
515feb9f9e | ||
|
|
0b84b79e1d | ||
|
|
8fd129f4fe | ||
|
|
ba334930fe | ||
|
|
982d64ddca | ||
|
|
3577300361 | ||
|
|
0478e0ff7c | ||
|
|
c2cfd0a1b0 | ||
|
|
637ba30df1 | ||
|
|
fa8421b297 | ||
|
|
61c23a57d9 | ||
|
|
43f61fd2df | ||
|
|
85eed64fe9 | ||
|
|
43634a4312 | ||
|
|
9757db4438 | ||
|
|
17a2ba7f1a | ||
|
|
03c7417888 | ||
|
|
2b2c86ba5a | ||
|
|
2b2217e8ce | ||
|
|
08d3e8bab6 | ||
|
|
ded3552873 | ||
|
|
9a7893e99f | ||
|
|
307ef4e285 | ||
|
|
99b43b0379 | ||
|
|
6fb8fbba18 | ||
|
|
09c750e622 | ||
|
|
f75deb3505 | ||
|
|
6281f9cfe1 | ||
|
|
b4f174f2f4 | ||
|
|
70810b5e71 | ||
|
|
82cc91cb63 | ||
|
|
48a3fc5656 | ||
|
|
9d20eef12d | ||
|
|
d59e183415 | ||
|
|
50396a5def | ||
|
|
ab92d07716 | ||
|
|
9172dd9181 | ||
|
|
0852502f52 | ||
|
|
c3d066650c | ||
|
|
172e8d0028 | ||
|
|
812c6ac475 | ||
|
|
f7c1f0b0d0 | ||
|
|
125190a0ac | ||
|
|
35c7349e5c | ||
|
|
d7e916269d | ||
|
|
f5f78182b6 | ||
|
|
87bcd4598a | ||
|
|
21a814b5ec | ||
|
|
687d9a40c9 | ||
|
|
9d01791fcb | ||
|
|
028afbe85d | ||
|
|
4bdea56d78 | ||
|
|
bf0737aab8 | ||
|
|
42d879a87b | ||
|
|
79b5671533 | ||
|
|
f48d67d9e7 | ||
|
|
16f7a454db | ||
|
|
02b486ea80 | ||
|
|
61d50e2c79 | ||
|
|
a5af36e15b | ||
|
|
e11ba9e657 | ||
|
|
878f0702b2 | ||
|
|
fa711373f2 | ||
|
|
e9a4a33942 | ||
|
|
f445f36eb1 | ||
|
|
148124ef04 | ||
|
|
27fbafbe62 | ||
|
|
a949673539 | ||
|
|
32193e3c62 | ||
|
|
5f79f0848f | ||
|
|
4d91128aed | ||
|
|
6f1d507df9 | ||
|
|
9b50306172 | ||
|
|
0fbc8e2420 | ||
|
|
f0d162554b | ||
|
|
d92361a8bb | ||
|
|
13bf2c44e7 | ||
|
|
cd155ba680 | ||
|
|
2a565dc677 | ||
|
|
9bf57c3e22 | ||
|
|
766281d8d2 | ||
|
|
bd128072c0 | ||
|
|
88e4ba7ecf | ||
|
|
96e5251050 | ||
|
|
187b163ffd | ||
|
|
9d04058984 | ||
|
|
12b06aa657 | ||
|
|
47b3e4bf00 | ||
|
|
f0f40bbcac | ||
|
|
036e2917a5 | ||
|
|
d294c418c1 | ||
|
|
40da7572cd | ||
|
|
532dc66282 | ||
|
|
8913f38fd0 | ||
|
|
11782eef8b | ||
|
|
b5f4eb6c62 | ||
|
|
ee4f58ce20 | ||
|
|
0fe0ee77b3 | ||
|
|
74e58aaa3d | ||
|
|
d39d6c7f1f | ||
|
|
4043eaf271 | ||
|
|
73ac60a8b2 | ||
|
|
1d6b283033 | ||
|
|
08ca4e44e8 | ||
|
|
255c173469 | ||
|
|
aeee318cca | ||
|
|
eb14e89c35 | ||
|
|
8411ea6773 | ||
|
|
015110a72e | ||
|
|
dae345f359 | ||
|
|
4cfd9b699c | ||
|
|
bd6371fd9d | ||
|
|
7507596869 | ||
|
|
5d99bd923b | ||
|
|
7402776248 | ||
|
|
8b5cc82df9 | ||
|
|
7c5351f15f | ||
|
|
b29f02e5dd | ||
|
|
e9e7162bcd | ||
|
|
b24aae9123 | ||
|
|
9dbd634322 | ||
|
|
daffc94c7f | ||
|
|
93de0e2f42 | ||
|
|
145172b6e9 | ||
|
|
c4a2f8bac8 | ||
|
|
0c0c928efc | ||
|
|
4d829b0b78 | ||
|
|
01e7d9b027 | ||
|
|
439da1d1e9 | ||
|
|
ac45c17204 | ||
|
|
cd24c7815b | ||
|
|
9665efbf38 | ||
|
|
d8aafdbfd4 | ||
|
|
0c6aef5b60 | ||
|
|
48a1bd0fe6 | ||
|
|
44cfffe8a4 | ||
|
|
e75bf8871e | ||
|
|
22703f4100 | ||
|
|
c066ab8400 | ||
|
|
4a0625e31c | ||
|
|
9aaaba5bb7 | ||
|
|
689a3a9a69 | ||
|
|
391a312f0c | ||
|
|
f782eac0cf | ||
|
|
d88513d789 | ||
|
|
59d07e81d6 | ||
|
|
419fe8ef5d | ||
|
|
4cfcba18ee | ||
|
|
b1d6ab3c96 | ||
|
|
ae26754bc8 | ||
|
|
61c3b47269 | ||
|
|
50bedb2b39 | ||
|
|
13db83a6de | ||
|
|
ec43a07866 | ||
|
|
fbc11b8ef8 | ||
|
|
f1c5a911f9 | ||
|
|
76c0aa6be8 | ||
|
|
61bf2332bb | ||
|
|
39ca956e1f | ||
|
|
0683b77437 | ||
|
|
5da2760dc6 | ||
|
|
3fabaf900f | ||
|
|
b2a8738672 | ||
|
|
c3b9c1ef98 | ||
|
|
aef6b6e92d | ||
|
|
dc46dac02a | ||
|
|
025c430611 | ||
|
|
995ab7233d | ||
|
|
1507989ca3 | ||
|
|
022e808b14 | ||
|
|
9b604e9c78 | ||
|
|
cd3cc09386 | ||
|
|
0df21a7843 | ||
|
|
9225ad2ad9 | ||
|
|
227b1ac59b | ||
|
|
a9a6e32dd1 | ||
|
|
3c4278029f | ||
|
|
3b4e09208e | ||
|
|
e9e0688737 | ||
|
|
7bb1b7bb91 | ||
|
|
4302bc9978 | ||
|
|
60d884df88 | ||
|
|
177c635bc1 | ||
|
|
9a97c28bf0 | ||
|
|
deb87cf5d7 | ||
|
|
a50227638b | ||
|
|
92b1515c8a | ||
|
|
0c5cf0475c | ||
|
|
f26e937514 | ||
|
|
1e4ca8b57f | ||
|
|
4d88bbd28f | ||
|
|
0ce110df9e | ||
|
|
3759fc81ba | ||
|
|
7965ecd856 | ||
|
|
7b0169bb68 | ||
|
|
f10f3d5305 | ||
|
|
ed9e2704b0 | ||
|
|
c47dd0e523 | ||
|
|
80c75bae77 | ||
|
|
cfa973b08b | ||
|
|
83987b71e0 | ||
|
|
a318f34988 | ||
|
|
5cc1660675 | ||
|
|
8a48da38b8 | ||
|
|
d514e67eb8 | ||
|
|
69964482f8 | ||
|
|
2aa691212c | ||
|
|
c27134f185 | ||
|
|
c8033692b1 | ||
|
|
c537d0ab8b | ||
|
|
bee703eb1f | ||
|
|
5d2bd6a2af | ||
|
|
67f0f40a8a | ||
|
|
e796fbef7a | ||
|
|
da7ef04741 | ||
|
|
ddb318dfc6 | ||
|
|
88643dc8e3 | ||
|
|
cf3854563d | ||
|
|
4b2468d616 | ||
|
|
4b08aba9c4 | ||
|
|
0a18add447 | ||
|
|
ca8d23ff3a | ||
|
|
c7e833194f | ||
|
|
f63f658421 | ||
|
|
13fcf09470 | ||
|
|
f560cbd60c | ||
|
|
38ead7e10f | ||
|
|
326bba9b42 | ||
|
|
d9a18bf255 | ||
|
|
6c42cbfb4b | ||
|
|
6691c81956 | ||
|
|
2f95ef305d | ||
|
|
7afde0ce6e | ||
|
|
f3cdafe7d0 | ||
|
|
4bf23e1bda | ||
|
|
aca66457f9 | ||
|
|
121887bdce | ||
|
|
32d7cf4e9c | ||
|
|
3f8227e95e | ||
|
|
10d84261da | ||
|
|
51848d8347 | ||
|
|
1c0669144f | ||
|
|
41a2ba6e5d | ||
|
|
b6fe9e7569 | ||
|
|
faa2c5f1aa | ||
|
|
c71034ff12 | ||
|
|
71be615bbe | ||
|
|
ce53fd1d04 | ||
|
|
1772876f9e | ||
|
|
cd5dbebea9 | ||
|
|
6c67b78a1c | ||
|
|
9b4deb271b | ||
|
|
0e9283af5c | ||
|
|
58011700fe | ||
|
|
079a08ff7b | ||
|
|
b64a55e460 | ||
|
|
2b7ef5b6ba | ||
|
|
d62eef4eca | ||
|
|
0f6d6e69f5 | ||
|
|
97f689b8a7 | ||
|
|
be9b4a070c | ||
|
|
bc15dd4463 | ||
|
|
1613caea86 | ||
|
|
4b39ab76ab | ||
|
|
d04c882590 | ||
|
|
a199bf21e1 | ||
|
|
3ae8722ece | ||
|
|
6a6cef57cf | ||
|
|
090491aab6 | ||
|
|
ca81a02a8c | ||
|
|
f64188bd5d | ||
|
|
d3e0f180c5 | ||
|
|
3f4d87a1d2 | ||
|
|
1d89e651a4 | ||
|
|
fad67b4ef9 | ||
|
|
ce1c22ee35 | ||
|
|
169b5f34ea | ||
|
|
131c03714f | ||
|
|
024c4a0c21 | ||
|
|
b0d111d86f | ||
|
|
faeef9c821 | ||
|
|
60a5b28a21 | ||
|
|
f4b5cf04a0 | ||
|
|
d69f26acac | ||
|
|
2a50701107 | ||
|
|
ab099c45b8 | ||
|
|
47f21019a0 | ||
|
|
5c473c2b3d | ||
|
|
9ddc587334 | ||
|
|
9b0a7a4872 | ||
|
|
97528e9df6 | ||
|
|
b797e1e2bb | ||
|
|
bb52d35c99 | ||
|
|
4c9026e11a | ||
|
|
6cd4e8a5b6 | ||
|
|
dfe037a7d3 | ||
|
|
b87622185d | ||
|
|
0318f8156d | ||
|
|
0c03fa1308 | ||
|
|
2d6e02171f | ||
|
|
2da312bf15 | ||
|
|
52f59a7b1d | ||
|
|
6c8624298c | ||
|
|
ce2d2fb867 | ||
|
|
ba2ad4e175 | ||
|
|
b3320c3e48 | ||
|
|
dc1094b6ba | ||
|
|
bb60568d83 | ||
|
|
67b5c5dd26 | ||
|
|
6866d5b7a9 | ||
|
|
b4e9af89ee | ||
|
|
775ed99b22 | ||
|
|
2ec13273fd | ||
|
|
a846b01103 | ||
|
|
6dffb08545 | ||
|
|
16f7cc148d | ||
|
|
568431ada4 | ||
|
|
6034c1e5eb | ||
|
|
e3566feefb | ||
|
|
ea46c79278 | ||
|
|
a02831e04e | ||
|
|
3b8c973f2a | ||
|
|
2c65936b3e | ||
|
|
bc04c34d58 | ||
|
|
5047f1752e | ||
|
|
1fa7a6c549 | ||
|
|
f9ebe33a7d | ||
|
|
e719dd963d | ||
|
|
c6b0099581 | ||
|
|
71726530c0 | ||
|
|
a749ecb298 | ||
|
|
7e963529c4 | ||
|
|
c76409cf3f | ||
|
|
efd0872690 | ||
|
|
57098b578f | ||
|
|
6472e9e224 | ||
|
|
026828efc7 | ||
|
|
fe6e9be4d3 | ||
|
|
7745a6f9a1 | ||
|
|
c523a379fe | ||
|
|
2854e9cbe9 | ||
|
|
4985f104c7 | ||
|
|
a0ea5f7ea1 | ||
|
|
efc35d0594 | ||
|
|
ccd6c6f8ad | ||
|
|
b426e0eb45 | ||
|
|
c53e1de569 | ||
|
|
8058e98748 | ||
|
|
ba419d09eb | ||
|
|
c701247652 | ||
|
|
12754d1c07 | ||
|
|
9a4daba31a | ||
|
|
a2aa2dccdd | ||
|
|
25af51a8e8 | ||
|
|
ddc597ff03 | ||
|
|
75310afd63 | ||
|
|
3095323c93 | ||
|
|
4667718f12 | ||
|
|
2cb1c5d7e7 | ||
|
|
d7b9754ddb | ||
|
|
99b769626e | ||
|
|
70035b059c | ||
|
|
b252c2f95a | ||
|
|
baa4f2eb39 | ||
|
|
c7b970b5b0 | ||
|
|
3ed5b65191 | ||
|
|
6e01c00d46 | ||
|
|
e72afc9065 | ||
|
|
de2f3e712d | ||
|
|
cacb92cd47 | ||
|
|
3eb20fa700 | ||
|
|
d35574d494 | ||
|
|
3cb29220ab | ||
|
|
d91812ab8f | ||
|
|
856f8efd25 | ||
|
|
d32378ccd4 | ||
|
|
f5006d1a11 | ||
|
|
c3468e6308 | ||
|
|
9f4de56099 | ||
|
|
78738de811 | ||
|
|
e64596ad61 | ||
|
|
024357ae80 | ||
|
|
3f07d3b75f | ||
|
|
cb7c695c67 | ||
|
|
912a764c2d | ||
|
|
c9c0f55b64 | ||
|
|
6991677cf9 | ||
|
|
83330907cd | ||
|
|
3571998da3 | ||
|
|
77465ebe81 | ||
|
|
006e5493e2 | ||
|
|
b29136433d | ||
|
|
0347a4b8b0 | ||
|
|
3330103a8d | ||
|
|
0a02252fee | ||
|
|
642f2116d2 | ||
|
|
7b8e45c2f7 | ||
|
|
0f2244607f | ||
|
|
32ab9dda45 | ||
|
|
b6659b8586 | ||
|
|
5eb85066ef | ||
|
|
9398fc72a0 | ||
|
|
1bcd453e3f | ||
|
|
d405478a13 | ||
|
|
c927e5f496 | ||
|
|
c5dbd7452e | ||
|
|
3a0b4ea587 | ||
|
|
6f4cdb7122 | ||
|
|
b5d6757660 | ||
|
|
0dcde46296 | ||
|
|
ac7e91beff | ||
|
|
9ea1e2752d | ||
|
|
b5471aef94 | ||
|
|
f31329ceff | ||
|
|
40d48f4407 | ||
|
|
11b7e1f86e | ||
|
|
e364a71eda | ||
|
|
e006bebb86 | ||
|
|
fa3d42a1c7 | ||
|
|
e435fd4391 | ||
|
|
d42efb32ab | ||
|
|
ad8f2dc823 | ||
|
|
f2ff14f511 | ||
|
|
e5e9d4c713 | ||
|
|
4c5f93000b | ||
|
|
0c7301a020 | ||
|
|
74c63bf17e | ||
|
|
4564e0b828 | ||
|
|
2ab9607989 | ||
|
|
29d198b46d | ||
|
|
8387ca0c07 | ||
|
|
3796fb8027 | ||
|
|
1da08633ec | ||
|
|
b097e372e4 | ||
|
|
846776929e | ||
|
|
6e1d56b8ee | ||
|
|
32f7eb7299 | ||
|
|
3fc9c3b56c | ||
|
|
e2e46543d2 | ||
|
|
a2b22198ec | ||
|
|
642b59f729 | ||
|
|
9e81de2164 | ||
|
|
d44797db1d | ||
|
|
673ee3f79b | ||
|
|
38a8179544 | ||
|
|
cfb59ac6a0 | ||
|
|
80c26e1adb | ||
|
|
d0de6e8d0f | ||
|
|
68be7d00ff | ||
|
|
d3e6043911 | ||
|
|
373895b36a | ||
|
|
594e10dbe1 | ||
|
|
e3c5b462da | ||
|
|
8a1376b169 | ||
|
|
900e26cf9f | ||
|
|
071e81f29b | ||
|
|
d1bd861ff0 | ||
|
|
f5925dbb3b | ||
|
|
b1eedcb1d8 | ||
|
|
ed7a9f43de | ||
|
|
67885950ef | ||
|
|
74ddb771e9 | ||
|
|
584d0e0b48 | ||
|
|
8d9ca33ea3 | ||
|
|
f0eea1a6a3 | ||
|
|
79cc0377c0 | ||
|
|
115cec08fa | ||
|
|
6df592c2b8 | ||
|
|
f2b81489ba | ||
|
|
0d8760219a | ||
|
|
e8e8d9c130 | ||
|
|
8f712412f5 | ||
|
|
f8ece46163 | ||
|
|
cd8b1add54 | ||
|
|
1c415a9715 | ||
|
|
e1e3601640 | ||
|
|
c2f718b49a | ||
|
|
cee1874689 | ||
|
|
57d172aac2 | ||
|
|
bb26900213 | ||
|
|
9d1ee27533 | ||
|
|
7fd735f667 | ||
|
|
2e79c3a5c6 | ||
|
|
d769999f10 | ||
|
|
bd93d26361 | ||
|
|
72feefe709 | ||
|
|
1d6c2c9664 | ||
|
|
85c78bc8e9 | ||
|
|
18bdcfe050 | ||
|
|
a6f77250b5 | ||
|
|
9874ecde82 | ||
|
|
584b8788be | ||
|
|
7eb15652c7 | ||
|
|
2c28a607ba | ||
|
|
45eca462e7 | ||
|
|
4474868afc | ||
|
|
6c6b5b060d | ||
|
|
c03eb290d1 | ||
|
|
de9bd44071 | ||
|
|
b7458b0686 | ||
|
|
a660ec1afa | ||
|
|
c4f6f01f7e | ||
|
|
9f60560f2b | ||
|
|
7b50f80cb8 | ||
|
|
656d275c56 | ||
|
|
eca17f2b2c | ||
|
|
7f60279aa3 | ||
|
|
5227fafa1b | ||
|
|
4991e2b7cd | ||
|
|
34c514709a | ||
|
|
7dfedfeb10 | ||
|
|
c8bf4cae17 | ||
|
|
2818e268b6 | ||
|
|
7a95c11f62 | ||
|
|
d712b1cce9 | ||
|
|
03fa92352b | ||
|
|
e121fdb47f | ||
|
|
c05793f64f | ||
|
|
3836610d81 | ||
|
|
c3f87cd321 | ||
|
|
4d5a27f45e | ||
|
|
c37c1aaad5 | ||
|
|
110c0df6fb | ||
|
|
04ac3be242 | ||
|
|
44c4ee8bc0 | ||
|
|
57c4d550a3 | ||
|
|
4bcb95eece | ||
|
|
0a92d455c8 | ||
|
|
edb30931ae | ||
|
|
d03ab7a16f | ||
|
|
ea9b6b3e00 | ||
|
|
bae6b600bd | ||
|
|
7eac920985 | ||
|
|
00c2b5992c | ||
|
|
43d2fa1f00 | ||
|
|
92d40d9287 | ||
|
|
7a47a2090f | ||
|
|
0d98491a97 | ||
|
|
16cf16616e | ||
|
|
d9f56ef3ae | ||
|
|
c8934af9bb | ||
|
|
cbacaa98d9 | ||
|
|
f1b6a0baf3 | ||
|
|
4d15f48e2b | ||
|
|
2ffd00e28a | ||
|
|
8ef5be9a7f | ||
|
|
8769faeb83 | ||
|
|
cefbf2d4d6 | ||
|
|
bfd5082054 | ||
|
|
3d3a634d94 | ||
|
|
1cb078cd3c | ||
|
|
22683f5d12 | ||
|
|
57b42aa7c2 | ||
|
|
4a59743024 | ||
|
|
66cec18dee | ||
|
|
ad71938fde | ||
|
|
d4155d6e9e | ||
|
|
af6be4c6b1 | ||
|
|
cfbd0168c3 | ||
|
|
61b9bd7581 | ||
|
|
4c7ad7124e | ||
|
|
0413314cfb | ||
|
|
ab7cbe8ead | ||
|
|
d1264828eb | ||
|
|
76a317b5c0 | ||
|
|
ec11ae2ef7 | ||
|
|
b857c6eb44 | ||
|
|
3f8add73ac | ||
|
|
08b0ddbb7f | ||
|
|
c44cb7e7fd | ||
|
|
77ff61046e | ||
|
|
a8eb06bef2 | ||
|
|
566b5c8ea5 | ||
|
|
eeccd076a0 | ||
|
|
f45ac9d0ef | ||
|
|
cdd9acddfa | ||
|
|
e484f11d12 | ||
|
|
19572f313a | ||
|
|
af9ad5d624 | ||
|
|
8adca69140 | ||
|
|
01710316ed | ||
|
|
ab80c82a22 | ||
|
|
dceafe65a7 | ||
|
|
4734a81fdb | ||
|
|
a2c099e615 | ||
|
|
f9b88a1b6b | ||
|
|
7f2b8cc971 | ||
|
|
582fb3d72f | ||
|
|
bf4fa74742 | ||
|
|
c62b3b9864 | ||
|
|
0ae9795d6b | ||
|
|
7488c31cd3 | ||
|
|
dc0e634004 | ||
|
|
57e7c0ae4f | ||
|
|
7214acfa20 | ||
|
|
ff6d47bb9c | ||
|
|
81971ba53f | ||
|
|
a58f37e3eb | ||
|
|
09a746cf8e | ||
|
|
ea70119138 | ||
|
|
5a9c5b5e2d | ||
|
|
7fb09c5045 | ||
|
|
705171f305 | ||
|
|
400d1a5f1a | ||
|
|
c4855cc5f2 | ||
|
|
1e4b487299 | ||
|
|
cbd883103e | ||
|
|
2ce12943cd | ||
|
|
4151a82b3b | ||
|
|
b871081ef1 | ||
|
|
da51d1d7d9 | ||
|
|
82dae30224 | ||
|
|
1fdac2fdab | ||
|
|
56b23e27d7 | ||
|
|
ba29aa62d3 | ||
|
|
be2342285f | ||
|
|
5595c01221 | ||
|
|
133cddef5b | ||
|
|
0fdba1f84d | ||
|
|
e10ec26e79 | ||
|
|
ffbb397dba | ||
|
|
799076d0c4 | ||
|
|
1fbd10df27 | ||
|
|
4a2f82f1e8 | ||
|
|
d913e4d90b | ||
|
|
0958c29c9e | ||
|
|
36e1c9f79d | ||
|
|
8fedac53dd | ||
|
|
a1c6159fc5 | ||
|
|
5b33acba5e | ||
|
|
4abaef2943 | ||
|
|
ad71773293 | ||
|
|
5b07309939 | ||
|
|
7a044a1dcd | ||
|
|
6507764157 | ||
|
|
7dfa8fc883 | ||
|
|
6df7965bb2 | ||
|
|
02a3dee764 | ||
|
|
d5895f1710 | ||
|
|
2b90ad3f6d | ||
|
|
0bb61b4296 | ||
|
|
2378fb547c | ||
|
|
a79ff3f417 | ||
|
|
4bc93615c5 | ||
|
|
685f0d93e5 | ||
|
|
1d7b6674bb | ||
|
|
014405e451 | ||
|
|
aff3ca3ad3 | ||
|
|
b723d09952 | ||
|
|
bfff842c82 | ||
|
|
ad7d21764d | ||
|
|
f2ae84b004 | ||
|
|
a81956654e | ||
|
|
3cb662799f | ||
|
|
9188d03d61 | ||
|
|
3df2d36453 | ||
|
|
0ab7eb42e4 | ||
|
|
84d4fb37fa | ||
|
|
aa9b38da03 | ||
|
|
8342867807 | ||
|
|
d8cff865da | ||
|
|
096f7e1c88 | ||
|
|
0608d847f5 | ||
|
|
b20360c2a5 | ||
|
|
59b5086cab | ||
|
|
2e3024ab61 | ||
|
|
b5299719da | ||
|
|
2620f56e0d | ||
|
|
20b978c46c | ||
|
|
5c8a18df68 | ||
|
|
3464d6c324 | ||
|
|
eac0cc0521 | ||
|
|
0953d37303 | ||
|
|
1d3fec2a95 | ||
|
|
61b374b7c0 | ||
|
|
c47cc0d5f1 | ||
|
|
939882efbf | ||
|
|
f42cbf548e | ||
|
|
91075ace37 | ||
|
|
de6405f8d1 | ||
|
|
2ffcaf4a9e | ||
|
|
1bda62309b | ||
|
|
83659e5da8 | ||
|
|
a6e136561e | ||
|
|
a75d7487fc | ||
|
|
31b0dd8d58 | ||
|
|
696bd1f455 | ||
|
|
18355efde2 | ||
|
|
d5100134e4 | ||
|
|
b932242e04 | ||
|
|
73ccff3412 | ||
|
|
e5f852a7ed | ||
|
|
581f19462d | ||
|
|
eb59b37251 | ||
|
|
f3696f60cd | ||
|
|
6b4e21f5db | ||
|
|
3d4d5b7bbc | ||
|
|
e6f15681c0 | ||
|
|
5f52a646ff | ||
|
|
be4f9296a5 | ||
|
|
c3181f589c | ||
|
|
872cd40f56 | ||
|
|
30b9de49bf | ||
|
|
8a91c6eb2f | ||
|
|
243471e21d | ||
|
|
b8f97ec94d | ||
|
|
8576a6f253 | ||
|
|
b33e6ceca9 | ||
|
|
8eaf7f32cd | ||
|
|
becdc8cef5 | ||
|
|
651688219c | ||
|
|
b9a4bb3511 | ||
|
|
b318274129 | ||
|
|
8b0e5ba8e7 | ||
|
|
4a9b74b311 | ||
|
|
371b198eb6 | ||
|
|
a6dfdb2c4c | ||
|
|
3122c2b2a9 | ||
|
|
9ac8d149fb | ||
|
|
91e1b0b3b8 | ||
|
|
01636ced88 | ||
|
|
53e587537f | ||
|
|
77eeacf121 | ||
|
|
a89c42d659 | ||
|
|
ba4bc423f4 | ||
|
|
8cd341576d | ||
|
|
006eae5862 | ||
|
|
6e29de4463 | ||
|
|
92d816b990 | ||
|
|
37ad1968b5 | ||
|
|
01793dd4f6 | ||
|
|
9a7f7fa1d5 | ||
|
|
648675d002 | ||
|
|
462d865fc9 | ||
|
|
9f24851948 | ||
|
|
a5e5ec5098 | ||
|
|
db90546bc3 | ||
|
|
2d9ea3ee8d | ||
|
|
4d3cafcf29 | ||
|
|
8685ffb1bf | ||
|
|
86408b3452 | ||
|
|
eab94f3b84 | ||
|
|
9123dbcc9e | ||
|
|
ec6f426b06 | ||
|
|
5482937332 | ||
|
|
0b667703c2 | ||
|
|
c732c96fc2 | ||
|
|
e3d260429c | ||
|
|
77eb2c747b | ||
|
|
6853cd738f | ||
|
|
d58776beab | ||
|
|
94a7b1e438 | ||
|
|
c3f7540f74 | ||
|
|
4642a50f69 | ||
|
|
b23bcf3f0b | ||
|
|
570678e3d3 | ||
|
|
64a2f5eb11 | ||
|
|
24fba8b382 | ||
|
|
9339ef481a | ||
|
|
075789b902 | ||
|
|
1dd1b47faf | ||
|
|
525a164c69 | ||
|
|
b60f333edb | ||
|
|
2323fdfe56 | ||
|
|
9b7fed4d1f | ||
|
|
67c59c9b4b | ||
|
|
b5fea921e6 | ||
|
|
eeb071afc6 | ||
|
|
5669873101 | ||
|
|
d371c9bc82 | ||
|
|
153dd19fc6 | ||
|
|
ae258a75d9 | ||
|
|
37c4be321f | ||
|
|
c810d58064 | ||
|
|
5a27817d11 | ||
|
|
4be2f9283d | ||
|
|
a65162fbbc | ||
|
|
494cf3b6a8 | ||
|
|
b900194402 | ||
|
|
d6e72e72d7 | ||
|
|
960baadeca | ||
|
|
ed92b37869 | ||
|
|
1598809815 | ||
|
|
3131969fc1 | ||
|
|
fb44bc33b9 | ||
|
|
59a806ac8c | ||
|
|
6a2a27e47e | ||
|
|
251d0af028 | ||
|
|
8fa800e2f7 | ||
|
|
df9d20ad88 | ||
|
|
c07754047d | ||
|
|
9c00af317e | ||
|
|
d3d132ec45 | ||
|
|
7adba972e7 | ||
|
|
d83399bd1f | ||
|
|
4471da4aa9 | ||
|
|
b3be1d9351 | ||
|
|
c6b8c2a630 | ||
|
|
3f51c21dc7 | ||
|
|
a8cdbc4fd6 | ||
|
|
95e5babb13 | ||
|
|
74cb23a8bb | ||
|
|
6be304f295 | ||
|
|
e702b0b733 | ||
|
|
bfbd263c74 | ||
|
|
472f922369 | ||
|
|
b13c608ff3 | ||
|
|
5464909121 | ||
|
|
d874b3f808 | ||
|
|
92108bc743 | ||
|
|
66ca1e52bb | ||
|
|
3589094d06 | ||
|
|
801ed6ef79 | ||
|
|
2547ae45a8 | ||
|
|
93e4abe72d | ||
|
|
eb87651c47 | ||
|
|
4138598db2 | ||
|
|
913bbd6e3b | ||
|
|
d35881b05b | ||
|
|
33b54ccf12 | ||
|
|
a8775b2200 | ||
|
|
6d3746222d | ||
|
|
fe169ac80f | ||
|
|
83724e3d44 | ||
|
|
a4db3ef5c4 | ||
|
|
f8adad7865 | ||
|
|
12c094228e | ||
|
|
902ea80807 | ||
|
|
73831d9ac6 | ||
|
|
6bfda79441 | ||
|
|
112de78fc5 | ||
|
|
adbd2381e1 | ||
|
|
397f3f546e | ||
|
|
43d3c28e16 | ||
|
|
747a64b869 | ||
|
|
7c2e5560bd | ||
|
|
9194db9f70 | ||
|
|
fece00c0c6 | ||
|
|
ba3ae5ea56 | ||
|
|
4c69c9e445 | ||
|
|
97925c47fd | ||
|
|
ad89fe15b1 | ||
|
|
29035cabfe | ||
|
|
9b3d43d27f | ||
|
|
0b4f17473a | ||
|
|
b0c29b57c7 | ||
|
|
6d22f6aebf | ||
|
|
3e3e10e6a0 | ||
|
|
b854c777c8 | ||
|
|
b71fdd77e8 | ||
|
|
0f6f7cea19 | ||
|
|
58beb092c2 | ||
|
|
c3f200f73b | ||
|
|
98640c11b1 | ||
|
|
d7904bdcaf | ||
|
|
ce05a94d58 | ||
|
|
c0ed62dc7a | ||
|
|
1557fa98b1 | ||
|
|
81a530f153 | ||
|
|
a4733c3e6a | ||
|
|
7851b8e94c | ||
|
|
36838f7690 | ||
|
|
fdd87d0757 | ||
|
|
c8822aff64 | ||
|
|
4b3205fc9c | ||
|
|
afc05ae9e8 | ||
|
|
9c3044efa0 | ||
|
|
8caeb129c1 | ||
|
|
0427504f0e | ||
|
|
4f11a7caa1 | ||
|
|
150695c185 | ||
|
|
3558591480 | ||
|
|
f7a24052c2 | ||
|
|
75c452486c | ||
|
|
40cab6775c | ||
|
|
392829c7db | ||
|
|
4621c9d616 | ||
|
|
006d664ec9 | ||
|
|
0cc9ac4dd8 | ||
|
|
502096dc22 | ||
|
|
8c424c7a64 | ||
|
|
d7c118b88a | ||
|
|
1fdb0b7516 | ||
|
|
c2fc771756 | ||
|
|
6f759c5bc4 | ||
|
|
facbbf1353 | ||
|
|
a218c7a781 | ||
|
|
5f42709eab | ||
|
|
5ec0f657a0 | ||
|
|
812911ffbb | ||
|
|
55235687ba | ||
|
|
a970009d20 | ||
|
|
4afc16e2cb | ||
|
|
3772d72b43 | ||
|
|
607f949638 | ||
|
|
473cf7c8af | ||
|
|
e0909df06c | ||
|
|
5fc606ef6d | ||
|
|
a3032f4da7 | ||
|
|
7fdd65e8ca | ||
|
|
dc6bf883f1 | ||
|
|
ce5edd93b4 | ||
|
|
e36e6bec9c | ||
|
|
c8fd08b6d2 | ||
|
|
c0d693c1c8 | ||
|
|
5d2a6e2898 | ||
|
|
4547a5ceb0 | ||
|
|
76151c4395 | ||
|
|
481089b1b4 | ||
|
|
463787b7f4 | ||
|
|
e2258a1c43 | ||
|
|
5528f29b6a | ||
|
|
e3861d54c9 | ||
|
|
3368f2803c | ||
|
|
6ca1d68d23 | ||
|
|
cf93081252 | ||
|
|
30fca2a190 |
@@ -1,27 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "EMS-ESP Devcontainer",
|
|
||||||
"image": "mcr.microsoft.com/devcontainers/typescript-node:1-22-bookworm",
|
|
||||||
"features": {
|
|
||||||
"ghcr.io/devcontainers/features/node:1": {},
|
|
||||||
"ghcr.io/devcontainers-extra/features/pnpm:2": {},
|
|
||||||
"ghcr.io/devcontainers/features/python:1": {},
|
|
||||||
"ghcr.io/shyim/devcontainers-features/bun:0": {}
|
|
||||||
},
|
|
||||||
|
|
||||||
"forwardPorts": [
|
|
||||||
3000,
|
|
||||||
3080
|
|
||||||
],
|
|
||||||
|
|
||||||
// Use 'postCreateCommand' to run commands after the container is created.
|
|
||||||
"postCreateCommand": "cd mock-api && pnpm install && cd .. && cd interface && pnpm install",
|
|
||||||
|
|
||||||
// Configure tool-specific properties.
|
|
||||||
"customizations": {
|
|
||||||
"vscode": {
|
|
||||||
"extensions": [
|
|
||||||
"platformio.platformio-ide"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
2
.github/ISSUE_TEMPLATE/bug_report.md
vendored
2
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -21,7 +21,7 @@ _Make sure your have performed every step and checked the applicable boxes befor
|
|||||||
|
|
||||||
- [ ] Searched the issue in [issues](https://github.com/emsesp/EMS-ESP32/issues)
|
- [ ] Searched the issue in [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://emsesp.org/Troubleshooting/)
|
- [ ] Searched the issue in the [docs](https://docs.emsesp.org/Troubleshooting/)
|
||||||
- [ ] Searched the issue in the [chat](https://discord.gg/3J3GgnzpyT)
|
- [ ] Searched the issue in the [chat](https://discord.gg/3J3GgnzpyT)
|
||||||
- [ ] 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`
|
||||||
|
|
||||||
|
|||||||
2
.github/ISSUE_TEMPLATE/config.yml
vendored
2
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1,7 +1,7 @@
|
|||||||
blank_issues_enabled: false
|
blank_issues_enabled: false
|
||||||
contact_links:
|
contact_links:
|
||||||
- name: EMS-ESP Docs
|
- name: EMS-ESP Docs
|
||||||
url: https://emsesp.org
|
url: https://docs.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
|
||||||
|
|||||||
24
.github/workflows/dev_release.yml
vendored
24
.github/workflows/dev_release.yml
vendored
@@ -64,7 +64,29 @@ jobs:
|
|||||||
- name: Commit the generated files
|
- name: Commit the generated files
|
||||||
uses: stefanzweifel/git-auto-commit-action@v5
|
uses: stefanzweifel/git-auto-commit-action@v5
|
||||||
with:
|
with:
|
||||||
commit_message: "chore: update generated files for v${{steps.build_info.outputs.VERSION}}"
|
commit_message: "chore: update generated files"
|
||||||
|
|
||||||
|
- 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'
|
||||||
|
|||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -2,6 +2,7 @@
|
|||||||
.vscode/c_cpp_properties.json
|
.vscode/c_cpp_properties.json
|
||||||
.vscode/extensions.json
|
.vscode/extensions.json
|
||||||
.vscode/launch.json
|
.vscode/launch.json
|
||||||
|
.vscode/settings.json
|
||||||
|
|
||||||
# c++ compiling
|
# c++ compiling
|
||||||
.clang_complete
|
.clang_complete
|
||||||
@@ -72,6 +73,4 @@ logs/*
|
|||||||
sdkconfig.*
|
sdkconfig.*
|
||||||
sdkconfig_tasmota_esp32
|
sdkconfig_tasmota_esp32
|
||||||
pnpm-lock.yaml
|
pnpm-lock.yaml
|
||||||
.cache/
|
package.json
|
||||||
interface/.tsbuildinfo
|
|
||||||
test/test_api/package-lock.json
|
|
||||||
|
|||||||
88
CHANGELOG.md
88
CHANGELOG.md
@@ -5,92 +5,6 @@ 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.0]
|
|
||||||
|
|
||||||
## 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
|
||||||
@@ -171,7 +85,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 [emsesp.org](https://emsesp.org/).
|
For more details go to [docs.emsesp.org](https://docs.emsesp.org/).
|
||||||
|
|
||||||
## Added
|
## Added
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,57 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
For more details go to [emsesp.org](https://emsesp.org/).
|
For more details go to [docs.emsesp.org](https://docs.emsesp.org/).
|
||||||
|
|
||||||
|
## [3.7.3]
|
||||||
|
|
||||||
|
## 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)
|
||||||
|
- add 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)
|
||||||
|
- 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)
|
||||||
|
|
||||||
|
## 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)
|
||||||
|
|
||||||
|
## Changed
|
||||||
|
|
||||||
|
- show console log with ISO date/time [#2533](https://github.com/emsesp/EMS-ESP32/discussions/2533)
|
||||||
|
- remove 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)
|
||||||
|
|||||||
@@ -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://emsesp.org)
|
- contributing to missing [documentation](https://docs.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.
|
||||||
|
|
||||||
|
|||||||
13
Makefile
13
Makefile
@@ -21,14 +21,13 @@ 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
|
||||||
@@ -47,8 +46,8 @@ 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/semver lib/espMqttClient/src lib/espMqttClient/src/* lib/ArduinoJson/src lib/uuid-common/src lib/uuid-console/src lib/uuid-log/src lib/PButton
|
||||||
INCLUDES := src/core src/devices src/web src/test lib_standalone lib/* lib/semver lib/espMqttClient/src lib/espMqttClient/src/Transport lib/ArduinoJson/src lib/uuid-common/src lib/uuid-console/src lib/uuid-log/src lib/uuid-telnet/src lib/uuid-syslog/src
|
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
|
||||||
LIBRARIES :=
|
LIBRARIES :=
|
||||||
|
|
||||||
CPPCHECK = cppcheck
|
CPPCHECK = cppcheck
|
||||||
@@ -67,7 +66,7 @@ DEFINES += -DARDUINOJSON_ENABLE -DARDUINOJSON_ENABLE_ARDUINO_STRING -DARDUINOJSO
|
|||||||
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 += $(ARGS)
|
DEFINES += $(ARGS)
|
||||||
|
|
||||||
DEFAULTS = -DEMSESP_DEFAULT_LOCALE=\"en\" -DEMSESP_DEFAULT_TX_MODE=8 -DEMSESP_DEFAULT_VERSION=\"3.8.0-dev.0\" -DEMSESP_DEFAULT_BOARD_PROFILE=\"S32S3\"
|
DEFAULTS = -DEMSESP_DEFAULT_LOCALE=\"en\" -DEMSESP_DEFAULT_TX_MODE=8 -DEMSESP_DEFAULT_VERSION=\"3.7.3-dev\" -DEMSESP_DEFAULT_BOARD_PROFILE=\"S32S3\"
|
||||||
|
|
||||||
#----------------------------------------------------------------------
|
#----------------------------------------------------------------------
|
||||||
# Sources & Files
|
# Sources & Files
|
||||||
@@ -138,7 +137,6 @@ 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
|
||||||
@@ -182,7 +180,6 @@ $(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)
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
<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://emsesp.org">
|
<a href="https://docs.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/3J3GgnzpyT">
|
||||||
@@ -60,17 +60,17 @@ It requires a small circuit to interface with the EMS bus which can be purchased
|
|||||||
|
|
||||||
## 🚀 **Installing**
|
## 🚀 **Installing**
|
||||||
|
|
||||||
Head over to the [Installation Guide](https://emsesp.org/Installing) section of the documentation for instructions on how to install EMS-ESP.
|
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.
|
||||||
|
|
||||||
## 📋 **Documentation**
|
## 📋 **Documentation**
|
||||||
|
|
||||||
Visit [emsesp.org](https://emsesp.org) for more details on how to install and configure EMS-ESP. There is also a collection of Frequently Asked Questions and Troubleshooting tips with example customizations from the community.
|
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.
|
||||||
|
|
||||||
## 💬 **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/3J3GgnzpyT).
|
||||||
|
|
||||||
If you find an issue or have a request, see [how to request support](https://emsesp.org/Support/) on how to submit a bug report or feature request.
|
If you find an issue or have a request, see [how to request support](https://docs.emsesp.org/Support/) on how to submit a bug report or feature request.
|
||||||
|
|
||||||
## 🎥 **Live Demo**
|
## 🎥 **Live Demo**
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
"upload": {
|
"upload": {
|
||||||
"flash_size": "32MB",
|
"flash_size": "32MB",
|
||||||
"maximum_ram_size": 327680,
|
"maximum_ram_size": 327680,
|
||||||
"maximum_size": 33554432,
|
"maximum_size": 16777216,
|
||||||
"require_upload_port": true,
|
"require_upload_port": true,
|
||||||
"speed": 460800
|
"speed": 460800
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -34,7 +34,6 @@
|
|||||||
"sdkconfig.*",
|
"sdkconfig.*",
|
||||||
"managed_components/**",
|
"managed_components/**",
|
||||||
"pnpm-*.yaml",
|
"pnpm-*.yaml",
|
||||||
"vite.config.ts",
|
"vite.config.ts"
|
||||||
"lib/esp32-psram/**"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -13,7 +13,7 @@ telegram_type_id,name,is_fetched
|
|||||||
0x19,UBAMonitorSlow,
|
0x19,UBAMonitorSlow,
|
||||||
0x1A,UBASetPoints,
|
0x1A,UBASetPoints,
|
||||||
0x1C,UBAMaintenanceStatus,
|
0x1C,UBAMaintenanceStatus,
|
||||||
0x1E,WM10TempMessage,
|
0x1E,HydrTemp,
|
||||||
0x23,JunkersSetMixer,fetched
|
0x23,JunkersSetMixer,fetched
|
||||||
0x27,UBASettingsWW,fetched
|
0x27,UBASettingsWW,fetched
|
||||||
0x28,WeatherComp,fetched
|
0x28,WeatherComp,fetched
|
||||||
@@ -111,7 +111,7 @@ telegram_type_id,name,is_fetched
|
|||||||
0x02A0,RC300Curves,
|
0x02A0,RC300Curves,
|
||||||
0x02A1,RC300Curves,
|
0x02A1,RC300Curves,
|
||||||
0x02A2,RC300Curves,
|
0x02A2,RC300Curves,
|
||||||
0x02A5,RC300Monitor,fetched
|
0x02A5,RC300Monitor,
|
||||||
0x02A6,RC300Monitor,
|
0x02A6,RC300Monitor,
|
||||||
0x02A7,RC300Monitor,
|
0x02A7,RC300Monitor,
|
||||||
0x02A8,RC300Monitor,
|
0x02A8,RC300Monitor,
|
||||||
@@ -136,7 +136,10 @@ telegram_type_id,name,is_fetched
|
|||||||
0x02BF,RC300Set,
|
0x02BF,RC300Set,
|
||||||
0x02C0,RC300Set,
|
0x02C0,RC300Set,
|
||||||
0x02CC,HPPressure,fetched
|
0x02CC,HPPressure,fetched
|
||||||
0x02CD,MMPLUSConfigMessage,
|
0x02CD,MMPLUSConfigMessage,fetched
|
||||||
|
0x02CE,RC300Set2,
|
||||||
|
0x02D0,RC300Set2,
|
||||||
|
0x02D2,RC300Set2,
|
||||||
0x02D6,HPPump2,fetched
|
0x02D6,HPPump2,fetched
|
||||||
0x02D7,MMPLUSStatusMessage,
|
0x02D7,MMPLUSStatusMessage,
|
||||||
0x02E0,UBASetPoints,
|
0x02E0,UBASetPoints,
|
||||||
@@ -161,10 +164,6 @@ 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,
|
||||||
|
|||||||
|
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "EMS-ESP",
|
"name": "EMS-ESP",
|
||||||
"version": "3.8.0",
|
"version": "3.7.3",
|
||||||
"description": "EMS-ESP WebUI",
|
"description": "EMS-ESP WebUI",
|
||||||
"homepage": "https://emsesp.org",
|
"homepage": "https://emsesp.org",
|
||||||
"author": "proddy, emsesp.org",
|
"author": "proddy, emsesp.org",
|
||||||
@@ -13,59 +13,55 @@
|
|||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"build-hosted": "typesafe-i18n && vite build --mode hosted",
|
"build-hosted": "typesafe-i18n && vite build --mode hosted",
|
||||||
"mock-rest": "bun --watch ../mock-api/restServer.ts",
|
|
||||||
"preview-standalone": "typesafe-i18n --no-watch && vite build && concurrently -c \"auto\" \"pnpm:mock-rest\" \"vite preview\"",
|
"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\"",
|
"mock-rest": "bun --watch ../mock-api/restServer.ts",
|
||||||
|
"standalone": "concurrently -c \"auto\" \"typesafe-i18n\" \"pnpm:mock-rest\" \"vite\"",
|
||||||
"typesafe-i18n": "typesafe-i18n --no-watch",
|
"typesafe-i18n": "typesafe-i18n --no-watch",
|
||||||
"build_webUI": "typesafe-i18n --no-watch && vite build && node progmem-generator.js",
|
"webUI": "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\""
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@alova/adapter-xhr": "2.3.1",
|
"@alova/adapter-xhr": "2.2.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.6",
|
"@mui/icons-material": "^7.3.4",
|
||||||
"@mui/material": "^7.3.6",
|
"@mui/material": "^7.3.4",
|
||||||
"@preact/compat": "^18.3.1",
|
|
||||||
"@table-library/react-table-library": "4.1.15",
|
"@table-library/react-table-library": "4.1.15",
|
||||||
"alova": "3.4.1",
|
"alova": "3.3.4",
|
||||||
"async-validator": "^4.2.5",
|
"async-validator": "^4.2.5",
|
||||||
"etag": "^1.8.1",
|
|
||||||
"formidable": "^3.5.4",
|
"formidable": "^3.5.4",
|
||||||
"jwt-decode": "^4.0.0",
|
"jwt-decode": "^4.0.0",
|
||||||
"magic-string": "^0.30.21",
|
"magic-string": "^0.30.21",
|
||||||
"mime-types": "^3.0.2",
|
"mime-types": "^3.0.1",
|
||||||
"preact": "^10.28.1",
|
"preact": "^10.27.2",
|
||||||
"react": "^19.2.3",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.3",
|
"react-dom": "^19.2.0",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
"react-router": "^7.11.0",
|
"react-router": "^7.9.4",
|
||||||
"react-toastify": "^11.0.5",
|
"react-toastify": "^11.0.5",
|
||||||
"typesafe-i18n": "^5.26.2",
|
"typesafe-i18n": "^5.26.2",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.28.5",
|
"@babel/core": "^7.28.5",
|
||||||
"@eslint/js": "^9.39.2",
|
"@eslint/js": "^9.38.0",
|
||||||
"@preact/compat": "^18.3.1",
|
"@preact/compat": "^18.3.1",
|
||||||
"@preact/preset-vite": "^2.10.2",
|
"@preact/preset-vite": "^2.10.2",
|
||||||
"@trivago/prettier-plugin-sort-imports": "^6.0.0",
|
"@trivago/prettier-plugin-sort-imports": "^5.2.2",
|
||||||
"@types/node": "^25.0.3",
|
"@types/node": "^24.9.1",
|
||||||
"@types/react": "^19.2.7",
|
"@types/react": "^19.2.2",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.2",
|
||||||
"axe-core": "^4.11.0",
|
|
||||||
"concurrently": "^9.2.1",
|
"concurrently": "^9.2.1",
|
||||||
"eslint": "^9.39.2",
|
"eslint": "^9.38.0",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"prettier": "^3.7.4",
|
"prettier": "^3.6.2",
|
||||||
"rollup-plugin-visualizer": "^6.0.5",
|
"rollup-plugin-visualizer": "^6.0.5",
|
||||||
"terser": "^5.44.1",
|
"terser": "^5.44.0",
|
||||||
"typescript-eslint": "^8.51.0",
|
"typescript-eslint": "^8.46.2",
|
||||||
"vite": "^7.3.0",
|
"vite": "^7.1.12",
|
||||||
"vite-plugin-imagemin": "^0.6.1",
|
"vite-plugin-imagemin": "^0.6.1",
|
||||||
"vite-tsconfig-paths": "^6.0.3"
|
"vite-tsconfig-paths": "^5.1.4"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.27.0+sha512.72d699da16b1179c14ba9e64dc71c9a40988cbdc65c264cb0e489db7de917f20dcf4d64d8723625f2969ba52d4b7e2a1170682d9ac2a5dcaeaab732b7e16f04a"
|
"packageManager": "pnpm@10.19.0+sha512.c9fc7236e92adf5c8af42fd5bf1612df99c2ceb62f27047032f4720b33f8eacdde311865e91c411f2774f618d82f320808ecb51718bfa82c060c4ba7c76a32b8"
|
||||||
}
|
}
|
||||||
|
|||||||
6056
interface/pnpm-lock.yaml
generated
Normal file
6056
interface/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,10 @@
|
|||||||
import etag from 'etag';
|
import crypto from 'crypto';
|
||||||
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';
|
||||||
@@ -35,7 +36,7 @@ const generateWWWClass =
|
|||||||
class WWWData {
|
class WWWData {
|
||||||
${INDENT}public:
|
${INDENT}public:
|
||||||
${INDENT.repeat(2)}static void registerRoutes(RouteRegistrationHandler handler) {
|
${INDENT.repeat(2)}static void registerRoutes(RouteRegistrationHandler handler) {
|
||||||
${fileInfo.map((f) => `${INDENT.repeat(3)}handler("${f.uri}", "${f.mimeType}", ${f.variable}, ${f.size}, ${f.hash});`).join('\n')}
|
${fileInfo.map((f) => `${INDENT.repeat(3)}handler("${f.uri}", "${f.mimeType}", ${f.variable}, ${f.size}, "${f.hash}");`).join('\n')}
|
||||||
${INDENT.repeat(2)}}
|
${INDENT.repeat(2)}}
|
||||||
};
|
};
|
||||||
`;
|
`;
|
||||||
@@ -70,8 +71,7 @@ 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
|
|
||||||
|
|
||||||
zipBuffer.forEach((b) => {
|
zipBuffer.forEach((b) => {
|
||||||
if (!(size % bytesPerLine)) {
|
if (!(size % bytesPerLine)) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { memo, useCallback, useEffect, useState } from 'react';
|
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { ToastContainer, Zoom } from 'react-toastify';
|
import { ToastContainer, Zoom } from 'react-toastify';
|
||||||
|
|
||||||
import AppRouting from 'AppRouting';
|
import AppRouting from 'AppRouting';
|
||||||
@@ -8,6 +8,7 @@ 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',
|
||||||
@@ -22,26 +23,6 @@ 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');
|
||||||
@@ -60,13 +41,36 @@ const App = memo(() => {
|
|||||||
void initializeLocale();
|
void initializeLocale();
|
||||||
}, [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;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TypesafeI18n locale={locale}>
|
<TypesafeI18n locale={locale}>
|
||||||
<CustomTheme>
|
<CustomTheme>
|
||||||
<AppRouting />
|
<AppRouting />
|
||||||
<ToastContainer {...TOAST_CONTAINER_PROPS} />
|
<ToastContainer {...toastContainerProps} />
|
||||||
</CustomTheme>
|
</CustomTheme>
|
||||||
</TypesafeI18n>
|
</TypesafeI18n>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,80 +1,60 @@
|
|||||||
import { type FC, Suspense, lazy, memo, useContext, useEffect, useRef } from 'react';
|
import { useContext, useEffect } 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';
|
||||||
|
|
||||||
import {
|
import AuthenticatedRouting from 'AuthenticatedRouting';
|
||||||
LoadingSpinner,
|
import SignIn from 'SignIn';
|
||||||
RequireAuthenticated,
|
import { RequireAuthenticated, RequireUnauthenticated } from 'components';
|
||||||
RequireUnauthenticated
|
|
||||||
} from 'components';
|
|
||||||
import { Authentication, AuthenticationContext } from 'contexts/authentication';
|
import { Authentication, AuthenticationContext } from 'contexts/authentication';
|
||||||
import { useI18nContext } from 'i18n/i18n-react';
|
import { useI18nContext } from 'i18n/i18n-react';
|
||||||
|
|
||||||
// Lazy load route components for better code splitting
|
|
||||||
const SignIn = lazy(() => import('SignIn'));
|
|
||||||
const AuthenticatedRouting = lazy(() => import('AuthenticatedRouting'));
|
|
||||||
|
|
||||||
interface SecurityRedirectProps {
|
interface SecurityRedirectProps {
|
||||||
readonly message: string;
|
message: string;
|
||||||
readonly signOut?: boolean;
|
signOut?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RootRedirect: FC<SecurityRedirectProps> = memo(
|
const RootRedirect = ({ message, signOut }: SecurityRedirectProps) => {
|
||||||
({ message, signOut = false }) => {
|
const authenticationContext = useContext(AuthenticationContext);
|
||||||
const { signOut: contextSignOut } = useContext(AuthenticationContext);
|
useEffect(() => {
|
||||||
const hasShownToast = useRef(false);
|
signOut && authenticationContext.signOut(false);
|
||||||
|
toast.success(message);
|
||||||
|
}, [message, signOut, authenticationContext]);
|
||||||
|
return <Navigate to="/" />;
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
const AppRouting = () => {
|
||||||
// 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 (
|
||||||
<Authentication>
|
<Authentication>
|
||||||
<Suspense fallback={<LoadingSpinner />}>
|
<Routes>
|
||||||
<Routes>
|
<Route
|
||||||
<Route
|
path="/unauthorized"
|
||||||
path="/unauthorized"
|
element={<RootRedirect message={LL.PLEASE_SIGNIN()} signOut />}
|
||||||
element={<RootRedirect message={LL.PLEASE_SIGNIN()} signOut />}
|
/>
|
||||||
/>
|
<Route
|
||||||
<Route
|
path="/fileUpdated"
|
||||||
path="/fileUpdated"
|
element={<RootRedirect message={LL.UPLOAD_SUCCESSFUL()} />}
|
||||||
element={<RootRedirect message={LL.UPLOAD_SUCCESSFUL()} />}
|
/>
|
||||||
/>
|
<Route
|
||||||
<Route
|
path="/"
|
||||||
path="/"
|
element={
|
||||||
element={
|
<RequireUnauthenticated>
|
||||||
<RequireUnauthenticated>
|
<SignIn />
|
||||||
<SignIn />
|
</RequireUnauthenticated>
|
||||||
</RequireUnauthenticated>
|
}
|
||||||
}
|
/>
|
||||||
/>
|
<Route
|
||||||
<Route
|
path="/*"
|
||||||
path="/*"
|
element={
|
||||||
element={
|
<RequireAuthenticated>
|
||||||
<RequireAuthenticated>
|
<AuthenticatedRouting />
|
||||||
<AuthenticatedRouting />
|
</RequireAuthenticated>
|
||||||
</RequireAuthenticated>
|
}
|
||||||
}
|
/>
|
||||||
/>
|
</Routes>
|
||||||
</Routes>
|
|
||||||
</Suspense>
|
|
||||||
</Authentication>
|
</Authentication>
|
||||||
);
|
);
|
||||||
});
|
};
|
||||||
|
|
||||||
export default AppRouting;
|
export default AppRouting;
|
||||||
|
|||||||
@@ -1,88 +1,77 @@
|
|||||||
import { Suspense, lazy, memo, useContext } from 'react';
|
import { useContext } from 'react';
|
||||||
import { Navigate, Route, Routes } from 'react-router';
|
import { Navigate, Route, Routes } from 'react-router';
|
||||||
|
|
||||||
import { Layout, LoadingSpinner } from 'components';
|
import CustomEntities from 'app/main/CustomEntities';
|
||||||
|
import Customizations from 'app/main/Customizations';
|
||||||
|
import Dashboard from 'app/main/Dashboard';
|
||||||
|
import Devices from 'app/main/Devices';
|
||||||
|
import Help from 'app/main/Help';
|
||||||
|
import Modules from 'app/main/Modules';
|
||||||
|
import Scheduler from 'app/main/Scheduler';
|
||||||
|
import Sensors from 'app/main/Sensors';
|
||||||
|
import APSettings from 'app/settings/APSettings';
|
||||||
|
import ApplicationSettings from 'app/settings/ApplicationSettings';
|
||||||
|
import DownloadUpload from 'app/settings/DownloadUpload';
|
||||||
|
import MqttSettings from 'app/settings/MqttSettings';
|
||||||
|
import NTPSettings from 'app/settings/NTPSettings';
|
||||||
|
import Settings from 'app/settings/Settings';
|
||||||
|
import Network from 'app/settings/network/Network';
|
||||||
|
import Security from 'app/settings/security/Security';
|
||||||
|
import APStatus from 'app/status/APStatus';
|
||||||
|
import Activity from 'app/status/Activity';
|
||||||
|
import HardwareStatus from 'app/status/HardwareStatus';
|
||||||
|
import MqttStatus from 'app/status/MqttStatus';
|
||||||
|
import NTPStatus from 'app/status/NTPStatus';
|
||||||
|
import NetworkStatus from 'app/status/NetworkStatus';
|
||||||
|
import Status from 'app/status/Status';
|
||||||
|
import SystemLog from 'app/status/SystemLog';
|
||||||
|
import Version from 'app/status/Version';
|
||||||
|
import { Layout } from 'components';
|
||||||
import { AuthenticatedContext } from 'contexts/authentication';
|
import { AuthenticatedContext } from 'contexts/authentication';
|
||||||
|
|
||||||
// Lazy load all route components for better code splitting
|
const AuthenticatedRouting = () => {
|
||||||
const Dashboard = lazy(() => import('app/main/Dashboard'));
|
|
||||||
const Devices = lazy(() => import('app/main/Devices'));
|
|
||||||
const Sensors = lazy(() => import('app/main/Sensors'));
|
|
||||||
const Help = lazy(() => import('app/main/Help'));
|
|
||||||
const Customizations = lazy(() => import('app/main/Customizations'));
|
|
||||||
const Scheduler = lazy(() => import('app/main/Scheduler'));
|
|
||||||
const CustomEntities = lazy(() => import('app/main/CustomEntities'));
|
|
||||||
const Modules = lazy(() => import('app/main/Modules'));
|
|
||||||
const UserProfile = lazy(() => import('app/main/UserProfile'));
|
|
||||||
|
|
||||||
const Status = lazy(() => import('app/status/Status'));
|
|
||||||
const HardwareStatus = lazy(() => import('app/status/HardwareStatus'));
|
|
||||||
const Activity = lazy(() => import('app/status/Activity'));
|
|
||||||
const SystemLog = lazy(() => import('app/status/SystemLog'));
|
|
||||||
const MqttStatus = lazy(() => import('app/status/MqttStatus'));
|
|
||||||
const NTPStatus = lazy(() => import('app/status/NTPStatus'));
|
|
||||||
const APStatus = lazy(() => import('app/status/APStatus'));
|
|
||||||
const NetworkStatus = lazy(() => import('app/status/NetworkStatus'));
|
|
||||||
const Version = lazy(() => import('app/status/Version'));
|
|
||||||
|
|
||||||
const Settings = lazy(() => import('app/settings/Settings'));
|
|
||||||
const ApplicationSettings = lazy(() => import('app/settings/ApplicationSettings'));
|
|
||||||
const MqttSettings = lazy(() => import('app/settings/MqttSettings'));
|
|
||||||
const NTPSettings = lazy(() => import('app/settings/NTPSettings'));
|
|
||||||
const APSettings = lazy(() => import('app/settings/APSettings'));
|
|
||||||
const DownloadUpload = lazy(() => import('app/settings/DownloadUpload'));
|
|
||||||
const Network = lazy(() => import('app/settings/network/Network'));
|
|
||||||
const Security = lazy(() => import('app/settings/security/Security'));
|
|
||||||
|
|
||||||
const AuthenticatedRouting = memo(() => {
|
|
||||||
const { me } = useContext(AuthenticatedContext);
|
const { me } = useContext(AuthenticatedContext);
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<Suspense fallback={<LoadingSpinner />}>
|
<Routes>
|
||||||
<Routes>
|
<Route path="/dashboard/*" element={<Dashboard />} />
|
||||||
<Route path="/dashboard/*" element={<Dashboard />} />
|
<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 />} />
|
||||||
<Route path="/status/activity" element={<Activity />} />
|
<Route path="/status/activity" element={<Activity />} />
|
||||||
<Route path="/status/log" element={<SystemLog />} />
|
<Route path="/status/log" element={<SystemLog />} />
|
||||||
<Route path="/status/mqtt" element={<MqttStatus />} />
|
<Route path="/status/mqtt" element={<MqttStatus />} />
|
||||||
<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 />} />
|
<Route path="/status/version" element={<Version />} />
|
||||||
|
|
||||||
{me.admin && (
|
{me.admin && (
|
||||||
<>
|
<>
|
||||||
<Route path="/settings" element={<Settings />} />
|
<Route path="/settings" element={<Settings />} />
|
||||||
<Route
|
<Route path="/settings/application" element={<ApplicationSettings />} />
|
||||||
path="/settings/application"
|
<Route path="/settings/mqtt" element={<MqttSettings />} />
|
||||||
element={<ApplicationSettings />}
|
<Route path="/settings/ntp" element={<NTPSettings />} />
|
||||||
/>
|
<Route path="/settings/ap" element={<APSettings />} />
|
||||||
<Route path="/settings/mqtt" element={<MqttSettings />} />
|
<Route path="/settings/modules" element={<Modules />} />
|
||||||
<Route path="/settings/ntp" element={<NTPSettings />} />
|
<Route path="/settings/downloadUpload" element={<DownloadUpload />} />
|
||||||
<Route path="/settings/ap" element={<APSettings />} />
|
|
||||||
<Route path="/settings/modules" element={<Modules />} />
|
|
||||||
<Route path="/settings/downloadUpload" element={<DownloadUpload />} />
|
|
||||||
|
|
||||||
<Route path="/settings/network/*" element={<Network />} />
|
<Route path="/settings/network/*" element={<Network />} />
|
||||||
<Route path="/settings/security/*" element={<Security />} />
|
<Route path="/settings/security/*" element={<Security />} />
|
||||||
|
|
||||||
<Route path="/customizations" element={<Customizations />} />
|
<Route path="/customizations" element={<Customizations />} />
|
||||||
<Route path="/scheduler" element={<Scheduler />} />
|
<Route path="/scheduler" element={<Scheduler />} />
|
||||||
<Route path="/customentities" element={<CustomEntities />} />
|
<Route path="/customentities" element={<CustomEntities />} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Route path="/*" element={<Navigate to="/" />} />
|
<Route path="/*" element={<Navigate to="/" />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Suspense>
|
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
});
|
};
|
||||||
|
|
||||||
export default AuthenticatedRouting;
|
export default AuthenticatedRouting;
|
||||||
|
|||||||
@@ -11,15 +11,17 @@ 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: '2px'
|
borderWidth: '1px'
|
||||||
}
|
}
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
// Memoize theme creation to prevent recreation
|
||||||
const theme = responsiveFontSizes(
|
const theme = responsiveFontSizes(
|
||||||
createTheme({
|
createTheme({
|
||||||
typography: {
|
typography: {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { memo, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
import { useContext, 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';
|
||||||
@@ -19,7 +19,7 @@ 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, validate } from 'validators';
|
||||||
|
|
||||||
const SignIn = memo(() => {
|
const SignIn = () => {
|
||||||
const authenticationContext = useContext(AuthenticationContext);
|
const authenticationContext = useContext(AuthenticationContext);
|
||||||
|
|
||||||
const { LL } = useI18nContext();
|
const { LL } = useI18nContext();
|
||||||
@@ -42,18 +42,9 @@ const SignIn = memo(() => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Memoize callback to prevent recreation on every render
|
const updateLoginRequestValue = updateValue(setSignInRequest);
|
||||||
const updateLoginRequestValue = useMemo(
|
|
||||||
() =>
|
|
||||||
updateValue((updater) =>
|
|
||||||
setSignInRequest(
|
|
||||||
updater as unknown as (prevState: SignInRequest) => SignInRequest
|
|
||||||
)
|
|
||||||
),
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const signIn = useCallback(async () => {
|
const signIn = 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());
|
||||||
@@ -62,9 +53,9 @@ const SignIn = memo(() => {
|
|||||||
}
|
}
|
||||||
setProcessing(false);
|
setProcessing(false);
|
||||||
});
|
});
|
||||||
}, [callSignIn, signInRequest, LL]);
|
};
|
||||||
|
|
||||||
const validateAndSignIn = useCallback(async () => {
|
const validateAndSignIn = async () => {
|
||||||
setProcessing(true);
|
setProcessing(true);
|
||||||
SIGN_IN_REQUEST_VALIDATOR.messages({
|
SIGN_IN_REQUEST_VALIDATOR.messages({
|
||||||
required: LL.IS_REQUIRED('%s')
|
required: LL.IS_REQUIRED('%s')
|
||||||
@@ -76,19 +67,9 @@ const SignIn = memo(() => {
|
|||||||
setFieldErrors(error as ValidateFieldsError);
|
setFieldErrors(error as ValidateFieldsError);
|
||||||
setProcessing(false);
|
setProcessing(false);
|
||||||
}
|
}
|
||||||
}, [signInRequest, signIn, LL]);
|
};
|
||||||
|
|
||||||
// Memoize callback to prevent recreation on every render
|
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
|
||||||
@@ -111,27 +92,23 @@ const SignIn = memo(() => {
|
|||||||
width: '100%'
|
width: '100%'
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Typography mb={1} variant="h4">
|
<Typography variant="h4">{PROJECT_NAME}</Typography>
|
||||||
{PROJECT_NAME}
|
|
||||||
</Typography>
|
|
||||||
<LanguageSelector />
|
<LanguageSelector />
|
||||||
<Box
|
|
||||||
mt={1}
|
<Box display="flex" flexDirection="column" alignItems="center">
|
||||||
display="flex"
|
|
||||||
flexDirection="column"
|
|
||||||
gap={1}
|
|
||||||
alignItems="center"
|
|
||||||
>
|
|
||||||
<ValidatedTextField
|
<ValidatedTextField
|
||||||
fieldErrors={fieldErrors || {}}
|
fieldErrors={fieldErrors || {}}
|
||||||
disabled={processing}
|
disabled={processing}
|
||||||
sx={{
|
sx={{
|
||||||
width: '32ch'
|
width: 240
|
||||||
}}
|
}}
|
||||||
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',
|
||||||
@@ -143,13 +120,14 @@ const SignIn = memo(() => {
|
|||||||
fieldErrors={fieldErrors || {}}
|
fieldErrors={fieldErrors || {}}
|
||||||
disabled={processing}
|
disabled={processing}
|
||||||
sx={{
|
sx={{
|
||||||
width: '32ch'
|
width: 240
|
||||||
}}
|
}}
|
||||||
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>
|
||||||
|
|
||||||
@@ -166,6 +144,6 @@ const SignIn = memo(() => {
|
|||||||
</Paper>
|
</Paper>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
});
|
};
|
||||||
|
|
||||||
export default SignIn;
|
export default SignIn;
|
||||||
|
|||||||
@@ -20,18 +20,19 @@ 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', MSGPACK_CONFIG);
|
alovaInstance.Get<DashboardData>('/rest/dashboardData', {
|
||||||
|
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 },
|
||||||
...MSGPACK_CONFIG
|
responseType: 'arraybuffer' // uses msgpack
|
||||||
});
|
});
|
||||||
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);
|
||||||
@@ -65,13 +66,13 @@ export const callAction = (action: Action) =>
|
|||||||
|
|
||||||
// SettingsCustomization
|
// SettingsCustomization
|
||||||
export const readDeviceEntities = (id: number) =>
|
export const readDeviceEntities = (id: number) =>
|
||||||
alovaInstance.Get<DeviceEntity[]>('/rest/deviceEntities', {
|
// alovaInstance.Get<DeviceEntity[]>(`/rest/deviceEntities/${id}`, {
|
||||||
|
alovaInstance.Get<DeviceEntity[]>(`/rest/deviceEntities`, {
|
||||||
params: { id },
|
params: { id },
|
||||||
...MSGPACK_CONFIG,
|
responseType: 'arraybuffer',
|
||||||
// @ts-expect-error - exactOptionalPropertyTypes compatibility issue
|
// @ts-expect-error - exactOptionalPropertyTypes compatibility issue
|
||||||
transform(data) {
|
transform(data) {
|
||||||
const entities = data as DeviceEntity[];
|
return (data as DeviceEntity[]).map((de: DeviceEntity) => ({
|
||||||
return entities.map((de) => ({
|
|
||||||
...de,
|
...de,
|
||||||
o_m: de.m,
|
o_m: de.m,
|
||||||
o_cn: de.cn,
|
o_cn: de.cn,
|
||||||
@@ -94,8 +95,7 @@ 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) {
|
||||||
const schedule = (data as Schedule).schedule;
|
return (data as Schedule).schedule.map((si: ScheduleItem) => ({
|
||||||
return schedule.map((si) => ({
|
|
||||||
...si,
|
...si,
|
||||||
o_id: si.id,
|
o_id: si.id,
|
||||||
o_active: si.active,
|
o_active: si.active,
|
||||||
@@ -115,8 +115,7 @@ 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) {
|
||||||
const modules = (data as Modules).modules;
|
return (data as Modules).modules.map((mi: ModuleItem) => ({
|
||||||
return modules.map((mi) => ({
|
|
||||||
...mi,
|
...mi,
|
||||||
o_enabled: mi.enabled,
|
o_enabled: mi.enabled,
|
||||||
o_license: mi.license
|
o_license: mi.license
|
||||||
@@ -134,8 +133,7 @@ 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) {
|
||||||
const entities = (data as Entities).entities;
|
return (data as Entities).entities.map((ei: EntityItem) => ({
|
||||||
return entities.map((ei) => ({
|
|
||||||
...ei,
|
...ei,
|
||||||
o_id: ei.id,
|
o_id: ei.id,
|
||||||
o_ram: ei.ram,
|
o_ram: ei.ram,
|
||||||
|
|||||||
@@ -4,57 +4,55 @@ import ReactHook from 'alova/react';
|
|||||||
|
|
||||||
import { unpack } from './unpack';
|
import { unpack } from './unpack';
|
||||||
|
|
||||||
export const ACCESS_TOKEN = 'access_token' as const;
|
export const ACCESS_TOKEN = 'access_token';
|
||||||
|
|
||||||
// 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) {
|
||||||
const token = getAccessToken();
|
if (localStorage.getItem(ACCESS_TOKEN)) {
|
||||||
if (token) {
|
method.config.headers.Authorization =
|
||||||
method.config.headers.Authorization = `Bearer ${token}`;
|
'Bearer ' + localStorage.getItem(ACCESS_TOKEN);
|
||||||
}
|
}
|
||||||
|
// for simulating very slow networks
|
||||||
|
// return new Promise((resolve) => {
|
||||||
|
// const random = 3000 + Math.random() * 2000;
|
||||||
|
// setTimeout(resolve, Math.floor(random));
|
||||||
|
// });
|
||||||
},
|
},
|
||||||
|
|
||||||
responded: {
|
responded: {
|
||||||
onSuccess: handleResponse
|
onSuccess: async (response: AlovaXHRResponse) => {
|
||||||
|
// if (response.status === 202) {
|
||||||
|
// throw new Error('Wait'); // wifi scan in progress
|
||||||
|
// } else
|
||||||
|
if (response.status === 205) {
|
||||||
|
throw new Error('Reboot required');
|
||||||
|
} else if (response.status === 400) {
|
||||||
|
throw new Error('Request Failed');
|
||||||
|
} else if (response.status >= 400) {
|
||||||
|
throw new Error(response.statusText);
|
||||||
|
}
|
||||||
|
const data: ArrayBuffer = (await response.data) as ArrayBuffer;
|
||||||
|
if (response.data instanceof ArrayBuffer) {
|
||||||
|
return unpack(data) as ArrayBuffer;
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interceptor for request failure. This interceptor will be entered when the request is wrong.
|
||||||
|
// http errors like 401 (unauthorized) are handled either in the methods or AuthenticatedRouting()
|
||||||
|
// onError: (error, method) => {
|
||||||
|
// alert(error.message);
|
||||||
|
// }
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,14 +2,12 @@ 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: LIST_NETWORKS_TIMEOUT
|
timeout: 20000 // 20 seconds
|
||||||
});
|
});
|
||||||
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);
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ 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');
|
||||||
@@ -36,12 +36,10 @@ export const getDevVersion = () =>
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const UPLOAD_TIMEOUT = 60000; // 1 minute
|
|
||||||
|
|
||||||
export const uploadFile = (file: File) => {
|
export const uploadFile = (file: File) => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
return alovaInstance.Post('/rest/uploadFile', formData, {
|
return alovaInstance.Post('/rest/uploadFile', formData, {
|
||||||
timeout: UPLOAD_TIMEOUT
|
timeout: 60000 // override timeout for uploading firmware - 1 minute
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export class Unpackr {
|
|||||||
}
|
}
|
||||||
Object.assign(this, options);
|
Object.assign(this, options);
|
||||||
}
|
}
|
||||||
unpack(source, options?: { start?: number; end?: number; lazy?: boolean }) {
|
unpack(source, options?: any) {
|
||||||
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?: { lazy?: boolean }) {
|
function checkedRead(options: any) {
|
||||||
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, useMemo, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
import { useBlocker } from 'react-router';
|
import { useBlocker } from 'react-router';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
@@ -35,10 +35,6 @@ 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);
|
||||||
@@ -57,20 +53,18 @@ const CustomEntities = () => {
|
|||||||
initialData: []
|
initialData: []
|
||||||
});
|
});
|
||||||
|
|
||||||
const intervalCallback = useCallback(() => {
|
useInterval(() => {
|
||||||
if (!dialogOpen && !numChanges) {
|
if (!dialogOpen && !numChanges) {
|
||||||
void fetchEntities();
|
void fetchEntities();
|
||||||
}
|
}
|
||||||
}, [dialogOpen, numChanges, fetchEntities]);
|
});
|
||||||
|
|
||||||
useInterval(intervalCallback);
|
|
||||||
|
|
||||||
const { send: writeEntities } = useRequest(
|
const { send: writeEntities } = useRequest(
|
||||||
(data: Entities) => writeCustomEntities(data),
|
(data: Entities) => writeCustomEntities(data),
|
||||||
{ immediate: false }
|
{ immediate: false }
|
||||||
);
|
);
|
||||||
|
|
||||||
const hasEntityChanged = useCallback((ei: EntityItem) => {
|
function 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 ||
|
||||||
@@ -86,21 +80,19 @@ 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 = useMemo(
|
const entity_theme = useTheme({
|
||||||
() =>
|
Table: `
|
||||||
useTheme({
|
|
||||||
Table: `
|
|
||||||
--data-table-library_grid-template-columns: repeat(1, minmax(60px, 1fr)) minmax(80px, auto) 80px 80px 80px 120px;
|
--data-table-library_grid-template-columns: repeat(1, minmax(60px, 1fr)) minmax(80px, auto) 80px 80px 80px 120px;
|
||||||
`,
|
`,
|
||||||
BaseRow: `
|
BaseRow: `
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
.td {
|
.td {
|
||||||
height: 32px;
|
height: 32px;
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
BaseCell: `
|
BaseCell: `
|
||||||
&:nth-of-type(1) {
|
&:nth-of-type(1) {
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
}
|
}
|
||||||
@@ -120,7 +112,7 @@ const CustomEntities = () => {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
HeaderRow: `
|
HeaderRow: `
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
background-color: black;
|
background-color: black;
|
||||||
color: #90CAF9;
|
color: #90CAF9;
|
||||||
@@ -129,7 +121,7 @@ const CustomEntities = () => {
|
|||||||
height: 36px;
|
height: 36px;
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
Row: `
|
Row: `
|
||||||
background-color: #1e1e1e;
|
background-color: #1e1e1e;
|
||||||
position: relative;
|
position: relative;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -140,11 +132,9 @@ const CustomEntities = () => {
|
|||||||
background-color: #177ac9;
|
background-color: #177ac9;
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
}),
|
});
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const saveEntities = useCallback(async () => {
|
const saveEntities = async () => {
|
||||||
await writeEntities({
|
await writeEntities({
|
||||||
entities: entities
|
entities: entities
|
||||||
.filter((ei: EntityItem) => !ei.deleted)
|
.filter((ei: EntityItem) => !ei.deleted)
|
||||||
@@ -173,7 +163,7 @@ const CustomEntities = () => {
|
|||||||
await fetchEntities();
|
await fetchEntities();
|
||||||
setNumChanges(0);
|
setNumChanges(0);
|
||||||
});
|
});
|
||||||
}, [entities, writeEntities, LL, fetchEntities]);
|
};
|
||||||
|
|
||||||
const editEntityItem = useCallback((ei: EntityItem) => {
|
const editEntityItem = useCallback((ei: EntityItem) => {
|
||||||
setCreating(false);
|
setCreating(false);
|
||||||
@@ -181,39 +171,36 @@ const CustomEntities = () => {
|
|||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const onDialogClose = useCallback(() => {
|
const onDialogClose = () => {
|
||||||
setDialogOpen(false);
|
setDialogOpen(false);
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const onDialogCancel = useCallback(async () => {
|
const onDialogCancel = async () => {
|
||||||
await fetchEntities().then(() => {
|
await fetchEntities().then(() => {
|
||||||
setNumChanges(0);
|
setNumChanges(0);
|
||||||
});
|
});
|
||||||
}, [fetchEntities]);
|
};
|
||||||
|
|
||||||
const onDialogSave = useCallback(
|
const onDialogSave = (updatedItem: EntityItem) => {
|
||||||
(updatedItem: EntityItem) => {
|
setDialogOpen(false);
|
||||||
setDialogOpen(false);
|
void updateState(readCustomEntities(), (data: EntityItem[]) => {
|
||||||
void updateState(readCustomEntities(), (data: EntityItem[]) => {
|
const new_data = creating
|
||||||
const new_data = creating
|
? [
|
||||||
? [
|
...data.filter((ei) => creating || ei.o_id !== updatedItem.o_id),
|
||||||
...data.filter((ei) => creating || ei.o_id !== updatedItem.o_id),
|
updatedItem
|
||||||
updatedItem
|
]
|
||||||
]
|
: data.map((ei) =>
|
||||||
: data.map((ei) =>
|
ei.id === updatedItem.id ? { ...ei, ...updatedItem } : ei
|
||||||
ei.id === updatedItem.id ? { ...ei, ...updatedItem } : ei
|
);
|
||||||
);
|
setNumChanges(new_data.filter((ei) => hasEntityChanged(ei)).length);
|
||||||
setNumChanges(new_data.filter((ei) => hasEntityChanged(ei)).length);
|
return new_data;
|
||||||
return new_data;
|
});
|
||||||
});
|
};
|
||||||
},
|
|
||||||
[creating, hasEntityChanged]
|
|
||||||
);
|
|
||||||
|
|
||||||
const onDialogDup = useCallback((item: EntityItem) => {
|
const onDialogDup = (item: EntityItem) => {
|
||||||
setCreating(true);
|
setCreating(true);
|
||||||
setSelectedEntityItem({
|
setSelectedEntityItem({
|
||||||
id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID),
|
id: Math.floor(Math.random() * (Math.floor(200) - 100) + 100),
|
||||||
name: item.name + '_',
|
name: item.name + '_',
|
||||||
ram: item.ram,
|
ram: item.ram,
|
||||||
device_id: item.device_id,
|
device_id: item.device_id,
|
||||||
@@ -228,12 +215,12 @@ const CustomEntities = () => {
|
|||||||
value: item.value
|
value: item.value
|
||||||
});
|
});
|
||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const addEntityItem = useCallback(() => {
|
const addEntityItem = () => {
|
||||||
setCreating(true);
|
setCreating(true);
|
||||||
setSelectedEntityItem({
|
setSelectedEntityItem({
|
||||||
id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID),
|
id: Math.floor(Math.random() * (Math.floor(200) - 100) + 100),
|
||||||
name: '',
|
name: '',
|
||||||
ram: 0,
|
ram: 0,
|
||||||
device_id: '0',
|
device_id: '0',
|
||||||
@@ -248,30 +235,22 @@ const CustomEntities = () => {
|
|||||||
value: ''
|
value: ''
|
||||||
});
|
});
|
||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const formatValue = useCallback((value: unknown, uom: number) => {
|
function 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]);
|
||||||
}, []);
|
}
|
||||||
|
|
||||||
const showHex = useCallback((value: number, digit: number) => {
|
function 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 = useMemo(
|
const renderEntity = () => {
|
||||||
() =>
|
|
||||||
entities
|
|
||||||
?.filter((ei: EntityItem) => !ei.deleted)
|
|
||||||
.sort((a: EntityItem, b: EntityItem) => a.name.localeCompare(b.name)) ?? [],
|
|
||||||
[entities]
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderEntity = useCallback(() => {
|
|
||||||
if (!entities) {
|
if (!entities) {
|
||||||
return (
|
return (
|
||||||
<FormLoader onRetry={fetchEntities} errorMessage={error?.message || ''} />
|
<FormLoader onRetry={fetchEntities} errorMessage={error?.message || ''} />
|
||||||
@@ -281,7 +260,9 @@ const CustomEntities = () => {
|
|||||||
return (
|
return (
|
||||||
<Table
|
<Table
|
||||||
data={{
|
data={{
|
||||||
nodes: filteredAndSortedEntities
|
nodes: entities
|
||||||
|
.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 }}
|
||||||
@@ -304,10 +285,7 @@ const CustomEntities = () => {
|
|||||||
<Cell>
|
<Cell>
|
||||||
{ei.name}
|
{ei.name}
|
||||||
{ei.writeable && (
|
{ei.writeable && (
|
||||||
<EditOutlinedIcon
|
<EditOutlinedIcon color="primary" sx={{ fontSize: 12 }} />
|
||||||
color="primary"
|
|
||||||
sx={{ fontSize: ICON_SIZE }}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</Cell>
|
</Cell>
|
||||||
<Cell>
|
<Cell>
|
||||||
@@ -326,17 +304,7 @@ const CustomEntities = () => {
|
|||||||
)}
|
)}
|
||||||
</Table>
|
</Table>
|
||||||
);
|
);
|
||||||
}, [
|
};
|
||||||
entities,
|
|
||||||
error,
|
|
||||||
fetchEntities,
|
|
||||||
entity_theme,
|
|
||||||
editEntityItem,
|
|
||||||
LL,
|
|
||||||
filteredAndSortedEntities,
|
|
||||||
showHex,
|
|
||||||
formatValue
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionContent>
|
<SectionContent>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import AddIcon from '@mui/icons-material/Add';
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
import CancelIcon from '@mui/icons-material/Cancel';
|
import CancelIcon from '@mui/icons-material/Cancel';
|
||||||
@@ -33,19 +33,6 @@ import { 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;
|
||||||
@@ -68,97 +55,64 @@ 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 = useMemo(
|
const updateFormValue = updateValue(setEditItem);
|
||||||
() =>
|
|
||||||
updateValue(
|
|
||||||
setEditItem as unknown as React.Dispatch<
|
|
||||||
React.SetStateAction<Record<string, unknown>>
|
|
||||||
>
|
|
||||||
),
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
setFieldErrors(undefined);
|
setFieldErrors(undefined);
|
||||||
// Convert to hex strings - combined into single setEditItem call
|
setEditItem(selectedItem);
|
||||||
const deviceIdHex =
|
// convert to hex strings straight away
|
||||||
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: deviceIdHex,
|
device_id: selectedItem.device_id.toString(16).toUpperCase(),
|
||||||
type_id: typeIdHex,
|
type_id: selectedItem.type_id.toString(16).toUpperCase(),
|
||||||
factor: factorValue
|
factor:
|
||||||
|
selectedItem.value_type === DeviceValueType.BOOL
|
||||||
|
? selectedItem.factor.toString(16).toUpperCase()
|
||||||
|
: selectedItem.factor
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [open, selectedItem]);
|
}, [open, selectedItem]);
|
||||||
|
|
||||||
const handleClose = useCallback(
|
const handleClose = (
|
||||||
(_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => {
|
_event: React.SyntheticEvent,
|
||||||
if (reason !== 'backdropClick') {
|
reason: 'backdropClick' | 'escapeKeyDown'
|
||||||
onClose();
|
) => {
|
||||||
}
|
if (reason !== 'backdropClick') {
|
||||||
},
|
onClose();
|
||||||
[onClose]
|
}
|
||||||
);
|
};
|
||||||
|
|
||||||
const save = useCallback(async () => {
|
const save = async () => {
|
||||||
try {
|
try {
|
||||||
setFieldErrors(undefined);
|
setFieldErrors(undefined);
|
||||||
await validate(validator, editItem);
|
await validate(validator, editItem);
|
||||||
|
if (typeof editItem.device_id === 'string') {
|
||||||
// Create a copy to avoid mutating the state directly
|
editItem.device_id = parseInt(editItem.device_id, 16);
|
||||||
const processedItem: EntityItem = { ...editItem };
|
|
||||||
|
|
||||||
if (typeof processedItem.device_id === 'string') {
|
|
||||||
processedItem.device_id = Number.parseInt(processedItem.device_id, 16);
|
|
||||||
}
|
}
|
||||||
if (typeof processedItem.type_id === 'string') {
|
if (typeof editItem.type_id === 'string') {
|
||||||
processedItem.type_id = Number.parseInt(processedItem.type_id, 16);
|
editItem.type_id = parseInt(editItem.type_id, 16);
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
processedItem.value_type === DeviceValueType.BOOL &&
|
editItem.value_type === DeviceValueType.BOOL &&
|
||||||
typeof processedItem.factor === 'string'
|
typeof editItem.factor === 'string'
|
||||||
) {
|
) {
|
||||||
processedItem.factor = Number.parseInt(processedItem.factor, 16);
|
editItem.factor = parseInt(editItem.factor, 16);
|
||||||
}
|
}
|
||||||
onSave(processedItem);
|
onSave(editItem);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setFieldErrors(error as ValidateFieldsError);
|
setFieldErrors(error as ValidateFieldsError);
|
||||||
}
|
}
|
||||||
}, [validator, editItem, onSave]);
|
};
|
||||||
|
|
||||||
const remove = useCallback(() => {
|
const remove = () => {
|
||||||
const itemWithDeleted = { ...editItem, deleted: true };
|
editItem.deleted = true;
|
||||||
onSave(itemWithDeleted);
|
onSave(editItem);
|
||||||
}, [editItem, onSave]);
|
};
|
||||||
|
|
||||||
const dup = useCallback(() => {
|
const dup = () => {
|
||||||
onDup(editItem);
|
onDup(editItem);
|
||||||
}, [editItem, onDup]);
|
};
|
||||||
|
|
||||||
// Memoize UOM menu items to avoid recreating on every render
|
|
||||||
const uomMenuItems = useMemo(
|
|
||||||
() =>
|
|
||||||
DeviceValueUOM_s.map((val, i) => (
|
|
||||||
<MenuItem key={val} value={i}>
|
|
||||||
{val}
|
|
||||||
</MenuItem>
|
|
||||||
)),
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
|
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
|
||||||
@@ -166,6 +120,9 @@ const CustomEntitiesDialog = ({
|
|||||||
{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
|
||||||
@@ -230,7 +187,11 @@ const CustomEntitiesDialog = ({
|
|||||||
onChange={updateFormValue}
|
onChange={updateFormValue}
|
||||||
select
|
select
|
||||||
>
|
>
|
||||||
{uomMenuItems}
|
{DeviceValueUOM_s.map((val, i) => (
|
||||||
|
<MenuItem key={val} value={i}>
|
||||||
|
{val}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
</TextField>
|
</TextField>
|
||||||
</Grid>
|
</Grid>
|
||||||
</>
|
</>
|
||||||
@@ -314,11 +275,33 @@ const CustomEntitiesDialog = ({
|
|||||||
margin="normal"
|
margin="normal"
|
||||||
select
|
select
|
||||||
>
|
>
|
||||||
{VALUE_TYPE_OPTIONS.map((valueType) => (
|
<MenuItem value={DeviceValueType.BOOL}>
|
||||||
<MenuItem key={valueType} value={valueType}>
|
{DeviceValueTypeNames[DeviceValueType.BOOL]}
|
||||||
{DeviceValueTypeNames[valueType]}
|
</MenuItem>
|
||||||
</MenuItem>
|
<MenuItem value={DeviceValueType.INT8}>
|
||||||
))}
|
{DeviceValueTypeNames[DeviceValueType.INT8]}
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem value={DeviceValueType.UINT8}>
|
||||||
|
{DeviceValueTypeNames[DeviceValueType.UINT8]}
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem value={DeviceValueType.INT16}>
|
||||||
|
{DeviceValueTypeNames[DeviceValueType.INT16]}
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem value={DeviceValueType.UINT16}>
|
||||||
|
{DeviceValueTypeNames[DeviceValueType.UINT16]}
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem value={DeviceValueType.UINT24}>
|
||||||
|
{DeviceValueTypeNames[DeviceValueType.UINT24]}
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem value={DeviceValueType.TIME}>
|
||||||
|
{DeviceValueTypeNames[DeviceValueType.TIME]}
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem value={DeviceValueType.UINT32}>
|
||||||
|
{DeviceValueTypeNames[DeviceValueType.UINT32]}
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem value={DeviceValueType.STRING}>
|
||||||
|
{DeviceValueTypeNames[DeviceValueType.STRING]}
|
||||||
|
</MenuItem>
|
||||||
</TextField>
|
</TextField>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
@@ -350,7 +333,11 @@ const CustomEntitiesDialog = ({
|
|||||||
onChange={updateFormValue}
|
onChange={updateFormValue}
|
||||||
select
|
select
|
||||||
>
|
>
|
||||||
{uomMenuItems}
|
{DeviceValueUOM_s.map((val, i) => (
|
||||||
|
<MenuItem key={val} value={i}>
|
||||||
|
{val}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
</TextField>
|
</TextField>
|
||||||
</Grid>
|
</Grid>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, 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,24 +62,7 @@ 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();
|
||||||
@@ -170,19 +153,17 @@ const Customizations = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const entities_theme = useMemo(
|
const entities_theme = useTheme({
|
||||||
() =>
|
Table: `
|
||||||
useTheme({
|
|
||||||
Table: `
|
|
||||||
--data-table-library_grid-template-columns: 156px repeat(1, minmax(80px, 1fr)) 45px minmax(45px, auto) minmax(120px, auto);
|
--data-table-library_grid-template-columns: 156px repeat(1, minmax(80px, 1fr)) 45px minmax(45px, auto) minmax(120px, auto);
|
||||||
`,
|
`,
|
||||||
BaseRow: `
|
BaseRow: `
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
.td {
|
.td {
|
||||||
height: 32px;
|
height: 32px;
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
BaseCell: `
|
BaseCell: `
|
||||||
&:nth-of-type(3) {
|
&:nth-of-type(3) {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
@@ -193,7 +174,7 @@ const Customizations = () => {
|
|||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
HeaderRow: `
|
HeaderRow: `
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
background-color: black;
|
background-color: black;
|
||||||
color: #90CAF9;
|
color: #90CAF9;
|
||||||
@@ -205,7 +186,7 @@ const Customizations = () => {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
Row: `
|
Row: `
|
||||||
background-color: #1e1e1e;
|
background-color: #1e1e1e;
|
||||||
position: relative;
|
position: relative;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -221,7 +202,7 @@ const Customizations = () => {
|
|||||||
background-color: #177ac9;
|
background-color: #177ac9;
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
Cell: `
|
Cell: `
|
||||||
&:nth-of-type(2) {
|
&:nth-of-type(2) {
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
}
|
}
|
||||||
@@ -235,9 +216,7 @@ const Customizations = () => {
|
|||||||
padding-right: 8px;
|
padding-right: 8px;
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
}),
|
});
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
function hasEntityChanged(de: DeviceEntity) {
|
function hasEntityChanged(de: DeviceEntity) {
|
||||||
return (
|
return (
|
||||||
@@ -250,8 +229,19 @@ const Customizations = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (deviceEntities.length) {
|
if (deviceEntities.length) {
|
||||||
const changedEntities = deviceEntities.filter((de) => hasEntityChanged(de));
|
setNumChanges(
|
||||||
setNumChanges(changedEntities.length);
|
deviceEntities
|
||||||
|
.filter((de) => hasEntityChanged(de))
|
||||||
|
.map(
|
||||||
|
(new_de) =>
|
||||||
|
new_de.m.toString(16).padStart(2, '0') +
|
||||||
|
new_de.id +
|
||||||
|
(new_de.cn || new_de.mi || new_de.ma ? '|' : '') +
|
||||||
|
(new_de.cn ? new_de.cn : '') +
|
||||||
|
(new_de.mi ? '>' + new_de.mi : '') +
|
||||||
|
(new_de.ma ? '<' + new_de.ma : '')
|
||||||
|
).length
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}, [deviceEntities]);
|
}, [deviceEntities]);
|
||||||
|
|
||||||
@@ -285,26 +275,18 @@ const Customizations = () => {
|
|||||||
return value as string;
|
return value as string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isCommand = useCallback((de: DeviceEntity) => {
|
const formatName = (de: DeviceEntity, withShortname: boolean) =>
|
||||||
return de.n && de.n[0] === '!';
|
(de.n && de.n[0] === '!'
|
||||||
}, []);
|
? de.t
|
||||||
|
? LL.COMMAND(1) + ': ' + de.t + ' ' + de.n.slice(1)
|
||||||
const formatName = useCallback(
|
: LL.COMMAND(1) + ': ' + de.n.slice(1)
|
||||||
(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;
|
|
||||||
},
|
|
||||||
[LL]
|
|
||||||
);
|
|
||||||
|
|
||||||
const getMaskNumber = (newMask: string[]) => {
|
const getMaskNumber = (newMask: string[]) => {
|
||||||
let new_mask = 0;
|
let new_mask = 0;
|
||||||
@@ -334,33 +316,34 @@ const Customizations = () => {
|
|||||||
return new_masks;
|
return new_masks;
|
||||||
};
|
};
|
||||||
|
|
||||||
const filter_entity = useCallback(
|
const filter_entity = (de: DeviceEntity) =>
|
||||||
(de: DeviceEntity) =>
|
(de.m & selectedFilters || !selectedFilters) &&
|
||||||
(de.m & selectedFilters || !selectedFilters) &&
|
formatName(de, true).toLowerCase().includes(search.toLowerCase());
|
||||||
formatName(de, true).toLowerCase().includes(search.toLowerCase()),
|
|
||||||
[selectedFilters, search, formatName]
|
|
||||||
);
|
|
||||||
|
|
||||||
const maskDisabled = useCallback(
|
const maskDisabled = (set: boolean) => {
|
||||||
(set: boolean) => {
|
setDeviceEntities(
|
||||||
setDeviceEntities((prev) =>
|
deviceEntities.map(function (de) {
|
||||||
prev.map((de) => {
|
if (filter_entity(de)) {
|
||||||
if (filter_entity(de)) {
|
return {
|
||||||
const excludeMask =
|
...de,
|
||||||
DeviceEntityMask.DV_API_MQTT_EXCLUDE | DeviceEntityMask.DV_WEB_EXCLUDE;
|
m: set
|
||||||
return {
|
? de.m |
|
||||||
...de,
|
(DeviceEntityMask.DV_API_MQTT_EXCLUDE |
|
||||||
m: set ? de.m | excludeMask : de.m & ~excludeMask
|
DeviceEntityMask.DV_WEB_EXCLUDE)
|
||||||
};
|
: de.m &
|
||||||
}
|
~(
|
||||||
|
DeviceEntityMask.DV_API_MQTT_EXCLUDE |
|
||||||
|
DeviceEntityMask.DV_WEB_EXCLUDE
|
||||||
|
)
|
||||||
|
};
|
||||||
|
} else {
|
||||||
return de;
|
return de;
|
||||||
})
|
}
|
||||||
);
|
})
|
||||||
},
|
);
|
||||||
[filter_entity]
|
};
|
||||||
);
|
|
||||||
|
|
||||||
const resetCustomization = useCallback(async () => {
|
const resetCustomization = async () => {
|
||||||
try {
|
try {
|
||||||
await sendResetCustomizations();
|
await sendResetCustomizations();
|
||||||
toast.info(LL.CUSTOMIZATIONS_RESTART());
|
toast.info(LL.CUSTOMIZATIONS_RESTART());
|
||||||
@@ -368,30 +351,25 @@ const Customizations = () => {
|
|||||||
toast.error((error as Error).message);
|
toast.error((error as Error).message);
|
||||||
} finally {
|
} finally {
|
||||||
setConfirmReset(false);
|
setConfirmReset(false);
|
||||||
setRestarting(true);
|
|
||||||
}
|
}
|
||||||
}, [sendResetCustomizations, LL]);
|
};
|
||||||
|
|
||||||
const onDialogClose = () => {
|
const onDialogClose = () => {
|
||||||
setDialogOpen(false);
|
setDialogOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateDeviceEntity = useCallback((updatedItem: DeviceEntity) => {
|
const updateDeviceEntity = (updatedItem: DeviceEntity) => {
|
||||||
setDeviceEntities(
|
setDeviceEntities(
|
||||||
(prev) =>
|
deviceEntities?.map((de) =>
|
||||||
prev?.map((de) =>
|
de.id === updatedItem.id ? { ...de, ...updatedItem } : de
|
||||||
de.id === updatedItem.id ? { ...de, ...updatedItem } : de
|
)
|
||||||
) ?? []
|
|
||||||
);
|
);
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const onDialogSave = useCallback(
|
const onDialogSave = (updatedItem: DeviceEntity) => {
|
||||||
(updatedItem: DeviceEntity) => {
|
setDialogOpen(false);
|
||||||
setDialogOpen(false);
|
updateDeviceEntity(updatedItem);
|
||||||
updateDeviceEntity(updatedItem);
|
};
|
||||||
},
|
|
||||||
[updateDeviceEntity]
|
|
||||||
);
|
|
||||||
|
|
||||||
const editDeviceEntity = useCallback((de: DeviceEntity) => {
|
const editDeviceEntity = useCallback((de: DeviceEntity) => {
|
||||||
if (de.n === undefined || (de.n && de.n[0] === '!')) {
|
if (de.n === undefined || (de.n && de.n[0] === '!')) {
|
||||||
@@ -406,54 +384,60 @@ const Customizations = () => {
|
|||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const saveCustomization = useCallback(async () => {
|
const saveCustomization = async () => {
|
||||||
if (!devices || !deviceEntities || selectedDevice === -1) {
|
if (devices && deviceEntities && selectedDevice !== -1) {
|
||||||
return;
|
const masked_entities = deviceEntities
|
||||||
}
|
.filter((de: DeviceEntity) => hasEntityChanged(de))
|
||||||
|
.map(
|
||||||
|
(new_de) =>
|
||||||
|
new_de.m.toString(16).padStart(2, '0') +
|
||||||
|
new_de.id +
|
||||||
|
(new_de.cn || new_de.mi || new_de.ma ? '|' : '') +
|
||||||
|
(new_de.cn ? new_de.cn : '') +
|
||||||
|
(new_de.mi ? '>' + new_de.mi : '') +
|
||||||
|
(new_de.ma ? '<' + new_de.ma : '')
|
||||||
|
);
|
||||||
|
|
||||||
const masked_entities = deviceEntities
|
// check size in bytes to match buffer in CPP, which is 2048
|
||||||
.filter((de: DeviceEntity) => hasEntityChanged(de))
|
const bytes = new TextEncoder().encode(JSON.stringify(masked_entities)).length;
|
||||||
.map((new_de) => createMaskedEntityId(new_de));
|
if (bytes > 2000) {
|
||||||
|
toast.warning(LL.CUSTOMIZATIONS_FULL());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// check size in bytes to match buffer in CPP, which is 2048
|
await sendCustomizationEntities({
|
||||||
const bytes = new TextEncoder().encode(JSON.stringify(masked_entities)).length;
|
id: selectedDevice,
|
||||||
if (bytes > MAX_BUFFER_SIZE) {
|
entity_ids: masked_entities
|
||||||
toast.warning(LL.CUSTOMIZATIONS_FULL());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await sendCustomizationEntities({
|
|
||||||
id: selectedDevice,
|
|
||||||
entity_ids: masked_entities
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
toast.success(LL.CUSTOMIZATIONS_SAVED());
|
|
||||||
})
|
})
|
||||||
.catch((error: Error) => {
|
.then(() => {
|
||||||
if (error.message === 'Reboot required') {
|
toast.success(LL.CUSTOMIZATIONS_SAVED());
|
||||||
setRestartNeeded(true);
|
})
|
||||||
} else {
|
.catch((error: Error) => {
|
||||||
toast.error(error.message);
|
if (error.message === 'Reboot required') {
|
||||||
}
|
setRestartNeeded(true);
|
||||||
})
|
} else {
|
||||||
.finally(() => {
|
toast.error(error.message);
|
||||||
setOriginalSettings(deviceEntities);
|
}
|
||||||
});
|
})
|
||||||
}, [devices, deviceEntities, selectedDevice, sendCustomizationEntities, LL]);
|
.finally(() => {
|
||||||
|
setOriginalSettings(deviceEntities);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const renameDevice = useCallback(async () => {
|
const renameDevice = async () => {
|
||||||
await sendDeviceName({ id: selectedDevice, name: selectedDeviceName })
|
await sendDeviceName({ id: selectedDevice, name: selectedDeviceName })
|
||||||
.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);
|
||||||
await fetchCoreData();
|
await fetchCoreData();
|
||||||
});
|
});
|
||||||
}, [selectedDevice, selectedDeviceName, sendDeviceName, LL, fetchCoreData]);
|
};
|
||||||
|
|
||||||
const renderDeviceList = () => (
|
const renderDeviceList = () => (
|
||||||
<>
|
<>
|
||||||
@@ -516,38 +500,25 @@ const Customizations = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<Button
|
||||||
<Button
|
startIcon={<EditIcon />}
|
||||||
startIcon={<EditIcon />}
|
variant="outlined"
|
||||||
variant="outlined"
|
onClick={() => setRename(true)}
|
||||||
onClick={() => setRename(true)}
|
>
|
||||||
>
|
{LL.RENAME()}
|
||||||
{LL.RENAME()}
|
</Button>
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
startIcon={<SettingsBackupRestoreIcon />}
|
|
||||||
variant="outlined"
|
|
||||||
color="error"
|
|
||||||
onClick={() => setConfirmReset(true)}
|
|
||||||
>
|
|
||||||
{LL.REMOVE_ALL()}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
const filteredEntities = useMemo(
|
|
||||||
() => deviceEntities.filter((de) => filter_entity(de)),
|
|
||||||
[deviceEntities, filter_entity]
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderDeviceData = () => {
|
const renderDeviceData = () => {
|
||||||
|
const shown_data = deviceEntities.filter((de) => filter_entity(de));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Box color="warning.main">
|
<Box color="warning.main">
|
||||||
<Typography variant="body2" mt={1} mb={1}>
|
<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()}
|
||||||
@@ -573,7 +544,6 @@ const Customizations = () => {
|
|||||||
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);
|
||||||
}}
|
}}
|
||||||
@@ -642,13 +612,13 @@ const Customizations = () => {
|
|||||||
</Grid>
|
</Grid>
|
||||||
<Grid>
|
<Grid>
|
||||||
<Typography variant="subtitle2" color="grey">
|
<Typography variant="subtitle2" color="grey">
|
||||||
{LL.SHOWING()} {filteredEntities.length}/{deviceEntities.length}
|
{LL.SHOWING()} {shown_data.length}/{deviceEntities.length}
|
||||||
{LL.ENTITIES(deviceEntities.length)}
|
{LL.ENTITIES(deviceEntities.length)}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Table
|
<Table
|
||||||
data={{ nodes: filteredEntities }}
|
data={{ nodes: shown_data }}
|
||||||
theme={entities_theme}
|
theme={entities_theme}
|
||||||
layout={{ custom: true }}
|
layout={{ custom: true }}
|
||||||
>
|
>
|
||||||
@@ -670,27 +640,14 @@ const Customizations = () => {
|
|||||||
<EntityMaskToggle onUpdate={updateDeviceEntity} de={de} />
|
<EntityMaskToggle onUpdate={updateDeviceEntity} de={de} />
|
||||||
</Cell>
|
</Cell>
|
||||||
<Cell>
|
<Cell>
|
||||||
<span
|
{formatName(de, false)} (
|
||||||
style={{
|
<Link
|
||||||
color:
|
target="_blank"
|
||||||
de.v === undefined && !isCommand(de) ? 'grey' : 'inherit'
|
href={APIURL + selectedDeviceTypeNameURL + '/' + de.id}
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{formatName(de, false)} (
|
{de.id}
|
||||||
<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)}
|
||||||
@@ -715,7 +672,7 @@ const Customizations = () => {
|
|||||||
open={confirmReset}
|
open={confirmReset}
|
||||||
onClose={() => setConfirmReset(false)}
|
onClose={() => setConfirmReset(false)}
|
||||||
>
|
>
|
||||||
<DialogTitle>{LL.REMOVE_ALL()}</DialogTitle>
|
<DialogTitle>{LL.RESET(1)}</DialogTitle>
|
||||||
<DialogContent dividers>{LL.CUSTOMIZATIONS_RESET()}</DialogContent>
|
<DialogContent dividers>{LL.CUSTOMIZATIONS_RESET()}</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<Button
|
<Button
|
||||||
@@ -732,7 +689,7 @@ const Customizations = () => {
|
|||||||
onClick={resetCustomization}
|
onClick={resetCustomization}
|
||||||
color="error"
|
color="error"
|
||||||
>
|
>
|
||||||
{LL.REMOVE_ALL()}
|
{LL.RESET(0)}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
@@ -743,9 +700,8 @@ const Customizations = () => {
|
|||||||
{devices && renderDeviceList()}
|
{devices && renderDeviceList()}
|
||||||
{selectedDevice !== -1 && !rename && renderDeviceData()}
|
{selectedDevice !== -1 && !rename && renderDeviceData()}
|
||||||
{restartNeeded ? (
|
{restartNeeded ? (
|
||||||
<MessageBox level="warning" message={LL.RESTART_TEXT(0)}>
|
<MessageBox my={2} level="warning" message={LL.RESTART_TEXT(0)}>
|
||||||
<Button
|
<Button
|
||||||
sx={{ ml: 2 }}
|
|
||||||
startIcon={<PowerSettingsNewIcon />}
|
startIcon={<PowerSettingsNewIcon />}
|
||||||
variant="contained"
|
variant="contained"
|
||||||
color="error"
|
color="error"
|
||||||
@@ -763,11 +719,7 @@ const Customizations = () => {
|
|||||||
startIcon={<CancelIcon />}
|
startIcon={<CancelIcon />}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
color="secondary"
|
color="secondary"
|
||||||
onClick={() => {
|
onClick={() => devices && sendDeviceEntities(selectedDevice)}
|
||||||
if (devices) {
|
|
||||||
void sendDeviceEntities(selectedDevice);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{LL.CANCEL()}
|
{LL.CANCEL()}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -782,6 +734,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()}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
|
import { 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,23 +30,6 @@ 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.main">
|
|
||||||
{label}:
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2">{value}</Typography>
|
|
||||||
</Grid>
|
|
||||||
));
|
|
||||||
LabelValue.displayName = 'LabelValue';
|
|
||||||
|
|
||||||
const ICON_SIZE = 16;
|
|
||||||
|
|
||||||
const CustomizationsDialog = ({
|
const CustomizationsDialog = ({
|
||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -57,23 +40,12 @@ 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 = useMemo(
|
const updateFormValue = updateValue(setEditItem);
|
||||||
() =>
|
|
||||||
updateValue(
|
|
||||||
setEditItem as unknown as React.Dispatch<
|
|
||||||
React.SetStateAction<Record<string, unknown>>
|
|
||||||
>
|
|
||||||
),
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const isWriteableNumber = useMemo(
|
const isWriteableNumber =
|
||||||
() =>
|
typeof editItem.v === 'number' &&
|
||||||
typeof editItem.v === 'number' &&
|
editItem.w &&
|
||||||
editItem.w &&
|
!(editItem.m & DeviceEntityMask.DV_READONLY);
|
||||||
!(editItem.m & DeviceEntityMask.DV_READONLY),
|
|
||||||
[editItem.v, editItem.w, editItem.m]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
@@ -82,59 +54,66 @@ const CustomizationsDialog = ({
|
|||||||
}
|
}
|
||||||
}, [open, selectedItem]);
|
}, [open, selectedItem]);
|
||||||
|
|
||||||
const handleClose = useCallback(
|
const handleClose = (
|
||||||
(_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => {
|
_event: React.SyntheticEvent,
|
||||||
if (reason !== 'backdropClick') {
|
reason: 'backdropClick' | 'escapeKeyDown'
|
||||||
onClose();
|
) => {
|
||||||
}
|
if (reason !== 'backdropClick') {
|
||||||
},
|
onClose();
|
||||||
[onClose]
|
}
|
||||||
);
|
};
|
||||||
|
|
||||||
const save = useCallback(() => {
|
const save = () => {
|
||||||
if (
|
if (
|
||||||
isWriteableNumber &&
|
isWriteableNumber &&
|
||||||
editItem.mi &&
|
editItem.mi &&
|
||||||
editItem.ma &&
|
editItem.ma &&
|
||||||
editItem.mi > editItem.ma
|
editItem.mi > editItem?.ma
|
||||||
) {
|
) {
|
||||||
setError(true);
|
setError(true);
|
||||||
} else {
|
} else {
|
||||||
onSave(editItem);
|
onSave(editItem);
|
||||||
}
|
}
|
||||||
}, [isWriteableNumber, editItem, onSave]);
|
};
|
||||||
|
|
||||||
const updateDeviceEntity = useCallback((updatedItem: DeviceEntity) => {
|
const updateDeviceEntity = (updatedItem: DeviceEntity) => {
|
||||||
setEditItem((prev) => ({ ...prev, m: updatedItem.m }));
|
setEditItem({ ...editItem, m: updatedItem.m });
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const dialogTitle = useMemo(() => `${LL.EDIT()} ${LL.ENTITY()}`, [LL]);
|
|
||||||
|
|
||||||
const writeableIcon = useMemo(
|
|
||||||
() =>
|
|
||||||
editItem.w ? (
|
|
||||||
<DoneIcon color="success" sx={{ fontSize: ICON_SIZE }} />
|
|
||||||
) : (
|
|
||||||
<CloseIcon color="error" sx={{ fontSize: ICON_SIZE }} />
|
|
||||||
),
|
|
||||||
[editItem.w]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
|
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
|
||||||
<DialogTitle>{dialogTitle}</DialogTitle>
|
<DialogTitle>{LL.EDIT() + ' ' + LL.ENTITY()}</DialogTitle>
|
||||||
<DialogContent dividers>
|
<DialogContent dividers>
|
||||||
<LabelValue label={LL.ID_OF(LL.ENTITY())} value={editItem.id} />
|
<Grid container>
|
||||||
<LabelValue
|
<Typography variant="body2" color="warning.main">
|
||||||
label={`${LL.DEFAULT(1)} ${LL.ENTITY_NAME(1)}`}
|
{LL.ID_OF(LL.ENTITY())}:
|
||||||
value={editItem.n}
|
</Typography>
|
||||||
/>
|
<Typography variant="body2">{editItem.id}</Typography>
|
||||||
<LabelValue label={LL.WRITEABLE()} value={writeableIcon} />
|
</Grid>
|
||||||
|
|
||||||
|
<Grid container direction="row">
|
||||||
|
<Typography variant="body2" color="warning.main">
|
||||||
|
{LL.DEFAULT(1) + ' ' + LL.ENTITY_NAME(1)}:
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2">{editItem.n}</Typography>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid container direction="row">
|
||||||
|
<Typography variant="body2" color="warning.main">
|
||||||
|
{LL.WRITEABLE()}:
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2">
|
||||||
|
{editItem.w ? (
|
||||||
|
<DoneIcon color="success" sx={{ fontSize: 16 }} />
|
||||||
|
) : (
|
||||||
|
<CloseIcon color="error" sx={{ fontSize: 16 }} />
|
||||||
|
)}
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
<Box mt={1} mb={2}>
|
<Box 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
|
||||||
@@ -170,14 +149,12 @@ const CustomizationsDialog = ({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<Typography variant="body2" color="error" mt={2}>
|
<Typography variant="body2" color="error" mt={2}>
|
||||||
Error: Check min and max values
|
Error: Check min and max values
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<Button
|
<Button
|
||||||
startIcon={<CancelIcon />}
|
startIcon={<CancelIcon />}
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ const Dashboard = memo(() => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const tree = useTree(
|
const tree = useTree(
|
||||||
{ nodes: [...data.nodes] },
|
{ nodes: data.nodes },
|
||||||
{
|
{
|
||||||
onChange: () => {} // not used but needed
|
onChange: () => {} // not used but needed
|
||||||
},
|
},
|
||||||
@@ -263,7 +263,7 @@ const Dashboard = memo(() => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{!data.connected && (
|
{!data.connected && (
|
||||||
<MessageBox level="error" message={LL.EMS_BUS_WARNING()} />
|
<MessageBox mb={2} level="error" message={LL.EMS_BUS_WARNING()} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{data.connected && data.nodes.length > 0 && !hasFavEntities && (
|
{data.connected && data.nodes.length > 0 && !hasFavEntities && (
|
||||||
@@ -283,121 +283,112 @@ const Dashboard = memo(() => {
|
|||||||
</MessageBox>
|
</MessageBox>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Box
|
{data.nodes.length > 0 && (
|
||||||
display="flex"
|
<>
|
||||||
justifyContent="flex-end"
|
<Box
|
||||||
flexWrap="nowrap"
|
display="flex"
|
||||||
whiteSpace="nowrap"
|
justifyContent="flex-end"
|
||||||
>
|
flexWrap="nowrap"
|
||||||
<ToggleButtonGroup
|
whiteSpace="nowrap"
|
||||||
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 mt={1} justifyContent="center" flexDirection="column">
|
|
||||||
<IconContext.Provider
|
|
||||||
value={{
|
|
||||||
color: 'lightblue',
|
|
||||||
size: '18',
|
|
||||||
style: { verticalAlign: 'middle' }
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Table
|
<ToggleButtonGroup
|
||||||
data={{ nodes: data.nodes }}
|
size="small"
|
||||||
theme={dashboard_theme}
|
|
||||||
layout={{ custom: true }}
|
|
||||||
tree={tree}
|
|
||||||
>
|
|
||||||
{(tableList: DashboardItem[]) => (
|
|
||||||
<Body>
|
|
||||||
{tableList.map((di: DashboardItem) => (
|
|
||||||
<Row
|
|
||||||
key={di.id}
|
|
||||||
item={di}
|
|
||||||
onClick={() => editDashboardValue(di)}
|
|
||||||
>
|
|
||||||
{di.id > 99 ? (
|
|
||||||
<>
|
|
||||||
<Cell>{showName(di)}</Cell>
|
|
||||||
<Cell>
|
|
||||||
<ButtonTooltip
|
|
||||||
title={formatValue(LL, di.dv?.v, di.dv?.u)}
|
|
||||||
>
|
|
||||||
<span>{formatValue(LL, di.dv?.v, di.dv?.u)}</span>
|
|
||||||
</ButtonTooltip>
|
|
||||||
</Cell>
|
|
||||||
|
|
||||||
<Cell>
|
|
||||||
{me.admin &&
|
|
||||||
di.dv?.c &&
|
|
||||||
!hasMask(di.dv.id, DeviceEntityMask.DV_READONLY) && (
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
aria-label={
|
|
||||||
LL.CHANGE_VALUE() + ' ' + LL.VALUE(0)
|
|
||||||
}
|
|
||||||
onClick={() => editDashboardValue(di)}
|
|
||||||
>
|
|
||||||
<EditIcon
|
|
||||||
color="primary"
|
|
||||||
sx={{ fontSize: 16 }}
|
|
||||||
/>
|
|
||||||
</IconButton>
|
|
||||||
)}
|
|
||||||
</Cell>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<CellTree item={di}>{showName(di)}</CellTree>
|
|
||||||
<Cell />
|
|
||||||
<Cell />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Row>
|
|
||||||
))}
|
|
||||||
</Body>
|
|
||||||
)}
|
|
||||||
</Table>
|
|
||||||
</IconContext.Provider>
|
|
||||||
</Box>
|
|
||||||
) : (
|
|
||||||
<Box
|
|
||||||
display="flex"
|
|
||||||
// justifyContent="flex-end"
|
|
||||||
// flexWrap="nowrap"
|
|
||||||
// whiteSpace="nowrap"
|
|
||||||
>
|
|
||||||
<Typography mt={1} color="warning.main" variant="body1">
|
|
||||||
no data
|
|
||||||
</Typography>
|
|
||||||
<Tooltip title={LL.DASHBOARD_1()}>
|
|
||||||
<HelpOutlineIcon
|
|
||||||
sx={{
|
|
||||||
ml: 1,
|
|
||||||
mt: 1,
|
|
||||||
fontSize: 20,
|
|
||||||
verticalAlign: 'middle'
|
|
||||||
}}
|
|
||||||
color="primary"
|
color="primary"
|
||||||
/>
|
value={showAll}
|
||||||
</Tooltip>
|
exclusive
|
||||||
</Box>
|
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>
|
||||||
|
<Tooltip title={LL.DASHBOARD_1()}>
|
||||||
|
<HelpOutlineIcon
|
||||||
|
sx={{
|
||||||
|
ml: 1,
|
||||||
|
mt: 1,
|
||||||
|
fontSize: 20,
|
||||||
|
verticalAlign: 'middle'
|
||||||
|
}}
|
||||||
|
color="primary"
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box mt={1} justifyContent="center" flexDirection="column">
|
||||||
|
<IconContext.Provider
|
||||||
|
value={{
|
||||||
|
color: 'lightblue',
|
||||||
|
size: '18',
|
||||||
|
style: { verticalAlign: 'middle' }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Table
|
||||||
|
data={{ nodes: data.nodes }}
|
||||||
|
theme={dashboard_theme}
|
||||||
|
layout={{ custom: true }}
|
||||||
|
tree={tree}
|
||||||
|
>
|
||||||
|
{(tableList: DashboardItem[]) => (
|
||||||
|
<Body>
|
||||||
|
{tableList.map((di: DashboardItem) => (
|
||||||
|
<Row
|
||||||
|
key={di.id}
|
||||||
|
item={di}
|
||||||
|
onClick={() => editDashboardValue(di)}
|
||||||
|
>
|
||||||
|
{di.id > 99 ? (
|
||||||
|
<>
|
||||||
|
<Cell>{showName(di)}</Cell>
|
||||||
|
<Cell>
|
||||||
|
<ButtonTooltip
|
||||||
|
title={formatValue(LL, di.dv?.v, di.dv?.u)}
|
||||||
|
>
|
||||||
|
<span>{formatValue(LL, di.dv?.v, di.dv?.u)}</span>
|
||||||
|
</ButtonTooltip>
|
||||||
|
</Cell>
|
||||||
|
|
||||||
|
<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,14 +1,13 @@
|
|||||||
import { memo } from 'react';
|
|
||||||
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';
|
||||||
@@ -19,10 +18,9 @@ import type { SvgIconProps } from '@mui/material';
|
|||||||
|
|
||||||
import { DeviceType } from './types';
|
import { DeviceType } from './types';
|
||||||
|
|
||||||
const deviceIconLookup: Record<
|
const deviceIconLookup: {
|
||||||
DeviceType,
|
[key in DeviceType]: React.ComponentType<SvgIconProps> | undefined;
|
||||||
React.ComponentType<SvgIconProps> | null
|
} = {
|
||||||
> = {
|
|
||||||
[DeviceType.TEMPERATURESENSOR]: TiThermometer,
|
[DeviceType.TEMPERATURESENSOR]: TiThermometer,
|
||||||
[DeviceType.ANALOGSENSOR]: PiGauge,
|
[DeviceType.ANALOGSENSOR]: PiGauge,
|
||||||
[DeviceType.BOILER]: CgSmartHomeBoiler,
|
[DeviceType.BOILER]: CgSmartHomeBoiler,
|
||||||
@@ -41,19 +39,15 @@ const deviceIconLookup: Record<
|
|||||||
[DeviceType.POOL]: MdOutlinePool,
|
[DeviceType.POOL]: MdOutlinePool,
|
||||||
[DeviceType.CUSTOM]: MdPlaylistAdd,
|
[DeviceType.CUSTOM]: MdPlaylistAdd,
|
||||||
[DeviceType.UNKNOWN]: MdOutlineSensors,
|
[DeviceType.UNKNOWN]: MdOutlineSensors,
|
||||||
[DeviceType.SYSTEM]: null,
|
[DeviceType.SYSTEM]: undefined,
|
||||||
[DeviceType.SCHEDULER]: MdMoreTime,
|
[DeviceType.SCHEDULER]: MdMoreTime,
|
||||||
[DeviceType.GENERIC]: MdOutlineSensors,
|
[DeviceType.GENERIC]: MdOutlineSensors,
|
||||||
[DeviceType.VENTILATION]: PiFan
|
[DeviceType.VENTILATION]: PiFan
|
||||||
};
|
};
|
||||||
|
|
||||||
interface DeviceIconProps {
|
const DeviceIcon = ({ type_id }: { type_id: DeviceType }) => {
|
||||||
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;
|
||||||
|
|||||||
@@ -93,7 +93,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,28 +118,30 @@ const Devices = memo(() => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
let raf = 0;
|
function updateSize() {
|
||||||
const updateSize = () => {
|
setSize([window.innerWidth, window.innerHeight]);
|
||||||
cancelAnimationFrame(raf);
|
}
|
||||||
raf = requestAnimationFrame(() => {
|
|
||||||
setSize([window.innerWidth, window.innerHeight]);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
window.addEventListener('resize', updateSize);
|
window.addEventListener('resize', updateSize);
|
||||||
updateSize();
|
updateSize();
|
||||||
return () => {
|
return () => window.removeEventListener('resize', updateSize);
|
||||||
window.removeEventListener('resize', updateSize);
|
|
||||||
cancelAnimationFrame(raf);
|
|
||||||
};
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const leftOffset = useCallback(() => {
|
const leftOffset = () => {
|
||||||
const devicesWindow = document.getElementById('devices-window');
|
const devicesWindow = document.getElementById('devices-window');
|
||||||
if (!devicesWindow) return 0;
|
if (!devicesWindow) {
|
||||||
const { left, right } = devicesWindow.getBoundingClientRect();
|
return 0;
|
||||||
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 = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -259,7 +261,7 @@ const Devices = memo(() => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const dv_sort = useSort(
|
const dv_sort = useSort(
|
||||||
{ nodes: [...deviceData.nodes] },
|
{ nodes: deviceData.nodes },
|
||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
sortIcon: {
|
sortIcon: {
|
||||||
@@ -289,7 +291,7 @@ const Devices = memo(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const device_select = useRowSelect(
|
const device_select = useRowSelect(
|
||||||
{ nodes: [...coreData.devices] },
|
{ nodes: coreData.devices },
|
||||||
{
|
{
|
||||||
onChange: onSelectChange
|
onChange: onSelectChange
|
||||||
}
|
}
|
||||||
@@ -533,19 +535,21 @@ const Devices = memo(() => {
|
|||||||
|
|
||||||
const renderCoreData = () => (
|
const renderCoreData = () => (
|
||||||
<>
|
<>
|
||||||
{!coreData.connected ? (
|
<Box justifyContent="center" flexDirection="column">
|
||||||
<MessageBox level="error" message={LL.EMS_BUS_WARNING()} />
|
<IconContext.Provider
|
||||||
) : (
|
value={{
|
||||||
<Box justifyContent="center" flexDirection="column">
|
color: 'lightblue',
|
||||||
<IconContext.Provider
|
size: '18',
|
||||||
value={{
|
style: { verticalAlign: 'middle' }
|
||||||
color: 'lightblue',
|
}}
|
||||||
size: '18',
|
>
|
||||||
style: { verticalAlign: 'middle' }
|
{!coreData.connected && (
|
||||||
}}
|
<MessageBox my={2} level="error" message={LL.EMS_BUS_WARNING()} />
|
||||||
>
|
)}
|
||||||
|
|
||||||
|
{coreData.connected && (
|
||||||
<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 }}
|
||||||
@@ -579,9 +583,9 @@ const Devices = memo(() => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Table>
|
</Table>
|
||||||
</IconContext.Provider>
|
)}
|
||||||
</Box>
|
</IconContext.Provider>
|
||||||
)}
|
</Box>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -650,7 +654,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,
|
||||||
@@ -667,7 +671,7 @@ const Devices = memo(() => {
|
|||||||
</Typography>
|
</Typography>
|
||||||
<Grid justifyContent="flex-end">
|
<Grid justifyContent="flex-end">
|
||||||
<ButtonTooltip title={LL.CLOSE()}>
|
<ButtonTooltip title={LL.CLOSE()}>
|
||||||
<IconButton onClick={resetDeviceSelect} aria-label={LL.CLOSE()}>
|
<IconButton onClick={resetDeviceSelect}>
|
||||||
<HighlightOffIcon color="primary" sx={{ fontSize: 18 }} />
|
<HighlightOffIcon color="primary" sx={{ fontSize: 18 }} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</ButtonTooltip>
|
</ButtonTooltip>
|
||||||
@@ -679,7 +683,6 @@ 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);
|
||||||
}}
|
}}
|
||||||
@@ -694,22 +697,19 @@ const Devices = memo(() => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<ButtonTooltip title={LL.DEVICE_DETAILS()}>
|
<ButtonTooltip title={LL.DEVICE_DETAILS()}>
|
||||||
<IconButton
|
<IconButton onClick={() => setShowDeviceInfo(true)}>
|
||||||
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} aria-label={LL.CUSTOMIZATIONS()}>
|
<IconButton onClick={customize}>
|
||||||
<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} aria-label={LL.EXPORT()}>
|
<IconButton onClick={handleDownloadCsv}>
|
||||||
<DownloadIcon color="primary" sx={{ fontSize: 18 }} />
|
<DownloadIcon color="primary" sx={{ fontSize: 18 }} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</ButtonTooltip>
|
</ButtonTooltip>
|
||||||
@@ -744,7 +744,7 @@ const Devices = memo(() => {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Table
|
<Table
|
||||||
data={{ nodes: Array.from(shown_data) }}
|
data={{ nodes: shown_data }}
|
||||||
theme={data_theme}
|
theme={data_theme}
|
||||||
sort={dv_sort}
|
sort={dv_sort}
|
||||||
layout={{ custom: true, fixedHeader: true }}
|
layout={{ custom: true, fixedHeader: true }}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useEffect, 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';
|
||||||
@@ -52,7 +52,7 @@ const DevicesDialog = ({
|
|||||||
const [editItem, setEditItem] = useState<DeviceValue>(selectedItem);
|
const [editItem, setEditItem] = useState<DeviceValue>(selectedItem);
|
||||||
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
||||||
|
|
||||||
const updateFormValue = useMemo(() => updateValue(setEditItem), [setEditItem]);
|
const updateFormValue = updateValue(setEditItem);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
@@ -61,7 +61,11 @@ const DevicesDialog = ({
|
|||||||
}
|
}
|
||||||
}, [open, selectedItem]);
|
}, [open, selectedItem]);
|
||||||
|
|
||||||
const save = useCallback(async () => {
|
const close = () => {
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
try {
|
try {
|
||||||
setFieldErrors(undefined);
|
setFieldErrors(undefined);
|
||||||
await validate(validator, editItem);
|
await validate(validator, editItem);
|
||||||
@@ -69,66 +73,46 @@ const DevicesDialog = ({
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
setFieldErrors(error as ValidateFieldsError);
|
setFieldErrors(error as ValidateFieldsError);
|
||||||
}
|
}
|
||||||
}, [validator, editItem, onSave]);
|
};
|
||||||
|
|
||||||
const setUom = useCallback(
|
const setUom = (uom?: DeviceValueUOM) => {
|
||||||
(uom?: DeviceValueUOM) => {
|
if (uom === undefined) {
|
||||||
if (uom === undefined) {
|
return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
switch (uom) {
|
|
||||||
case DeviceValueUOM.HOURS:
|
|
||||||
return LL.HOURS();
|
|
||||||
case DeviceValueUOM.MINUTES:
|
|
||||||
return LL.MINUTES();
|
|
||||||
case DeviceValueUOM.SECONDS:
|
|
||||||
return LL.SECONDS();
|
|
||||||
default:
|
|
||||||
return DeviceValueUOM_s[uom];
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[LL]
|
|
||||||
);
|
|
||||||
|
|
||||||
const showHelperText = useCallback((dv: DeviceValue) => {
|
|
||||||
if (dv.h) return dv.h;
|
|
||||||
if (dv.l) return dv.l.join(' | ');
|
|
||||||
if (dv.m !== undefined && dv.x !== undefined) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{dv.m} → {dv.x}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return undefined;
|
switch (uom) {
|
||||||
}, []);
|
case DeviceValueUOM.HOURS:
|
||||||
|
return LL.HOURS();
|
||||||
|
case DeviceValueUOM.MINUTES:
|
||||||
|
return LL.MINUTES();
|
||||||
|
case DeviceValueUOM.SECONDS:
|
||||||
|
return LL.SECONDS();
|
||||||
|
default:
|
||||||
|
return DeviceValueUOM_s[uom];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const isCommand = useMemo(
|
const showHelperText = (dv: DeviceValue) =>
|
||||||
() => selectedItem.v === '' && selectedItem.c,
|
dv.h ? (
|
||||||
[selectedItem.v, selectedItem.c]
|
dv.h
|
||||||
);
|
) : dv.l ? (
|
||||||
|
dv.l.join(' | ')
|
||||||
const dialogTitle = useMemo(() => {
|
) : dv.m !== undefined && dv.x !== undefined ? (
|
||||||
if (isCommand) return LL.RUN_COMMAND();
|
<>
|
||||||
return writeable ? LL.CHANGE_VALUE() : LL.VALUE(0);
|
{dv.m} → {dv.x}
|
||||||
}, [isCommand, writeable, LL]);
|
</>
|
||||||
|
) : undefined;
|
||||||
const buttonLabel = useMemo(() => {
|
|
||||||
return isCommand ? LL.EXECUTE() : LL.UPDATE();
|
|
||||||
}, [isCommand, LL]);
|
|
||||||
|
|
||||||
const helperText = useMemo(
|
|
||||||
() => showHelperText(editItem),
|
|
||||||
[editItem, showHelperText]
|
|
||||||
);
|
|
||||||
|
|
||||||
const valueLabel = LL.VALUE(0);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog sx={dialogStyle} open={open} onClose={onClose}>
|
<Dialog sx={dialogStyle} open={open} onClose={close}>
|
||||||
<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" mb={2}>
|
<Box color="warning.main" p={0} pl={0} pr={0} mt={0} mb={2}>
|
||||||
<Typography variant="body2">{editItem.id.slice(2)}</Typography>
|
<Typography variant="body2">{editItem.id.slice(2)}</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Grid container>
|
<Grid container>
|
||||||
@@ -136,8 +120,8 @@ const DevicesDialog = ({
|
|||||||
{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
|
||||||
@@ -153,7 +137,7 @@ const DevicesDialog = ({
|
|||||||
<ValidatedTextField
|
<ValidatedTextField
|
||||||
fieldErrors={fieldErrors || {}}
|
fieldErrors={fieldErrors || {}}
|
||||||
name="v"
|
name="v"
|
||||||
label={valueLabel}
|
label={LL.VALUE(0)}
|
||||||
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}
|
||||||
@@ -177,7 +161,7 @@ const DevicesDialog = ({
|
|||||||
<ValidatedTextField
|
<ValidatedTextField
|
||||||
fieldErrors={fieldErrors || {}}
|
fieldErrors={fieldErrors || {}}
|
||||||
name="v"
|
name="v"
|
||||||
label={valueLabel}
|
label={LL.VALUE(0)}
|
||||||
value={editItem.v}
|
value={editItem.v}
|
||||||
disabled={!writeable}
|
disabled={!writeable}
|
||||||
sx={{ width: '30ch' }}
|
sx={{ width: '30ch' }}
|
||||||
@@ -186,9 +170,9 @@ const DevicesDialog = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Grid>
|
</Grid>
|
||||||
{writeable && helperText && (
|
{writeable && (
|
||||||
<Grid>
|
<Grid>
|
||||||
<FormHelperText>{helperText}</FormHelperText>
|
<FormHelperText>{showHelperText(editItem)}</FormHelperText>
|
||||||
</Grid>
|
</Grid>
|
||||||
)}
|
)}
|
||||||
</Grid>
|
</Grid>
|
||||||
@@ -207,7 +191,7 @@ const DevicesDialog = ({
|
|||||||
<Button
|
<Button
|
||||||
startIcon={<CancelIcon />}
|
startIcon={<CancelIcon />}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
onClick={onClose}
|
onClick={close}
|
||||||
color="secondary"
|
color="secondary"
|
||||||
>
|
>
|
||||||
{LL.CANCEL()}
|
{LL.CANCEL()}
|
||||||
@@ -218,7 +202,7 @@ const DevicesDialog = ({
|
|||||||
onClick={save}
|
onClick={save}
|
||||||
color="primary"
|
color="primary"
|
||||||
>
|
>
|
||||||
{buttonLabel}
|
{selectedItem.v === '' && selectedItem.c ? LL.EXECUTE() : LL.UPDATE()}
|
||||||
</Button>
|
</Button>
|
||||||
{progress && (
|
{progress && (
|
||||||
<CircularProgress
|
<CircularProgress
|
||||||
@@ -233,7 +217,7 @@ const DevicesDialog = ({
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<Button variant="outlined" onClick={onClose} color="secondary">
|
<Button variant="outlined" onClick={close} color="secondary">
|
||||||
{LL.CLOSE()}
|
{LL.CLOSE()}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { useCallback, useMemo } from 'react';
|
|
||||||
|
|
||||||
import { ToggleButton, ToggleButtonGroup } from '@mui/material';
|
import { ToggleButton, ToggleButtonGroup } from '@mui/material';
|
||||||
|
|
||||||
import OptionIcon from './OptionIcon';
|
import OptionIcon from './OptionIcon';
|
||||||
@@ -11,132 +9,92 @@ interface EntityMaskToggleProps {
|
|||||||
de: DeviceEntity;
|
de: DeviceEntity;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Available mask values
|
|
||||||
const MASK_VALUES = [
|
|
||||||
DeviceEntityMask.DV_WEB_EXCLUDE, // 1
|
|
||||||
DeviceEntityMask.DV_API_MQTT_EXCLUDE, // 2
|
|
||||||
DeviceEntityMask.DV_READONLY, // 4
|
|
||||||
DeviceEntityMask.DV_FAVORITE, // 8
|
|
||||||
DeviceEntityMask.DV_DELETED // 128
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Converts an array of mask strings to a bitmask number
|
|
||||||
*/
|
|
||||||
const getMaskNumber = (newMask: string[]): number => {
|
|
||||||
return newMask.reduce((mask, entry) => mask | Number(entry), 0);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Converts a bitmask number to an array of mask strings
|
|
||||||
*/
|
|
||||||
const getMaskString = (mask: number): string[] => {
|
|
||||||
return MASK_VALUES.filter((value) => (mask & value) === value).map((value) =>
|
|
||||||
String(value)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if a specific mask bit is set
|
|
||||||
*/
|
|
||||||
const hasMask = (mask: number, flag: number): boolean => (mask & flag) === flag;
|
|
||||||
|
|
||||||
const EntityMaskToggle = ({ onUpdate, de }: EntityMaskToggleProps) => {
|
const EntityMaskToggle = ({ onUpdate, de }: EntityMaskToggleProps) => {
|
||||||
const handleChange = useCallback(
|
const getMaskNumber = (newMask: string[]) => {
|
||||||
(_event: unknown, mask: string[]) => {
|
let new_mask = 0;
|
||||||
// Convert selected masks to a number
|
for (const entry of newMask) {
|
||||||
const newMask = getMaskNumber(mask);
|
new_mask |= Number(entry);
|
||||||
const updatedDe = { ...de };
|
}
|
||||||
|
return new_mask;
|
||||||
|
};
|
||||||
|
|
||||||
// Apply business logic for mask interactions
|
const getMaskString = (m: number) => {
|
||||||
// If entity has no name and is set to readonly, also exclude from web
|
const new_masks: string[] = [];
|
||||||
if (updatedDe.n === '' && hasMask(newMask, DeviceEntityMask.DV_READONLY)) {
|
if ((m & 1) === 1) {
|
||||||
updatedDe.m = newMask | DeviceEntityMask.DV_WEB_EXCLUDE;
|
new_masks.push('1');
|
||||||
} else {
|
}
|
||||||
updatedDe.m = newMask;
|
if ((m & 2) === 2) {
|
||||||
}
|
new_masks.push('2');
|
||||||
|
}
|
||||||
// If excluded from web, cannot be favorite
|
if ((m & 4) === 4) {
|
||||||
if (hasMask(updatedDe.m, DeviceEntityMask.DV_WEB_EXCLUDE)) {
|
new_masks.push('4');
|
||||||
updatedDe.m = updatedDe.m & ~DeviceEntityMask.DV_FAVORITE;
|
}
|
||||||
}
|
if ((m & 8) === 8) {
|
||||||
|
new_masks.push('8');
|
||||||
onUpdate(updatedDe);
|
}
|
||||||
},
|
if ((m & 128) === 128) {
|
||||||
[de, onUpdate]
|
new_masks.push('128');
|
||||||
);
|
}
|
||||||
|
return new_masks;
|
||||||
// Memoize mask string value
|
};
|
||||||
const maskStringValue = useMemo(() => getMaskString(de.m), [de.m]);
|
|
||||||
|
|
||||||
// Memoize disabled states
|
|
||||||
const isFavoriteDisabled = useMemo(
|
|
||||||
() =>
|
|
||||||
hasMask(de.m, DeviceEntityMask.DV_WEB_EXCLUDE | DeviceEntityMask.DV_DELETED) ||
|
|
||||||
de.n === undefined,
|
|
||||||
[de.m, de.n]
|
|
||||||
);
|
|
||||||
|
|
||||||
const isReadonlyDisabled = useMemo(
|
|
||||||
() =>
|
|
||||||
!de.w ||
|
|
||||||
hasMask(de.m, DeviceEntityMask.DV_WEB_EXCLUDE | DeviceEntityMask.DV_FAVORITE),
|
|
||||||
[de.w, de.m]
|
|
||||||
);
|
|
||||||
|
|
||||||
const isApiMqttExcludeDisabled = useMemo(
|
|
||||||
() => de.n === '' || hasMask(de.m, DeviceEntityMask.DV_DELETED),
|
|
||||||
[de.n, de.m]
|
|
||||||
);
|
|
||||||
|
|
||||||
const isWebExcludeDisabled = useMemo(
|
|
||||||
() => de.n === undefined || hasMask(de.m, DeviceEntityMask.DV_DELETED),
|
|
||||||
[de.n, de.m]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Memoize mask flag checks
|
|
||||||
const isFavoriteSet = useMemo(
|
|
||||||
() => hasMask(de.m, DeviceEntityMask.DV_FAVORITE),
|
|
||||||
[de.m]
|
|
||||||
);
|
|
||||||
const isReadonlySet = useMemo(
|
|
||||||
() => hasMask(de.m, DeviceEntityMask.DV_READONLY),
|
|
||||||
[de.m]
|
|
||||||
);
|
|
||||||
const isApiMqttExcludeSet = useMemo(
|
|
||||||
() => hasMask(de.m, DeviceEntityMask.DV_API_MQTT_EXCLUDE),
|
|
||||||
[de.m]
|
|
||||||
);
|
|
||||||
const isWebExcludeSet = useMemo(
|
|
||||||
() => hasMask(de.m, DeviceEntityMask.DV_WEB_EXCLUDE),
|
|
||||||
[de.m]
|
|
||||||
);
|
|
||||||
const isDeletedSet = useMemo(
|
|
||||||
() => hasMask(de.m, DeviceEntityMask.DV_DELETED),
|
|
||||||
[de.m]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ToggleButtonGroup
|
<ToggleButtonGroup
|
||||||
size="small"
|
size="small"
|
||||||
color="secondary"
|
color="secondary"
|
||||||
value={maskStringValue}
|
value={getMaskString(de.m)}
|
||||||
onChange={handleChange}
|
onChange={(_event, mask: string[]) => {
|
||||||
|
de.m = getMaskNumber(mask);
|
||||||
|
if (de.n === '' && de.m & DeviceEntityMask.DV_READONLY) {
|
||||||
|
de.m = de.m | DeviceEntityMask.DV_WEB_EXCLUDE;
|
||||||
|
}
|
||||||
|
if (de.m & DeviceEntityMask.DV_WEB_EXCLUDE) {
|
||||||
|
de.m = de.m & ~DeviceEntityMask.DV_FAVORITE;
|
||||||
|
}
|
||||||
|
onUpdate(de);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<ToggleButton value="8" disabled={isFavoriteDisabled}>
|
<ToggleButton value="8" disabled={(de.m & 0x81) !== 0 || de.n === undefined}>
|
||||||
<OptionIcon type="favorite" isSet={isFavoriteSet} />
|
<OptionIcon
|
||||||
|
type="favorite"
|
||||||
|
isSet={
|
||||||
|
(de.m & DeviceEntityMask.DV_FAVORITE) === DeviceEntityMask.DV_FAVORITE
|
||||||
|
}
|
||||||
|
/>
|
||||||
</ToggleButton>
|
</ToggleButton>
|
||||||
<ToggleButton value="4" disabled={isReadonlyDisabled}>
|
<ToggleButton value="4" disabled={!de.w || (de.m & 0x83) >= 3}>
|
||||||
<OptionIcon type="readonly" isSet={isReadonlySet} />
|
<OptionIcon
|
||||||
|
type="readonly"
|
||||||
|
isSet={
|
||||||
|
(de.m & DeviceEntityMask.DV_READONLY) === DeviceEntityMask.DV_READONLY
|
||||||
|
}
|
||||||
|
/>
|
||||||
</ToggleButton>
|
</ToggleButton>
|
||||||
<ToggleButton value="2" disabled={isApiMqttExcludeDisabled}>
|
<ToggleButton value="2" disabled={de.n === '' || (de.m & 0x80) !== 0}>
|
||||||
<OptionIcon type="api_mqtt_exclude" isSet={isApiMqttExcludeSet} />
|
<OptionIcon
|
||||||
|
type="api_mqtt_exclude"
|
||||||
|
isSet={
|
||||||
|
(de.m & DeviceEntityMask.DV_API_MQTT_EXCLUDE) ===
|
||||||
|
DeviceEntityMask.DV_API_MQTT_EXCLUDE
|
||||||
|
}
|
||||||
|
/>
|
||||||
</ToggleButton>
|
</ToggleButton>
|
||||||
<ToggleButton value="1" disabled={isWebExcludeDisabled}>
|
<ToggleButton value="1" disabled={de.n === undefined || (de.m & 0x80) !== 0}>
|
||||||
<OptionIcon type="web_exclude" isSet={isWebExcludeSet} />
|
<OptionIcon
|
||||||
|
type="web_exclude"
|
||||||
|
isSet={
|
||||||
|
(de.m & DeviceEntityMask.DV_WEB_EXCLUDE) ===
|
||||||
|
DeviceEntityMask.DV_WEB_EXCLUDE
|
||||||
|
}
|
||||||
|
/>
|
||||||
</ToggleButton>
|
</ToggleButton>
|
||||||
<ToggleButton value="128">
|
<ToggleButton value="128">
|
||||||
<OptionIcon type="deleted" isSet={isDeletedSet} />
|
<OptionIcon
|
||||||
|
type="deleted"
|
||||||
|
isSet={
|
||||||
|
(de.m & DeviceEntityMask.DV_DELETED) === DeviceEntityMask.DV_DELETED
|
||||||
|
}
|
||||||
|
/>
|
||||||
</ToggleButton>
|
</ToggleButton>
|
||||||
</ToggleButtonGroup>
|
</ToggleButtonGroup>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { memo, useCallback, useContext, useMemo, useState } from 'react';
|
import { 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';
|
||||||
@@ -20,7 +19,6 @@ 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';
|
||||||
@@ -31,61 +29,26 @@ 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';
|
||||||
|
|
||||||
interface HelpLink {
|
const Help = () => {
|
||||||
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 HelpComponent = () => {
|
|
||||||
const { LL } = useI18nContext();
|
const { LL } = useI18nContext();
|
||||||
useLayoutTitle(LL.HELP());
|
useLayoutTitle(LL.HELP());
|
||||||
|
|
||||||
const { me } = useContext(AuthenticatedContext);
|
const { me } = useContext(AuthenticatedContext);
|
||||||
|
|
||||||
const [customSupport, setCustomSupport] = useState<CustomSupport>({
|
const [customSupportIMG, setCustomSupportIMG] = useState<string | null>(null);
|
||||||
img_url: null,
|
const [customSupportHTML, setCustomSupportHTML] = useState<string | null>(null);
|
||||||
html: null
|
const [notFound, setNotFound] = useState<boolean>(false);
|
||||||
});
|
|
||||||
const [imgError, setImgError] = useState<boolean>(false);
|
|
||||||
|
|
||||||
// Memoize the request method to prevent re-creation on every render
|
useRequest(() => callAction({ action: 'getCustomSupport' })).onSuccess((event) => {
|
||||||
const getCustomSupportMethod = useMemo(
|
if (event && event.data && Object.keys(event.data).length !== 0) {
|
||||||
() => callAction({ action: 'getCustomSupport' }),
|
const data = (event.data as { Support: { img_url?: string; html?: string[] } })
|
||||||
[]
|
.Support;
|
||||||
);
|
if (data.img_url) {
|
||||||
|
setCustomSupportIMG(data.img_url);
|
||||||
useRequest(getCustomSupportMethod).onSuccess((event) => {
|
}
|
||||||
if (event?.data && Object.keys(event.data).length !== 0) {
|
if (data.html) {
|
||||||
const { Support } = event.data as {
|
setCustomSupportHTML(data.html.join('<br/>'));
|
||||||
Support: { img_url?: string; html?: string[] };
|
}
|
||||||
};
|
|
||||||
setCustomSupport({
|
|
||||||
img_url: Support.img_url || null,
|
|
||||||
html: Support.html?.join('<br/>') || null
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -100,88 +63,90 @@ const HelpComponent = () => {
|
|||||||
toast.error(String(error.error?.message || 'An error occurred'));
|
toast.error(String(error.error?.message || 'An error occurred'));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Optimize API call memoization
|
|
||||||
const apiCall = useMemo(() => ({ device: 'system', cmd: 'info', id: 0 }), []);
|
|
||||||
|
|
||||||
const handleDownloadSystemInfo = useCallback(() => {
|
|
||||||
void sendAPI(apiCall);
|
|
||||||
}, [sendAPI, apiCall]);
|
|
||||||
|
|
||||||
const handleImageError = useCallback(() => {
|
|
||||||
setImgError(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Memoize help links to prevent recreation on every render
|
|
||||||
const helpLinks: HelpLink[] = useMemo(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
href: 'https://emsesp.org',
|
|
||||||
icon: <MenuBookIcon />,
|
|
||||||
label: () => LL.HELP_INFORMATION_1()
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: 'https://discord.gg/3J3GgnzpyT',
|
|
||||||
icon: <CommentIcon />,
|
|
||||||
label: () => LL.HELP_INFORMATION_2()
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: 'https://github.com/emsesp/EMS-ESP32/issues/new/choose',
|
|
||||||
icon: <GitHubIcon />,
|
|
||||||
label: () => LL.HELP_INFORMATION_3()
|
|
||||||
}
|
|
||||||
],
|
|
||||||
[LL]
|
|
||||||
);
|
|
||||||
|
|
||||||
const isAdmin = useMemo(() => me?.admin ?? false, [me?.admin]);
|
|
||||||
|
|
||||||
// Memoize image source computation
|
|
||||||
const imageSrc = useMemo(
|
|
||||||
() =>
|
|
||||||
imgError || !customSupport.img_url ? DEFAULT_IMAGE_URL : customSupport.img_url,
|
|
||||||
[imgError, customSupport.img_url]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionContent>
|
<SectionContent>
|
||||||
{customSupport.html && (
|
{customSupportHTML && (
|
||||||
<Stack
|
<Stack
|
||||||
padding={1}
|
padding={1}
|
||||||
mb={2}
|
mb={2}
|
||||||
direction="row"
|
direction="row"
|
||||||
divider={<Divider orientation="vertical" flexItem />}
|
divider={<Divider orientation="vertical" flexItem />}
|
||||||
sx={SUPPORT_BOX_STYLES}
|
sx={{
|
||||||
|
borderRadius: 3,
|
||||||
|
border: '1px solid lightblue',
|
||||||
|
justifyContent: 'space-evenly',
|
||||||
|
alignItems: 'center'
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Typography variant="subtitle1">
|
<Typography variant="subtitle1">
|
||||||
<div dangerouslySetInnerHTML={{ __html: customSupport.html }} />
|
<div dangerouslySetInnerHTML={{ __html: customSupportHTML }} />
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box
|
<Box
|
||||||
component="img"
|
component="img"
|
||||||
referrerPolicy="no-referrer"
|
referrerPolicy="no-referrer"
|
||||||
sx={IMAGE_STYLES}
|
sx={{
|
||||||
onError={handleImageError}
|
maxHeight: { xs: 100, md: 250 }
|
||||||
src={imageSrc}
|
}}
|
||||||
|
onError={() => setNotFound(true)}
|
||||||
|
src={
|
||||||
|
notFound
|
||||||
|
? ''
|
||||||
|
: customSupportIMG ||
|
||||||
|
'https://docs.emsesp.org/_media/images/installer.jpeg'
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isAdmin && (
|
{me.admin && (
|
||||||
<List>
|
<List>
|
||||||
{helpLinks.map(({ href, icon, label }) => (
|
<ListItem>
|
||||||
<ListItem key={href}>
|
<ListItemButton
|
||||||
<ListItemButton
|
component="a"
|
||||||
component="a"
|
target="_blank"
|
||||||
target="_blank"
|
rel="noreferrer"
|
||||||
rel="noreferrer"
|
href="https://docs.emsesp.org"
|
||||||
href={href}
|
>
|
||||||
>
|
<ListItemAvatar>
|
||||||
<ListItemAvatar>
|
<Avatar sx={{ bgcolor: '#72caf9' }}>
|
||||||
<Avatar sx={AVATAR_STYLES}>{icon}</Avatar>
|
<MenuBookIcon />
|
||||||
</ListItemAvatar>
|
</Avatar>
|
||||||
<ListItemText primary={label()} />
|
</ListItemAvatar>
|
||||||
</ListItemButton>
|
<ListItemText primary={LL.HELP_INFORMATION_1()} />
|
||||||
</ListItem>
|
</ListItemButton>
|
||||||
))}
|
</ListItem>
|
||||||
|
|
||||||
|
<ListItem>
|
||||||
|
<ListItemButton
|
||||||
|
component="a"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
href="https://discord.gg/3J3GgnzpyT"
|
||||||
|
>
|
||||||
|
<ListItemAvatar>
|
||||||
|
<Avatar sx={{ bgcolor: '#72caf9' }}>
|
||||||
|
<CommentIcon />
|
||||||
|
</Avatar>
|
||||||
|
</ListItemAvatar>
|
||||||
|
<ListItemText primary={LL.HELP_INFORMATION_2()} />
|
||||||
|
</ListItemButton>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
|
<ListItem>
|
||||||
|
<ListItemButton
|
||||||
|
component="a"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
href="https://github.com/emsesp/EMS-ESP32/issues/new/choose"
|
||||||
|
>
|
||||||
|
<ListItemAvatar>
|
||||||
|
<Avatar sx={{ bgcolor: '#72caf9' }}>
|
||||||
|
<GitHubIcon />
|
||||||
|
</Avatar>
|
||||||
|
</ListItemAvatar>
|
||||||
|
<ListItemText primary={LL.HELP_INFORMATION_3()} />
|
||||||
|
</ListItemButton>
|
||||||
|
</ListItem>
|
||||||
</List>
|
</List>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -193,7 +158,7 @@ const HelpComponent = () => {
|
|||||||
startIcon={<DownloadIcon />}
|
startIcon={<DownloadIcon />}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
color="primary"
|
color="primary"
|
||||||
onClick={handleDownloadSystemInfo}
|
onClick={() => sendAPI({ device: 'system', cmd: 'info', id: 0 })}
|
||||||
>
|
>
|
||||||
{LL.SUPPORT_INFORMATION(0)}
|
{LL.SUPPORT_INFORMATION(0)}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -209,14 +174,11 @@ const HelpComponent = () => {
|
|||||||
href="https://emsesp.org"
|
href="https://emsesp.org"
|
||||||
color="primary"
|
color="primary"
|
||||||
>
|
>
|
||||||
emsesp.org
|
{'emsesp.org'}
|
||||||
</Link>
|
</Link>
|
||||||
</Typography>
|
</Typography>
|
||||||
</SectionContent>
|
</SectionContent>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Memoize the component to prevent unnecessary re-renders
|
|
||||||
const Help = memo(HelpComponent);
|
|
||||||
|
|
||||||
export default Help;
|
export default Help;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { memo, useCallback, useMemo, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
import { useBlocker } from 'react-router';
|
import { useBlocker } from 'react-router';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
@@ -31,19 +31,6 @@ 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);
|
||||||
@@ -69,107 +56,105 @@ const Modules = () => {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const modules_theme = useTheme(
|
const modules_theme = useTheme({
|
||||||
useMemo(
|
Table: `
|
||||||
() => ({
|
--data-table-library_grid-template-columns: 48px 180px 120px 100px repeat(1, minmax(160px, 1fr)) 180px;
|
||||||
Table: `
|
`,
|
||||||
--data-table-library_grid-template-columns: 48px 180px 120px 100px repeat(1, minmax(160px, 1fr)) 180px;
|
BaseRow: `
|
||||||
`,
|
font-size: 14px;
|
||||||
BaseRow: `
|
.td {
|
||||||
font-size: 14px;
|
height: 32px;
|
||||||
.td {
|
}
|
||||||
height: 32px;
|
`,
|
||||||
}
|
BaseCell: `
|
||||||
`,
|
&:nth-of-type(1) {
|
||||||
BaseCell: `
|
text-align: center;
|
||||||
&:nth-of-type(1) {
|
}
|
||||||
text-align: center;
|
`,
|
||||||
}
|
HeaderRow: `
|
||||||
`,
|
text-transform: uppercase;
|
||||||
HeaderRow: `
|
background-color: black;
|
||||||
text-transform: uppercase;
|
color: #90CAF9;
|
||||||
background-color: black;
|
.th {
|
||||||
color: #90CAF9;
|
border-bottom: 1px solid #565656;
|
||||||
.th {
|
height: 36px;
|
||||||
border-bottom: 1px solid #565656;
|
}
|
||||||
height: 36px;
|
`,
|
||||||
}
|
Row: `
|
||||||
`,
|
background-color: #1e1e1e;
|
||||||
Row: `
|
position: relative;
|
||||||
background-color: #1e1e1e;
|
cursor: pointer;
|
||||||
position: relative;
|
.td {
|
||||||
cursor: pointer;
|
border-top: 1px solid #565656;
|
||||||
.td {
|
border-bottom: 1px solid #565656;
|
||||||
border-top: 1px solid #565656;
|
}
|
||||||
border-bottom: 1px solid #565656;
|
&:hover .td {
|
||||||
}
|
border-top: 1px solid #177ac9;
|
||||||
&:hover .td {
|
border-bottom: 1px solid #177ac9;
|
||||||
border-top: 1px solid #177ac9;
|
}
|
||||||
border-bottom: 1px solid #177ac9;
|
&:nth-of-type(odd) .td {
|
||||||
}
|
background-color: #303030;
|
||||||
&:nth-of-type(odd) .td {
|
}
|
||||||
background-color: #303030;
|
`
|
||||||
}
|
});
|
||||||
`
|
|
||||||
}),
|
|
||||||
[]
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
const onDialogClose = useCallback(() => {
|
const onDialogClose = () => {
|
||||||
setDialogOpen(false);
|
setDialogOpen(false);
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const updateModuleItem = useCallback((updatedItem: ModuleItem) => {
|
const onDialogSave = (updatedItem: ModuleItem) => {
|
||||||
void updateState(readModules(), (data: ModuleItem[]) => {
|
setDialogOpen(false);
|
||||||
const new_data = data.map((mi) =>
|
updateModuleItem(updatedItem);
|
||||||
mi.id === updatedItem.id ? { ...mi, ...updatedItem } : mi
|
};
|
||||||
);
|
|
||||||
setNumChanges(new_data.filter(hasModulesChanged).length);
|
|
||||||
return new_data;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const onDialogSave = useCallback(
|
|
||||||
(updatedItem: ModuleItem) => {
|
|
||||||
setDialogOpen(false);
|
|
||||||
updateModuleItem(updatedItem);
|
|
||||||
},
|
|
||||||
[updateModuleItem]
|
|
||||||
);
|
|
||||||
|
|
||||||
const editModuleItem = useCallback((mi: ModuleItem) => {
|
const editModuleItem = useCallback((mi: ModuleItem) => {
|
||||||
setSelectedModuleItem(mi);
|
setSelectedModuleItem(mi);
|
||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const onCancel = useCallback(async () => {
|
const onCancel = async () => {
|
||||||
await fetchModules().then(() => {
|
await fetchModules().then(() => {
|
||||||
setNumChanges(0);
|
setNumChanges(0);
|
||||||
});
|
});
|
||||||
}, [fetchModules]);
|
};
|
||||||
|
|
||||||
const saveModules = useCallback(async () => {
|
function hasModulesChanged(mi: ModuleItem) {
|
||||||
try {
|
return mi.enabled !== mi.o_enabled || mi.license !== mi.o_license;
|
||||||
await Promise.all(
|
}
|
||||||
modules.map((condensed_mi: ModuleItem) =>
|
|
||||||
updateModules({
|
const updateModuleItem = (updatedItem: ModuleItem) => {
|
||||||
key: condensed_mi.key,
|
void updateState(readModules(), (data: ModuleItem[]) => {
|
||||||
enabled: condensed_mi.enabled,
|
const new_data = data.map((mi) =>
|
||||||
license: condensed_mi.license
|
mi.id === updatedItem.id ? { ...mi, ...updatedItem } : mi
|
||||||
})
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
toast.success(LL.MODULES_UPDATED());
|
setNumChanges(new_data.filter((mi) => hasModulesChanged(mi)).length);
|
||||||
} catch (error) {
|
return new_data;
|
||||||
toast.error(error instanceof Error ? error.message : String(error));
|
});
|
||||||
} finally {
|
};
|
||||||
await fetchModules();
|
|
||||||
setNumChanges(0);
|
|
||||||
}
|
|
||||||
}, [modules, updateModules, LL, fetchModules]);
|
|
||||||
|
|
||||||
const content = useMemo(() => {
|
const saveModules = async () => {
|
||||||
|
await Promise.all(
|
||||||
|
modules.map((condensed_mi: ModuleItem) =>
|
||||||
|
updateModules({
|
||||||
|
key: condensed_mi.key,
|
||||||
|
enabled: condensed_mi.enabled,
|
||||||
|
license: condensed_mi.license
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.then(() => {
|
||||||
|
toast.success(LL.MODULES_UPDATED());
|
||||||
|
})
|
||||||
|
.catch((error: Error) => {
|
||||||
|
toast.error(error.message);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await fetchModules();
|
||||||
|
setNumChanges(0);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderContent = () => {
|
||||||
if (!modules) {
|
if (!modules) {
|
||||||
return (
|
return (
|
||||||
<FormLoader onRetry={fetchModules} errorMessage={error?.message || ''} />
|
<FormLoader onRetry={fetchModules} errorMessage={error?.message || ''} />
|
||||||
@@ -184,6 +169,13 @@ 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">
|
<Box mb={2} color="warning.main">
|
||||||
@@ -226,9 +218,7 @@ 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>
|
<Cell>{colorStatus(mi.status)}</Cell>
|
||||||
<ColorStatus status={mi.status} />
|
|
||||||
</Cell>
|
|
||||||
</Row>
|
</Row>
|
||||||
))}
|
))}
|
||||||
</Body>
|
</Body>
|
||||||
@@ -262,22 +252,12 @@ const Modules = () => {
|
|||||||
</Box>
|
</Box>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}, [
|
};
|
||||||
modules,
|
|
||||||
fetchModules,
|
|
||||||
error,
|
|
||||||
modules_theme,
|
|
||||||
editModuleItem,
|
|
||||||
LL,
|
|
||||||
numChanges,
|
|
||||||
onCancel,
|
|
||||||
saveModules
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionContent>
|
<SectionContent>
|
||||||
{blocker ? <BlockNavigation blocker={blocker} /> : null}
|
{blocker ? <BlockNavigation blocker={blocker} /> : null}
|
||||||
{content}
|
{renderContent()}
|
||||||
{selectedModuleItem && (
|
{selectedModuleItem && (
|
||||||
<ModulesDialog
|
<ModulesDialog
|
||||||
open={dialogOpen}
|
open={dialogOpen}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo, 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 DoneIcon from '@mui/icons-material/Done';
|
||||||
@@ -37,35 +37,25 @@ const ModulesDialog = ({
|
|||||||
const { LL } = useI18nContext();
|
const { LL } = useI18nContext();
|
||||||
const [editItem, setEditItem] = useState<ModuleItem>(selectedItem);
|
const [editItem, setEditItem] = useState<ModuleItem>(selectedItem);
|
||||||
|
|
||||||
const updateFormValue = useMemo(
|
const updateFormValue = updateValue(setEditItem);
|
||||||
() =>
|
|
||||||
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 handleSave = useCallback(() => {
|
const close = () => {
|
||||||
onSave(editItem);
|
onClose();
|
||||||
}, [editItem, onSave]);
|
};
|
||||||
|
|
||||||
const dialogTitle = useMemo(
|
const save = () => {
|
||||||
() => `${LL.EDIT()} ${editItem.key}`,
|
onSave(editItem);
|
||||||
[LL, editItem.key]
|
};
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog sx={dialogStyle} fullWidth maxWidth="xs" open={open} onClose={onClose}>
|
<Dialog sx={dialogStyle} fullWidth maxWidth="xs" open={open} onClose={onClose}>
|
||||||
<DialogTitle>{dialogTitle}</DialogTitle>
|
<DialogTitle>{LL.EDIT() + ' ' + editItem.key}</DialogTitle>
|
||||||
<DialogContent dividers>
|
<DialogContent dividers>
|
||||||
<Grid container>
|
<Grid container>
|
||||||
<BlockFormControlLabel
|
<BlockFormControlLabel
|
||||||
@@ -95,7 +85,7 @@ const ModulesDialog = ({
|
|||||||
<Button
|
<Button
|
||||||
startIcon={<CancelIcon />}
|
startIcon={<CancelIcon />}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
onClick={onClose}
|
onClick={close}
|
||||||
color="secondary"
|
color="secondary"
|
||||||
>
|
>
|
||||||
{LL.CANCEL()}
|
{LL.CANCEL()}
|
||||||
@@ -103,7 +93,7 @@ const ModulesDialog = ({
|
|||||||
<Button
|
<Button
|
||||||
startIcon={<DoneIcon />}
|
startIcon={<DoneIcon />}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
onClick={handleSave}
|
onClick={save}
|
||||||
color="primary"
|
color="primary"
|
||||||
>
|
>
|
||||||
{LL.UPDATE()}
|
{LL.UPDATE()}
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
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/DeleteOutline';
|
||||||
@@ -12,39 +10,33 @@ 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';
|
||||||
|
|
||||||
export type OptionType =
|
type OptionType =
|
||||||
| 'deleted'
|
| 'deleted'
|
||||||
| 'readonly'
|
| 'readonly'
|
||||||
| 'web_exclude'
|
| 'web_exclude'
|
||||||
| 'api_mqtt_exclude'
|
| 'api_mqtt_exclude'
|
||||||
| 'favorite';
|
| 'favorite';
|
||||||
|
|
||||||
type IconPair = [
|
const OPTION_ICONS: {
|
||||||
React.ComponentType<SvgIconProps>,
|
[type in OptionType]: [
|
||||||
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} />;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default memo(OptionIcon);
|
const OptionIcon = ({ type, isSet }: { type: OptionType; isSet: boolean }) => {
|
||||||
|
const Icon = OPTION_ICONS[type][isSet ? 0 : 1];
|
||||||
|
return isSet ? (
|
||||||
|
<Icon color="primary" sx={{ fontSize: 16, verticalAlign: 'middle' }} />
|
||||||
|
) : (
|
||||||
|
<Icon sx={{ fontSize: 16, verticalAlign: 'middle' }} />
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default OptionIcon;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, 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,76 +35,6 @@ 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 REFERENCE_YEAR = 2017;
|
|
||||||
const REFERENCE_MONTH = '01';
|
|
||||||
const LOG_2 = Math.log(2);
|
|
||||||
|
|
||||||
// Days of week starting from Monday (1-7)
|
|
||||||
const WEEK_DAYS = [1, 2, 3, 4, 5, 6, 7] as const;
|
|
||||||
|
|
||||||
const DEFAULT_SCHEDULE_ITEM: Omit<ScheduleItem, 'id' | 'o_id'> = {
|
|
||||||
active: false,
|
|
||||||
deleted: false,
|
|
||||||
flags: ScheduleFlag.SCHEDULE_DAY,
|
|
||||||
time: '',
|
|
||||||
cmd: '',
|
|
||||||
value: '',
|
|
||||||
name: ''
|
|
||||||
};
|
|
||||||
|
|
||||||
const scheduleTheme = {
|
|
||||||
Table: `
|
|
||||||
--data-table-library_grid-template-columns: 36px 210px 100px 192px repeat(1, minmax(100px, 1fr)) 160px;
|
|
||||||
`,
|
|
||||||
BaseRow: `
|
|
||||||
font-size: 14px;
|
|
||||||
.td {
|
|
||||||
height: 32px;
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
BaseCell: `
|
|
||||||
&:nth-of-type(2) {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
&:nth-of-type(1) {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
HeaderRow: `
|
|
||||||
text-transform: uppercase;
|
|
||||||
background-color: black;
|
|
||||||
color: #90CAF9;
|
|
||||||
.th {
|
|
||||||
border-bottom: 1px solid #565656;
|
|
||||||
height: 36px;
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
Row: `
|
|
||||||
background-color: #1e1e1e;
|
|
||||||
position: relative;
|
|
||||||
cursor: pointer;
|
|
||||||
.td {
|
|
||||||
border-bottom: 1px solid #565656;
|
|
||||||
}
|
|
||||||
&:hover .td {
|
|
||||||
background-color: #177ac9;
|
|
||||||
}
|
|
||||||
`
|
|
||||||
};
|
|
||||||
|
|
||||||
const scheduleTypeLabels: Record<number, string> = {
|
|
||||||
[ScheduleFlag.SCHEDULE_IMMEDIATE]: 'Immediate',
|
|
||||||
[ScheduleFlag.SCHEDULE_TIMER]: 'Timer',
|
|
||||||
[ScheduleFlag.SCHEDULE_CONDITION]: 'Condition',
|
|
||||||
[ScheduleFlag.SCHEDULE_ONCHANGE]: 'On Change'
|
|
||||||
};
|
|
||||||
|
|
||||||
const Scheduler = () => {
|
const Scheduler = () => {
|
||||||
const { LL, locale } = useI18nContext();
|
const { LL, locale } = useI18nContext();
|
||||||
const [numChanges, setNumChanges] = useState<number>(0);
|
const [numChanges, setNumChanges] = useState<number>(0);
|
||||||
@@ -131,7 +61,7 @@ const Scheduler = () => {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const hasScheduleChanged = useCallback((si: ScheduleItem) => {
|
function 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 || '') ||
|
||||||
@@ -142,56 +72,91 @@ const Scheduler = () => {
|
|||||||
si.cmd !== si.o_cmd ||
|
si.cmd !== si.o_cmd ||
|
||||||
si.value !== si.o_value
|
si.value !== si.o_value
|
||||||
);
|
);
|
||||||
}, []);
|
}
|
||||||
|
|
||||||
const intervalCallback = useCallback(() => {
|
useInterval(() => {
|
||||||
if (numChanges === 0) {
|
if (numChanges === 0) {
|
||||||
void fetchSchedule();
|
void fetchSchedule();
|
||||||
}
|
}
|
||||||
}, [numChanges, fetchSchedule]);
|
});
|
||||||
|
|
||||||
useInterval(intervalCallback, 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 = WEEK_DAYS.map((day) => {
|
const days = [1, 2, 3, 4, 5, 6, 7].map((day) => {
|
||||||
const dayStr = String(day).padStart(2, '0');
|
const dd = day < 10 ? `0${day}` : day;
|
||||||
return new Date(
|
return new Date(`2017-01-${dd}T00:00:00+00:00`);
|
||||||
`${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(scheduleTheme);
|
const schedule_theme = useTheme({
|
||||||
|
Table: `
|
||||||
|
--data-table-library_grid-template-columns: 36px 210px 100px 192px repeat(1, minmax(100px, 1fr)) 160px;
|
||||||
|
`,
|
||||||
|
BaseRow: `
|
||||||
|
font-size: 14px;
|
||||||
|
.td {
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
BaseCell: `
|
||||||
|
&:nth-of-type(2) {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
&:nth-of-type(1) {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
HeaderRow: `
|
||||||
|
text-transform: uppercase;
|
||||||
|
background-color: black;
|
||||||
|
color: #90CAF9;
|
||||||
|
.th {
|
||||||
|
border-bottom: 1px solid #565656;
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
Row: `
|
||||||
|
background-color: #1e1e1e;
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
.td {
|
||||||
|
border-bottom: 1px solid #565656;
|
||||||
|
}
|
||||||
|
&:hover .td {
|
||||||
|
background-color: #177ac9;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
});
|
||||||
|
|
||||||
const saveSchedule = useCallback(async () => {
|
const saveSchedule = async () => {
|
||||||
try {
|
await updateSchedule({
|
||||||
await updateSchedule({
|
schedule: schedule
|
||||||
schedule: schedule
|
.filter((si: ScheduleItem) => !si.deleted)
|
||||||
.filter((si: ScheduleItem) => !si.deleted)
|
.map((condensed_si: ScheduleItem) => ({
|
||||||
.map((condensed_si: ScheduleItem) => ({
|
id: condensed_si.id,
|
||||||
id: condensed_si.id,
|
active: condensed_si.active,
|
||||||
active: condensed_si.active,
|
flags: condensed_si.flags,
|
||||||
flags: condensed_si.flags,
|
time: condensed_si.time,
|
||||||
time: condensed_si.time,
|
cmd: condensed_si.cmd,
|
||||||
cmd: condensed_si.cmd,
|
value: condensed_si.value,
|
||||||
value: condensed_si.value,
|
name: condensed_si.name
|
||||||
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);
|
|
||||||
}
|
|
||||||
}, [LL, schedule, updateSchedule, fetchSchedule]);
|
|
||||||
|
|
||||||
const editScheduleItem = useCallback((si: ScheduleItem) => {
|
const editScheduleItem = useCallback((si: ScheduleItem) => {
|
||||||
setCreating(false);
|
setCreating(false);
|
||||||
@@ -202,93 +167,95 @@ const Scheduler = () => {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const onDialogClose = useCallback(() => {
|
const onDialogClose = () => {
|
||||||
setDialogOpen(false);
|
setDialogOpen(false);
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const onDialogCancel = useCallback(async () => {
|
const onDialogCancel = async () => {
|
||||||
await fetchSchedule().then(() => {
|
await fetchSchedule().then(() => {
|
||||||
setNumChanges(0);
|
setNumChanges(0);
|
||||||
});
|
});
|
||||||
}, [fetchSchedule]);
|
};
|
||||||
|
|
||||||
const onDialogSave = useCallback(
|
const onDialogSave = (updatedItem: ScheduleItem) => {
|
||||||
(updatedItem: ScheduleItem) => {
|
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),
|
||||||
: data.map((si) =>
|
updatedItem
|
||||||
si.id === updatedItem.id ? { ...si, ...updatedItem } : si
|
]
|
||||||
);
|
: data.map((si) =>
|
||||||
|
si.id === updatedItem.id ? { ...si, ...updatedItem } : si
|
||||||
|
);
|
||||||
|
|
||||||
setNumChanges(new_data.filter((si) => hasScheduleChanged(si)).length);
|
setNumChanges(new_data.filter((si) => hasScheduleChanged(si)).length);
|
||||||
|
|
||||||
return new_data;
|
return new_data;
|
||||||
});
|
});
|
||||||
},
|
};
|
||||||
[creating, hasScheduleChanged]
|
|
||||||
);
|
|
||||||
|
|
||||||
const addScheduleItem = useCallback(() => {
|
const addScheduleItem = () => {
|
||||||
setCreating(true);
|
setCreating(true);
|
||||||
const newItem: ScheduleItem = {
|
setSelectedScheduleItem({
|
||||||
id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID),
|
id: Math.floor(Math.random() * (Math.floor(200) - 100) + 100),
|
||||||
...DEFAULT_SCHEDULE_ITEM
|
active: false,
|
||||||
};
|
deleted: false,
|
||||||
setSelectedScheduleItem(newItem);
|
flags: ScheduleFlag.SCHEDULE_DAY,
|
||||||
|
time: '',
|
||||||
|
cmd: '',
|
||||||
|
value: '',
|
||||||
|
name: ''
|
||||||
|
});
|
||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const filteredAndSortedSchedule = useMemo(
|
const renderSchedule = () => {
|
||||||
() =>
|
|
||||||
schedule
|
|
||||||
.filter((si: ScheduleItem) => !si.deleted)
|
|
||||||
.sort((a: ScheduleItem, b: ScheduleItem) => a.flags - b.flags),
|
|
||||||
[schedule]
|
|
||||||
);
|
|
||||||
|
|
||||||
const dayBox = useCallback(
|
|
||||||
(si: ScheduleItem, flag: number) => {
|
|
||||||
const dayIndex = Math.log(flag) / LOG_2;
|
|
||||||
const isActive = (si.flags & flag) === flag;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Box>
|
|
||||||
<Typography sx={{ fontSize: 11 }} color={isActive ? 'primary' : 'grey'}>
|
|
||||||
{dow[dayIndex]}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<Divider orientation="vertical" flexItem />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[dow]
|
|
||||||
);
|
|
||||||
|
|
||||||
const scheduleType = useCallback((si: ScheduleItem) => {
|
|
||||||
const label = scheduleTypeLabels[si.flags];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box>
|
|
||||||
<Typography sx={{ fontSize: 11 }} color="primary">
|
|
||||||
{label || ''}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const renderSchedule = useCallback(() => {
|
|
||||||
if (!schedule) {
|
if (!schedule) {
|
||||||
return (
|
return (
|
||||||
<FormLoader onRetry={fetchSchedule} errorMessage={error?.message || ''} />
|
<FormLoader onRetry={fetchSchedule} errorMessage={error?.message || ''} />
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const dayBox = (si: ScheduleItem, flag: number) => (
|
||||||
|
<>
|
||||||
|
<Box>
|
||||||
|
<Typography
|
||||||
|
sx={{ fontSize: 11 }}
|
||||||
|
color={(si.flags & flag) === flag ? 'primary' : 'grey'}
|
||||||
|
>
|
||||||
|
{dow[Math.log(flag) / Math.log(2)]}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Divider orientation="vertical" flexItem />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const scheduleType = (si: ScheduleItem) => (
|
||||||
|
<Box>
|
||||||
|
<Typography sx={{ fontSize: 11 }} color="primary">
|
||||||
|
{si.flags === ScheduleFlag.SCHEDULE_IMMEDIATE ? (
|
||||||
|
<>Immediate</>
|
||||||
|
) : si.flags === ScheduleFlag.SCHEDULE_TIMER ? (
|
||||||
|
<>Timer</>
|
||||||
|
) : si.flags === ScheduleFlag.SCHEDULE_CONDITION ? (
|
||||||
|
<>Condition</>
|
||||||
|
) : si.flags === ScheduleFlag.SCHEDULE_ONCHANGE ? (
|
||||||
|
<>On Change</>
|
||||||
|
) : (
|
||||||
|
<></>
|
||||||
|
)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Table
|
<Table
|
||||||
data={{ nodes: filteredAndSortedSchedule }}
|
data={{
|
||||||
|
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 }}
|
||||||
>
|
>
|
||||||
@@ -308,15 +275,22 @@ 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>
|
||||||
<CircleIcon
|
{si.active ? (
|
||||||
color={si.active ? 'success' : 'error'}
|
<CircleIcon
|
||||||
sx={{ fontSize: ICON_SIZE, verticalAlign: 'middle' }}
|
color="success"
|
||||||
/>
|
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 > SCHEDULE_FLAG_THRESHOLD ? (
|
{si.flags > 127 ? (
|
||||||
scheduleType(si)
|
scheduleType(si)
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -342,17 +316,7 @@ const Scheduler = () => {
|
|||||||
)}
|
)}
|
||||||
</Table>
|
</Table>
|
||||||
);
|
);
|
||||||
}, [
|
};
|
||||||
schedule,
|
|
||||||
error,
|
|
||||||
fetchSchedule,
|
|
||||||
filteredAndSortedSchedule,
|
|
||||||
schedule_theme,
|
|
||||||
editScheduleItem,
|
|
||||||
LL,
|
|
||||||
dayBox,
|
|
||||||
scheduleType
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionContent>
|
<SectionContent>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import AddIcon from '@mui/icons-material/Add';
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
import CancelIcon from '@mui/icons-material/Cancel';
|
import CancelIcon from '@mui/icons-material/Cancel';
|
||||||
@@ -31,34 +31,6 @@ import { 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 = 128;
|
|
||||||
const DEFAULT_TIME = '00:00';
|
|
||||||
const TYPOGRAPHY_FONT_SIZE = 10;
|
|
||||||
|
|
||||||
// Day of week flag configuration (static, defined outside component)
|
|
||||||
const DAY_FLAGS = [
|
|
||||||
{ value: '2', flag: ScheduleFlag.SCHEDULE_MON },
|
|
||||||
{ value: '4', flag: ScheduleFlag.SCHEDULE_TUE },
|
|
||||||
{ value: '8', flag: ScheduleFlag.SCHEDULE_WED },
|
|
||||||
{ value: '16', flag: ScheduleFlag.SCHEDULE_THU },
|
|
||||||
{ value: '32', flag: ScheduleFlag.SCHEDULE_FRI },
|
|
||||||
{ value: '64', flag: ScheduleFlag.SCHEDULE_SAT },
|
|
||||||
{ value: '1', flag: ScheduleFlag.SCHEDULE_SUN }
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
// Day of week flag values array (static)
|
|
||||||
const FLAG_VALUES = [
|
|
||||||
ScheduleFlag.SCHEDULE_SUN,
|
|
||||||
ScheduleFlag.SCHEDULE_MON,
|
|
||||||
ScheduleFlag.SCHEDULE_TUE,
|
|
||||||
ScheduleFlag.SCHEDULE_WED,
|
|
||||||
ScheduleFlag.SCHEDULE_THU,
|
|
||||||
ScheduleFlag.SCHEDULE_FRI,
|
|
||||||
ScheduleFlag.SCHEDULE_SAT
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
interface SchedulerDialogProps {
|
interface SchedulerDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
creating: boolean;
|
creating: boolean;
|
||||||
@@ -81,163 +53,110 @@ 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 = useMemo(
|
const updateFormValue = updateValue(setEditItem);
|
||||||
() =>
|
|
||||||
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 < SCHEDULE_TYPE_THRESHOLD
|
selectedItem.flags < 128 ? ScheduleFlag.SCHEDULE_DAY : selectedItem.flags
|
||||||
? ScheduleFlag.SCHEDULE_DAY
|
|
||||||
: selectedItem.flags
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, [open, selectedItem]);
|
}, [open, selectedItem]);
|
||||||
|
|
||||||
// Helper function to handle save operations
|
const save = async () => {
|
||||||
const handleSave = useCallback(
|
try {
|
||||||
async (itemToSave: ScheduleItem) => {
|
setFieldErrors(undefined);
|
||||||
try {
|
await validate(validator, editItem);
|
||||||
setFieldErrors(undefined);
|
onSave(editItem);
|
||||||
await validate(validator, itemToSave);
|
} catch (error) {
|
||||||
onSave(itemToSave);
|
setFieldErrors(error as ValidateFieldsError);
|
||||||
} catch (error) {
|
|
||||||
setFieldErrors(error as ValidateFieldsError);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[validator, onSave]
|
|
||||||
);
|
|
||||||
|
|
||||||
const save = useCallback(async () => {
|
|
||||||
await handleSave(editItem);
|
|
||||||
}, [editItem, handleSave]);
|
|
||||||
|
|
||||||
const saveandactivate = useCallback(async () => {
|
|
||||||
await handleSave({ ...editItem, active: true });
|
|
||||||
}, [editItem, handleSave]);
|
|
||||||
|
|
||||||
const remove = useCallback(() => {
|
|
||||||
onSave({ ...editItem, deleted: true });
|
|
||||||
}, [editItem, onSave]);
|
|
||||||
|
|
||||||
// Optimize DOW flag conversion
|
|
||||||
const getFlagDOWnumber = useCallback((flags: string[]) => {
|
|
||||||
return flags.reduce((acc, flag) => acc | Number(flag), 0) & FLAG_MASK_127;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const getFlagDOWstring = useCallback((f: number) => {
|
|
||||||
return FLAG_VALUES.filter((flag) => (f & flag) === flag).map((flag) =>
|
|
||||||
String(flag)
|
|
||||||
);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Day of week display component
|
|
||||||
const DayOfWeekButton = useCallback(
|
|
||||||
(flag: number) => {
|
|
||||||
const dayIndex = Math.log2(flag);
|
|
||||||
const isSelected = (editItem.flags & flag) === flag;
|
|
||||||
return (
|
|
||||||
<Typography
|
|
||||||
sx={{ fontSize: TYPOGRAPHY_FONT_SIZE }}
|
|
||||||
color={isSelected ? 'primary' : 'grey'}
|
|
||||||
>
|
|
||||||
{dow[dayIndex]}
|
|
||||||
</Typography>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[editItem.flags, dow]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleClose = useCallback(
|
|
||||||
(_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => {
|
|
||||||
if (reason !== 'backdropClick') {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[onClose]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleScheduleTypeChange = useCallback(
|
|
||||||
(_event: React.SyntheticEvent<HTMLElement>, flag: ScheduleFlag | null) => {
|
|
||||||
if (flag !== null) {
|
|
||||||
setFieldErrors(undefined); // clear any validation errors
|
|
||||||
setScheduleType(flag);
|
|
||||||
// wipe the time field when changing the schedule type
|
|
||||||
// set the flags based on type
|
|
||||||
const newFlags = flag === ScheduleFlag.SCHEDULE_DAY ? 0 : flag;
|
|
||||||
setEditItem((prev) => ({ ...prev, time: '', flags: newFlags }));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleDOWChange = useCallback(
|
|
||||||
(_event: React.SyntheticEvent<HTMLElement>, flags: string[]) => {
|
|
||||||
const newFlags = getFlagDOWnumber(flags);
|
|
||||||
setEditItem((prev) => ({ ...prev, flags: newFlags }));
|
|
||||||
},
|
|
||||||
[getFlagDOWnumber]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Memoize derived values
|
|
||||||
const isDaySchedule = useMemo(
|
|
||||||
() => scheduleType === ScheduleFlag.SCHEDULE_DAY,
|
|
||||||
[scheduleType]
|
|
||||||
);
|
|
||||||
const isTimerSchedule = useMemo(
|
|
||||||
() => scheduleType === ScheduleFlag.SCHEDULE_TIMER,
|
|
||||||
[scheduleType]
|
|
||||||
);
|
|
||||||
const isImmediateSchedule = useMemo(
|
|
||||||
() => scheduleType === ScheduleFlag.SCHEDULE_IMMEDIATE,
|
|
||||||
[scheduleType]
|
|
||||||
);
|
|
||||||
const needsTimeField = useMemo(
|
|
||||||
() => isDaySchedule || isTimerSchedule,
|
|
||||||
[isDaySchedule, isTimerSchedule]
|
|
||||||
);
|
|
||||||
|
|
||||||
const dowFlags = useMemo(
|
|
||||||
() => getFlagDOWstring(editItem.flags),
|
|
||||||
[editItem.flags, getFlagDOWstring]
|
|
||||||
);
|
|
||||||
|
|
||||||
const timeFieldValue = useMemo(() => {
|
|
||||||
if (needsTimeField) {
|
|
||||||
return editItem.time === '' ? DEFAULT_TIME : editItem.time;
|
|
||||||
}
|
}
|
||||||
return editItem.time === DEFAULT_TIME ? '' : editItem.time;
|
};
|
||||||
}, [editItem.time, needsTimeField]);
|
|
||||||
|
|
||||||
const timeFieldLabel = useMemo(() => {
|
const saveandactivate = async () => {
|
||||||
if (scheduleType === ScheduleFlag.SCHEDULE_TIMER) return LL.TIMER(1);
|
editItem.active = true;
|
||||||
if (scheduleType === ScheduleFlag.SCHEDULE_CONDITION) return LL.CONDITION();
|
try {
|
||||||
if (scheduleType === ScheduleFlag.SCHEDULE_ONCHANGE) return LL.ONCHANGE();
|
setFieldErrors(undefined);
|
||||||
if (scheduleType === ScheduleFlag.SCHEDULE_IMMEDIATE) return LL.IMMEDIATE();
|
await validate(validator, editItem);
|
||||||
return LL.TIME(1);
|
onSave(editItem);
|
||||||
}, [scheduleType, LL]);
|
} catch (error) {
|
||||||
|
setFieldErrors(error as ValidateFieldsError);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = () => {
|
||||||
|
editItem.deleted = true;
|
||||||
|
onSave(editItem);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFlagDOWnumber = (newFlag: string[]) => {
|
||||||
|
let new_flag = 0;
|
||||||
|
for (const entry of newFlag) {
|
||||||
|
new_flag |= Number(entry);
|
||||||
|
}
|
||||||
|
return new_flag & 127;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFlagDOWstring = (f: number) => {
|
||||||
|
const new_flags: string[] = [];
|
||||||
|
if ((f & 129) === 1) {
|
||||||
|
new_flags.push('1');
|
||||||
|
}
|
||||||
|
if ((f & 130) === 2) {
|
||||||
|
new_flags.push('2');
|
||||||
|
}
|
||||||
|
if ((f & 4) === 4) {
|
||||||
|
new_flags.push('4');
|
||||||
|
}
|
||||||
|
if ((f & 8) === 8) {
|
||||||
|
new_flags.push('8');
|
||||||
|
}
|
||||||
|
if ((f & 16) === 16) {
|
||||||
|
new_flags.push('16');
|
||||||
|
}
|
||||||
|
if ((f & 32) === 32) {
|
||||||
|
new_flags.push('32');
|
||||||
|
}
|
||||||
|
if ((f & 64) === 64) {
|
||||||
|
new_flags.push('64');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new_flags;
|
||||||
|
};
|
||||||
|
|
||||||
|
const showDOW = (si: ScheduleItem, flag: number) => (
|
||||||
|
<Typography
|
||||||
|
sx={{ fontSize: 10 }}
|
||||||
|
color={(si.flags & flag) === flag ? 'primary' : 'grey'}
|
||||||
|
>
|
||||||
|
{dow[Math.log(flag) / Math.log(2)]}
|
||||||
|
</Typography>
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleClose = (
|
||||||
|
_event: React.SyntheticEvent,
|
||||||
|
reason: 'backdropClick' | 'escapeKeyDown'
|
||||||
|
) => {
|
||||||
|
if (reason !== 'backdropClick') {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
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>
|
||||||
@@ -247,27 +166,47 @@ const SchedulerDialog = ({
|
|||||||
value={scheduleType}
|
value={scheduleType}
|
||||||
exclusive
|
exclusive
|
||||||
disabled={!creating}
|
disabled={!creating}
|
||||||
onChange={handleScheduleTypeChange}
|
onChange={(_event, flag: ScheduleFlag) => {
|
||||||
|
if (flag !== null) {
|
||||||
|
setFieldErrors(undefined); // clear any validation errors
|
||||||
|
setScheduleType(flag);
|
||||||
|
// wipe the time field when changing the schedule type
|
||||||
|
setEditItem({ ...editItem, time: '' });
|
||||||
|
// set the flags based on type
|
||||||
|
// 0-127 is day schedule
|
||||||
|
// 128 is timer
|
||||||
|
// 129 is on change
|
||||||
|
// 130 is on condition
|
||||||
|
// 132 is immediate
|
||||||
|
setEditItem(
|
||||||
|
flag === ScheduleFlag.SCHEDULE_DAY
|
||||||
|
? { ...editItem, flags: 0 }
|
||||||
|
: { ...editItem, flags: flag }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<ToggleButton value={ScheduleFlag.SCHEDULE_DAY}>
|
<ToggleButton value={ScheduleFlag.SCHEDULE_DAY}>
|
||||||
<Typography
|
<Typography
|
||||||
sx={{ fontSize: TYPOGRAPHY_FONT_SIZE }}
|
sx={{ fontSize: 10 }}
|
||||||
color={isDaySchedule ? 'primary' : 'grey'}
|
color={scheduleType === ScheduleFlag.SCHEDULE_DAY ? '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: TYPOGRAPHY_FONT_SIZE }}
|
sx={{ fontSize: 10 }}
|
||||||
color={isTimerSchedule ? 'primary' : 'grey'}
|
color={
|
||||||
|
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: TYPOGRAPHY_FONT_SIZE }}
|
sx={{ fontSize: 10 }}
|
||||||
color={
|
color={
|
||||||
scheduleType === ScheduleFlag.SCHEDULE_ONCHANGE ? 'primary' : 'grey'
|
scheduleType === ScheduleFlag.SCHEDULE_ONCHANGE ? 'primary' : 'grey'
|
||||||
}
|
}
|
||||||
@@ -277,7 +216,7 @@ const SchedulerDialog = ({
|
|||||||
</ToggleButton>
|
</ToggleButton>
|
||||||
<ToggleButton value={ScheduleFlag.SCHEDULE_CONDITION}>
|
<ToggleButton value={ScheduleFlag.SCHEDULE_CONDITION}>
|
||||||
<Typography
|
<Typography
|
||||||
sx={{ fontSize: TYPOGRAPHY_FONT_SIZE }}
|
sx={{ fontSize: 10 }}
|
||||||
color={
|
color={
|
||||||
scheduleType === ScheduleFlag.SCHEDULE_CONDITION ? 'primary' : 'grey'
|
scheduleType === ScheduleFlag.SCHEDULE_CONDITION ? 'primary' : 'grey'
|
||||||
}
|
}
|
||||||
@@ -287,30 +226,50 @@ const SchedulerDialog = ({
|
|||||||
</ToggleButton>
|
</ToggleButton>
|
||||||
<ToggleButton value={ScheduleFlag.SCHEDULE_IMMEDIATE}>
|
<ToggleButton value={ScheduleFlag.SCHEDULE_IMMEDIATE}>
|
||||||
<Typography
|
<Typography
|
||||||
sx={{ fontSize: TYPOGRAPHY_FONT_SIZE }}
|
sx={{ fontSize: 10 }}
|
||||||
color={isImmediateSchedule ? 'primary' : 'grey'}
|
color={
|
||||||
|
scheduleType === ScheduleFlag.SCHEDULE_IMMEDIATE ? 'primary' : 'grey'
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{LL.IMMEDIATE()}
|
{LL.IMMEDIATE()}
|
||||||
</Typography>
|
</Typography>
|
||||||
</ToggleButton>
|
</ToggleButton>
|
||||||
</ToggleButtonGroup>
|
</ToggleButtonGroup>
|
||||||
|
|
||||||
{isDaySchedule && (
|
{scheduleType === ScheduleFlag.SCHEDULE_DAY && (
|
||||||
<ToggleButtonGroup
|
<ToggleButtonGroup
|
||||||
size="small"
|
size="small"
|
||||||
color="secondary"
|
color="secondary"
|
||||||
value={dowFlags}
|
value={getFlagDOWstring(editItem.flags)}
|
||||||
onChange={handleDOWChange}
|
onChange={(_event, flag: string[]) => {
|
||||||
|
setEditItem({ ...editItem, flags: getFlagDOWnumber(flag) });
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{DAY_FLAGS.map(({ value, flag }) => (
|
<ToggleButton value="2">
|
||||||
<ToggleButton key={value} value={value}>
|
{showDOW(editItem, ScheduleFlag.SCHEDULE_MON)}
|
||||||
{DayOfWeekButton(flag)}
|
</ToggleButton>
|
||||||
</ToggleButton>
|
<ToggleButton value="4">
|
||||||
))}
|
{showDOW(editItem, ScheduleFlag.SCHEDULE_TUE)}
|
||||||
|
</ToggleButton>
|
||||||
|
<ToggleButton value="8">
|
||||||
|
{showDOW(editItem, ScheduleFlag.SCHEDULE_WED)}
|
||||||
|
</ToggleButton>
|
||||||
|
<ToggleButton value="16">
|
||||||
|
{showDOW(editItem, ScheduleFlag.SCHEDULE_THU)}
|
||||||
|
</ToggleButton>
|
||||||
|
<ToggleButton value="32">
|
||||||
|
{showDOW(editItem, ScheduleFlag.SCHEDULE_FRI)}
|
||||||
|
</ToggleButton>
|
||||||
|
<ToggleButton value="64">
|
||||||
|
{showDOW(editItem, ScheduleFlag.SCHEDULE_SAT)}
|
||||||
|
</ToggleButton>
|
||||||
|
<ToggleButton value="1">
|
||||||
|
{showDOW(editItem, ScheduleFlag.SCHEDULE_SUN)}
|
||||||
|
</ToggleButton>
|
||||||
</ToggleButtonGroup>
|
</ToggleButtonGroup>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isImmediateSchedule && (
|
{scheduleType !== ScheduleFlag.SCHEDULE_IMMEDIATE && (
|
||||||
<>
|
<>
|
||||||
<Grid container>
|
<Grid container>
|
||||||
<BlockFormControlLabel
|
<BlockFormControlLabel
|
||||||
@@ -325,17 +284,22 @@ const SchedulerDialog = ({
|
|||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid container>
|
<Grid container>
|
||||||
{needsTimeField ? (
|
{scheduleType === ScheduleFlag.SCHEDULE_DAY ||
|
||||||
|
scheduleType === ScheduleFlag.SCHEDULE_TIMER ? (
|
||||||
<>
|
<>
|
||||||
<TextField
|
<TextField
|
||||||
name="time"
|
name="time"
|
||||||
type="time"
|
type="time"
|
||||||
label={timeFieldLabel}
|
label={
|
||||||
value={timeFieldValue}
|
scheduleType === ScheduleFlag.SCHEDULE_TIMER
|
||||||
|
? LL.TIMER(1)
|
||||||
|
: LL.TIME(1)
|
||||||
|
}
|
||||||
|
value={editItem.time === '' ? '00:00' : editItem.time}
|
||||||
margin="normal"
|
margin="normal"
|
||||||
onChange={updateFormValue}
|
onChange={updateFormValue}
|
||||||
/>
|
/>
|
||||||
{isTimerSchedule && (
|
{scheduleType === ScheduleFlag.SCHEDULE_TIMER && (
|
||||||
<Box color="warning.main" ml={2} mt={4}>
|
<Box color="warning.main" ml={2} mt={4}>
|
||||||
<Typography variant="body2">
|
<Typography variant="body2">
|
||||||
{LL.SCHEDULER_HELP_2()}
|
{LL.SCHEDULER_HELP_2()}
|
||||||
@@ -346,10 +310,16 @@ const SchedulerDialog = ({
|
|||||||
) : (
|
) : (
|
||||||
<TextField
|
<TextField
|
||||||
name="time"
|
name="time"
|
||||||
label={timeFieldLabel}
|
label={
|
||||||
|
scheduleType === ScheduleFlag.SCHEDULE_CONDITION
|
||||||
|
? LL.CONDITION()
|
||||||
|
: scheduleType === ScheduleFlag.SCHEDULE_ONCHANGE
|
||||||
|
? LL.ONCHANGE()
|
||||||
|
: LL.IMMEDIATE()
|
||||||
|
}
|
||||||
multiline
|
multiline
|
||||||
fullWidth
|
fullWidth
|
||||||
value={timeFieldValue}
|
value={editItem.time === '00:00' ? '' : editItem.time}
|
||||||
margin="normal"
|
margin="normal"
|
||||||
onChange={updateFormValue}
|
onChange={updateFormValue}
|
||||||
/>
|
/>
|
||||||
@@ -416,7 +386,7 @@ const SchedulerDialog = ({
|
|||||||
>
|
>
|
||||||
{creating ? LL.ADD(0) : LL.UPDATE()}
|
{creating ? LL.ADD(0) : LL.UPDATE()}
|
||||||
</Button>
|
</Button>
|
||||||
{isImmediateSchedule && editItem.cmd !== '' && (
|
{scheduleType === ScheduleFlag.SCHEDULE_IMMEDIATE && editItem.cmd !== '' && (
|
||||||
<Button
|
<Button
|
||||||
startIcon={<PlayArrowIcon />}
|
startIcon={<PlayArrowIcon />}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useContext, useMemo, useRef, useState } from 'react';
|
import { useContext, 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,74 +49,6 @@ 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);
|
||||||
@@ -127,22 +59,18 @@ 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(readSensorData, {
|
const { data: sensorData, send: fetchSensorData } = useRequest(
|
||||||
initialData: {
|
() => readSensorData(),
|
||||||
ts: [],
|
{
|
||||||
as: [],
|
initialData: {
|
||||||
analog_enabled: false,
|
ts: [],
|
||||||
available_gpios: [] as number[],
|
as: [],
|
||||||
platform: 'ESP32'
|
analog_enabled: false,
|
||||||
|
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),
|
||||||
@@ -158,18 +86,116 @@ const Sensors = () => {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const intervalCallback = useCallback(() => {
|
useInterval(() => {
|
||||||
if (!temperatureDialogOpen && !analogDialogOpen) {
|
if (!temperatureDialogOpen && !analogDialogOpen) {
|
||||||
void fetchSensorData();
|
void fetchSensorData();
|
||||||
}
|
}
|
||||||
}, [temperatureDialogOpen, analogDialogOpen, fetchSensorData]);
|
});
|
||||||
|
|
||||||
useInterval(intervalCallback);
|
const common_theme = useTheme({
|
||||||
|
BaseRow: `
|
||||||
|
font-size: 14px;
|
||||||
|
.td {
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
HeaderRow: `
|
||||||
|
text-transform: uppercase;
|
||||||
|
background-color: black;
|
||||||
|
color: #90CAF9;
|
||||||
|
.th {
|
||||||
|
border-bottom: 1px solid #565656;
|
||||||
|
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, temperature_theme_config]);
|
const temperature_theme = useTheme([
|
||||||
const analog_theme = useTheme([common_theme, analog_theme_config]);
|
common_theme,
|
||||||
|
{
|
||||||
|
Table: `
|
||||||
|
--data-table-library_grid-template-columns: minmax(0, 1fr) 35%;
|
||||||
|
`
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
const getSortIcon = useCallback((state: State, sortKey: unknown) => {
|
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) => {
|
||||||
if (state.sortKey === sortKey && state.reverse) {
|
if (state.sortKey === sortKey && state.reverse) {
|
||||||
return <KeyboardArrowDownOutlinedIcon />;
|
return <KeyboardArrowDownOutlinedIcon />;
|
||||||
}
|
}
|
||||||
@@ -177,7 +203,7 @@ const Sensors = () => {
|
|||||||
return <KeyboardArrowUpOutlinedIcon />;
|
return <KeyboardArrowUpOutlinedIcon />;
|
||||||
}
|
}
|
||||||
return <UnfoldMoreOutlinedIcon />;
|
return <UnfoldMoreOutlinedIcon />;
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const analog_sort = useSort(
|
const analog_sort = useSort(
|
||||||
{ nodes: sensorData.as },
|
{ nodes: sensorData.as },
|
||||||
@@ -190,20 +216,11 @@ const Sensors = () => {
|
|||||||
},
|
},
|
||||||
sortToggleType: SortToggleType.AlternateWithReset,
|
sortToggleType: SortToggleType.AlternateWithReset,
|
||||||
sortFns: {
|
sortFns: {
|
||||||
GPIO: (array) =>
|
GPIO: (array) => array.sort((a, b) => a.g - b.g),
|
||||||
[...array].sort(
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||||
(a, b) => ((a as AnalogSensor)?.g ?? 0) - ((b as AnalogSensor)?.g ?? 0)
|
NAME: (array) => array.sort((a, b) => a.n.localeCompare(b.n)),
|
||||||
),
|
TYPE: (array) => array.sort((a, b) => a.t - b.t),
|
||||||
NAME: (array) =>
|
VALUE: (array) => array.sort((a, b) => a.v - b.v)
|
||||||
[...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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -219,349 +236,227 @@ const Sensors = () => {
|
|||||||
},
|
},
|
||||||
sortToggleType: SortToggleType.AlternateWithReset,
|
sortToggleType: SortToggleType.AlternateWithReset,
|
||||||
sortFns: {
|
sortFns: {
|
||||||
NAME: (array) =>
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||||
[...array].sort((a, b) =>
|
NAME: (array) => array.sort((a, b) => a.n.localeCompare(b.n)),
|
||||||
(a as TemperatureSensor).n.localeCompare((b as TemperatureSensor).n)
|
VALUE: (array) => array.sort((a, b) => a.t - b.t)
|
||||||
),
|
|
||||||
VALUE: (array) =>
|
|
||||||
[...array].sort(
|
|
||||||
(a, b) =>
|
|
||||||
((a as TemperatureSensor).t ?? 0) - ((b as TemperatureSensor).t ?? 0)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
useLayoutTitle(LL.SENSORS());
|
useLayoutTitle(LL.SENSORS());
|
||||||
|
|
||||||
const formatDurationMin = useCallback(
|
const formatDurationMin = (duration_min: number) => {
|
||||||
(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;
|
|
||||||
|
|
||||||
const parts: string[] = [];
|
let formatted = '';
|
||||||
if (days > 0) {
|
if (days) {
|
||||||
parts.push(LL.NUM_DAYS({ num: days }));
|
formatted += LL.NUM_DAYS({ num: days }) + ' ';
|
||||||
}
|
}
|
||||||
if (hours > 0) {
|
if (hours) {
|
||||||
parts.push(LL.NUM_HOURS({ num: hours }));
|
formatted += LL.NUM_HOURS({ num: hours }) + ' ';
|
||||||
}
|
}
|
||||||
if (minutes > 0) {
|
if (minutes) {
|
||||||
parts.push(LL.NUM_MINUTES({ num: minutes }));
|
formatted += LL.NUM_MINUTES({ num: minutes });
|
||||||
}
|
}
|
||||||
return parts.join(' ');
|
return formatted;
|
||||||
},
|
};
|
||||||
[LL]
|
|
||||||
);
|
|
||||||
|
|
||||||
const formatValue = useCallback(
|
function formatValue(value: unknown, uom: DeviceValueUOM) {
|
||||||
(value: unknown, uom: DeviceValueUOM) => {
|
if (value === undefined) {
|
||||||
if (value === undefined) {
|
return '';
|
||||||
return '';
|
}
|
||||||
}
|
if (typeof value !== 'number') {
|
||||||
if (typeof value !== 'number') {
|
return value as string;
|
||||||
return value as string;
|
}
|
||||||
}
|
switch (uom) {
|
||||||
switch (uom) {
|
case DeviceValueUOM.HOURS:
|
||||||
case DeviceValueUOM.HOURS:
|
return value ? formatDurationMin(value * 60) : LL.NUM_HOURS({ num: 0 });
|
||||||
return value ? formatDurationMin(value * 60) : LL.NUM_HOURS({ num: 0 });
|
case DeviceValueUOM.MINUTES:
|
||||||
case DeviceValueUOM.MINUTES:
|
return value ? formatDurationMin(value) : LL.NUM_MINUTES({ num: 0 });
|
||||||
return value ? formatDurationMin(value) : LL.NUM_MINUTES({ num: 0 });
|
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 new Intl.NumberFormat().format(value);
|
case DeviceValueUOM.DEGREES:
|
||||||
case DeviceValueUOM.DEGREES:
|
case DeviceValueUOM.DEGREES_R:
|
||||||
case DeviceValueUOM.DEGREES_R:
|
case DeviceValueUOM.FAHRENHEIT:
|
||||||
case DeviceValueUOM.FAHRENHEIT:
|
return (
|
||||||
return (
|
new Intl.NumberFormat(undefined, {
|
||||||
new Intl.NumberFormat(undefined, {
|
minimumFractionDigits: 1
|
||||||
minimumFractionDigits: 1
|
}).format(value) +
|
||||||
}).format(value) +
|
' ' +
|
||||||
' ' +
|
DeviceValueUOM_s[uom]
|
||||||
DeviceValueUOM_s[uom]
|
);
|
||||||
);
|
default:
|
||||||
default:
|
return new Intl.NumberFormat().format(value) + ' ' + DeviceValueUOM_s[uom];
|
||||||
return new Intl.NumberFormat().format(value) + ' ' + DeviceValueUOM_s[uom];
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
[formatDurationMin, LL]
|
|
||||||
);
|
|
||||||
|
|
||||||
const updateTemperatureSensor = useCallback(
|
const updateTemperatureSensor = (ts: TemperatureSensor) => {
|
||||||
(ts: TemperatureSensor) => {
|
if (me.admin) {
|
||||||
if (me.admin) {
|
ts.o_n = ts.n;
|
||||||
ts.o_n = ts.n;
|
setSelectedTemperatureSensor(ts);
|
||||||
setSelectedTemperatureSensor(ts);
|
setTemperatureDialogOpen(true);
|
||||||
setTemperatureDialogOpen(true);
|
}
|
||||||
}
|
};
|
||||||
},
|
|
||||||
[me.admin]
|
|
||||||
);
|
|
||||||
|
|
||||||
const onTemperatureDialogClose = useCallback(() => {
|
const onTemperatureDialogClose = () => {
|
||||||
setTemperatureDialogOpen(false);
|
setTemperatureDialogOpen(false);
|
||||||
void fetchSensorData();
|
void fetchSensorData();
|
||||||
}, [fetchSensorData]);
|
};
|
||||||
|
|
||||||
const onTemperatureDialogSave = useCallback(
|
const onTemperatureDialogSave = async (ts: TemperatureSensor) => {
|
||||||
async (ts: TemperatureSensor) => {
|
await sendTemperatureSensor({ id: ts.id, name: ts.n, offset: ts.o })
|
||||||
await sendTemperatureSensor({
|
.then(() => {
|
||||||
id: ts.id,
|
toast.success(LL.UPDATED_OF(LL.SENSOR(1)));
|
||||||
name: ts.n,
|
|
||||||
offset: ts.o,
|
|
||||||
is_system: ts.s
|
|
||||||
})
|
})
|
||||||
.then(() => {
|
.catch(() => {
|
||||||
toast.success(LL.UPDATED_OF(LL.SENSOR(1)));
|
toast.error(LL.UPDATE_OF(LL.SENSOR(2)) + ' ' + LL.FAILED(1));
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.finally(() => {
|
||||||
toast.error(LL.UPDATE_OF(LL.SENSOR(2)) + ' ' + LL.FAILED(1));
|
setTemperatureDialogOpen(false);
|
||||||
})
|
setSelectedTemperatureSensor(undefined);
|
||||||
.finally(() => {
|
void fetchSensorData();
|
||||||
setTemperatureDialogOpen(false);
|
});
|
||||||
setSelectedTemperatureSensor(undefined);
|
};
|
||||||
void fetchSensorData();
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[sendTemperatureSensor, LL, fetchSensorData]
|
|
||||||
);
|
|
||||||
|
|
||||||
const updateAnalogSensor = useCallback(
|
const updateAnalogSensor = (as: AnalogSensor) => {
|
||||||
(as: AnalogSensor) => {
|
if (me.admin) {
|
||||||
if (me.admin) {
|
setCreating(false);
|
||||||
setCreating(false);
|
as.o_n = as.n;
|
||||||
as.o_n = as.n;
|
setSelectedAnalogSensor(as);
|
||||||
setSelectedAnalogSensor(as);
|
setAnalogDialogOpen(true);
|
||||||
setAnalogDialogOpen(true);
|
}
|
||||||
}
|
};
|
||||||
},
|
|
||||||
[me.admin]
|
|
||||||
);
|
|
||||||
|
|
||||||
const onAnalogDialogClose = useCallback(() => {
|
const onAnalogDialogClose = () => {
|
||||||
setAnalogDialogOpen(false);
|
setAnalogDialogOpen(false);
|
||||||
void fetchSensorData();
|
void fetchSensorData();
|
||||||
}, [fetchSensorData]);
|
};
|
||||||
|
|
||||||
const addAnalogSensor = useCallback(() => {
|
const addAnalogSensor = () => {
|
||||||
if (firstAvailableGPIO.current === undefined) {
|
|
||||||
toast.error('No available GPIO found');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setCreating(true);
|
setCreating(true);
|
||||||
setSelectedAnalogSensor({
|
setSelectedAnalogSensor({
|
||||||
id: Math.floor(Math.random() * (MAX_TEMP_ID - MIN_TEMP_ID) + MIN_TEMP_ID),
|
id: Math.floor(Math.random() * (Math.floor(200) - 100) + 100),
|
||||||
n: '',
|
n: '',
|
||||||
g: firstAvailableGPIO.current,
|
g: 21, // default GPIO 21 which is safe for all platforms
|
||||||
u: DeviceValueUOM.NONE,
|
u: 0,
|
||||||
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);
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const onAnalogDialogSave = useCallback(
|
const onAnalogDialogSave = async (as: AnalogSensor) => {
|
||||||
async (as: AnalogSensor) => {
|
await sendAnalogSensor({
|
||||||
await sendAnalogSensor({
|
id: as.id,
|
||||||
id: as.id,
|
gpio: as.g,
|
||||||
gpio: as.g,
|
name: as.n,
|
||||||
name: as.n,
|
offset: as.o,
|
||||||
offset: as.o,
|
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(() => {
|
||||||
|
toast.success(LL.UPDATED_OF(LL.ANALOG_SENSOR(2)));
|
||||||
})
|
})
|
||||||
.then(() => {
|
.catch(() => {
|
||||||
toast.success(LL.UPDATED_OF(LL.ANALOG_SENSOR(2)));
|
toast.error(LL.UPDATE_OF(LL.ANALOG_SENSOR(5)) + ' ' + LL.FAILED(1));
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.finally(() => {
|
||||||
toast.error(LL.UPDATE_OF(LL.ANALOG_SENSOR(5)) + ' ' + LL.FAILED(1));
|
setAnalogDialogOpen(false);
|
||||||
})
|
setSelectedAnalogSensor(undefined);
|
||||||
.finally(() => {
|
void fetchSensorData();
|
||||||
setAnalogDialogOpen(false);
|
});
|
||||||
setSelectedAnalogSensor(undefined);
|
};
|
||||||
void fetchSensorData();
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[sendAnalogSensor, LL, fetchSensorData]
|
|
||||||
);
|
|
||||||
|
|
||||||
const RenderAnalogSensors = useMemo(
|
const RenderAnalogSensors = () => (
|
||||||
() => (
|
<Table
|
||||||
<Table
|
data={{ nodes: sensorData.as }}
|
||||||
data={{ nodes: sensorData.as }}
|
theme={analog_theme}
|
||||||
theme={analog_theme}
|
sort={analog_sort}
|
||||||
sort={analog_sort}
|
layout={{ custom: true }}
|
||||||
layout={{ custom: true }}
|
>
|
||||||
>
|
{(tableList: AnalogSensor[]) => (
|
||||||
{(tableList: AnalogSensor[]) => (
|
<>
|
||||||
<>
|
<Header>
|
||||||
<Header>
|
<HeaderRow>
|
||||||
<HeaderRow>
|
<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' })}
|
|
||||||
>
|
|
||||||
GPIO
|
|
||||||
</Button>
|
|
||||||
</HeaderCell>
|
|
||||||
<HeaderCell resize>
|
|
||||||
<Button
|
|
||||||
fullWidth
|
|
||||||
style={HEADER_BUTTON_STYLE}
|
|
||||||
endIcon={getSortIcon(analog_sort.state, 'NAME')}
|
|
||||||
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'NAME' })}
|
|
||||||
>
|
|
||||||
{LL.NAME(0)}
|
|
||||||
</Button>
|
|
||||||
</HeaderCell>
|
|
||||||
<HeaderCell stiff>
|
|
||||||
<Button
|
|
||||||
fullWidth
|
|
||||||
style={HEADER_BUTTON_STYLE}
|
|
||||||
endIcon={getSortIcon(analog_sort.state, 'TYPE')}
|
|
||||||
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'TYPE' })}
|
|
||||||
>
|
|
||||||
{LL.TYPE(0)}
|
|
||||||
</Button>
|
|
||||||
</HeaderCell>
|
|
||||||
<HeaderCell stiff>
|
|
||||||
<Button
|
|
||||||
fullWidth
|
|
||||||
style={HEADER_BUTTON_STYLE_END}
|
|
||||||
endIcon={getSortIcon(analog_sort.state, 'VALUE')}
|
|
||||||
onClick={() =>
|
|
||||||
analog_sort.fns.onToggleSort({ sortKey: 'VALUE' })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{LL.VALUE(0)}
|
|
||||||
</Button>
|
|
||||||
</HeaderCell>
|
|
||||||
</HeaderRow>
|
|
||||||
</Header>
|
|
||||||
<Body>
|
|
||||||
{tableList.map((as: AnalogSensor) => (
|
|
||||||
<Row
|
|
||||||
style={{ color: as.s ? 'grey' : 'inherit' }}
|
|
||||||
key={as.id}
|
|
||||||
item={as}
|
|
||||||
onClick={() => updateAnalogSensor(as)}
|
|
||||||
>
|
>
|
||||||
<Cell stiff>{as.g}</Cell>
|
GPIO
|
||||||
<Cell>{as.n}</Cell>
|
</Button>
|
||||||
<Cell stiff>{AnalogTypeNames[as.t - 1]} </Cell>
|
</HeaderCell>
|
||||||
{(as.t === AnalogType.DIGITAL_OUT &&
|
<HeaderCell resize>
|
||||||
as.g !== GPIO_25 &&
|
<Button
|
||||||
as.g !== GPIO_26) ||
|
fullWidth
|
||||||
as.t === AnalogType.DIGITAL_IN ||
|
style={{ fontSize: '14px', justifyContent: 'flex-start' }}
|
||||||
as.t === AnalogType.PULSE ? (
|
endIcon={getSortIcon(analog_sort.state, 'NAME')}
|
||||||
<Cell stiff>{as.v ? LL.ON() : LL.OFF()}</Cell>
|
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'NAME' })}
|
||||||
) : (
|
|
||||||
<Cell stiff>{formatValue(as.v, as.u)}</Cell>
|
|
||||||
)}
|
|
||||||
</Row>
|
|
||||||
))}
|
|
||||||
</Body>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Table>
|
|
||||||
),
|
|
||||||
[
|
|
||||||
analog_sort,
|
|
||||||
analog_theme,
|
|
||||||
getSortIcon,
|
|
||||||
sensorData.as,
|
|
||||||
LL,
|
|
||||||
updateAnalogSensor,
|
|
||||||
formatValue
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
const RenderTemperatureSensors = useMemo(
|
|
||||||
() => (
|
|
||||||
<Table
|
|
||||||
data={{ nodes: sensorData.ts }}
|
|
||||||
theme={temperature_theme}
|
|
||||||
sort={temperature_sort}
|
|
||||||
layout={{ custom: true }}
|
|
||||||
>
|
|
||||||
{(tableList: TemperatureSensor[]) => (
|
|
||||||
<>
|
|
||||||
<Header>
|
|
||||||
<HeaderRow>
|
|
||||||
<HeaderCell resize>
|
|
||||||
<Button
|
|
||||||
fullWidth
|
|
||||||
style={HEADER_BUTTON_STYLE}
|
|
||||||
endIcon={getSortIcon(temperature_sort.state, 'NAME')}
|
|
||||||
onClick={() =>
|
|
||||||
temperature_sort.fns.onToggleSort({ sortKey: 'NAME' })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{LL.NAME(0)}
|
|
||||||
</Button>
|
|
||||||
</HeaderCell>
|
|
||||||
<HeaderCell stiff>
|
|
||||||
<Button
|
|
||||||
fullWidth
|
|
||||||
style={HEADER_BUTTON_STYLE_END}
|
|
||||||
endIcon={getSortIcon(temperature_sort.state, 'VALUE')}
|
|
||||||
onClick={() =>
|
|
||||||
temperature_sort.fns.onToggleSort({ sortKey: 'VALUE' })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{LL.VALUE(0)}
|
|
||||||
</Button>
|
|
||||||
</HeaderCell>
|
|
||||||
</HeaderRow>
|
|
||||||
</Header>
|
|
||||||
<Body>
|
|
||||||
{tableList.map((ts: TemperatureSensor) => (
|
|
||||||
<Row
|
|
||||||
style={{ color: ts.s ? 'grey' : 'inherit' }}
|
|
||||||
key={ts.id}
|
|
||||||
item={ts}
|
|
||||||
onClick={() => updateTemperatureSensor(ts)}
|
|
||||||
>
|
>
|
||||||
<Cell>{ts.n}</Cell>
|
{LL.NAME(0)}
|
||||||
<Cell>{formatValue(ts.t, ts.u)}</Cell>
|
</Button>
|
||||||
</Row>
|
</HeaderCell>
|
||||||
))}
|
<HeaderCell stiff>
|
||||||
</Body>
|
<Button
|
||||||
</>
|
fullWidth
|
||||||
)}
|
style={{ fontSize: '14px', justifyContent: 'flex-start' }}
|
||||||
</Table>
|
endIcon={getSortIcon(analog_sort.state, 'TYPE')}
|
||||||
),
|
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'TYPE' })}
|
||||||
[
|
>
|
||||||
temperature_sort,
|
{LL.TYPE(0)}
|
||||||
temperature_theme,
|
</Button>
|
||||||
getSortIcon,
|
</HeaderCell>
|
||||||
sensorData.ts,
|
<HeaderCell stiff>
|
||||||
LL,
|
<Button
|
||||||
updateTemperatureSensor,
|
fullWidth
|
||||||
formatValue
|
style={{ fontSize: '14px', justifyContent: 'flex-end' }}
|
||||||
]
|
endIcon={getSortIcon(analog_sort.state, 'VALUE')}
|
||||||
|
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'VALUE' })}
|
||||||
|
>
|
||||||
|
{LL.VALUE(0)}
|
||||||
|
</Button>
|
||||||
|
</HeaderCell>
|
||||||
|
</HeaderRow>
|
||||||
|
</Header>
|
||||||
|
<Body>
|
||||||
|
{tableList.map((a: AnalogSensor) => (
|
||||||
|
<Row key={a.id} item={a} onClick={() => updateAnalogSensor(a)}>
|
||||||
|
<Cell stiff>{a.g}</Cell>
|
||||||
|
<Cell>{a.n}</Cell>
|
||||||
|
<Cell stiff>{AnalogTypeNames[a.t]} </Cell>
|
||||||
|
{(a.t === AnalogType.DIGITAL_OUT && a.g !== 25 && a.g !== 26) ||
|
||||||
|
a.t === AnalogType.DIGITAL_IN ||
|
||||||
|
a.t === AnalogType.PULSE ? (
|
||||||
|
<Cell stiff>{a.v ? LL.ON() : LL.OFF()}</Cell>
|
||||||
|
) : (
|
||||||
|
<Cell stiff>{a.t ? formatValue(a.v, a.u) : ''}</Cell>
|
||||||
|
)}
|
||||||
|
</Row>
|
||||||
|
))}
|
||||||
|
</Body>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Table>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionContent>
|
<SectionContent>
|
||||||
<Typography sx={{ pb: 1 }} variant="h6" color="primary">
|
<Typography sx={{ pb: 1 }} variant="h6" color="secondary">
|
||||||
{LL.TEMP_SENSORS()}
|
{LL.TEMP_SENSORS()}
|
||||||
</Typography>
|
</Typography>
|
||||||
{RenderTemperatureSensors}
|
<RenderTemperatureSensors />
|
||||||
{selectedTemperatureSensor && (
|
{selectedTemperatureSensor && (
|
||||||
<DashboardSensorsTemperatureDialog
|
<DashboardSensorsTemperatureDialog
|
||||||
open={temperatureDialogOpen}
|
open={temperatureDialogOpen}
|
||||||
@@ -574,10 +469,10 @@ const Sensors = () => {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Typography sx={{ pt: 4, pb: 1 }} variant="h6" color="primary">
|
<Typography sx={{ pt: 4, pb: 1 }} variant="h6" color="secondary">
|
||||||
{LL.ANALOG_SENSORS()}
|
{LL.ANALOG_SENSORS()}
|
||||||
</Typography>
|
</Typography>
|
||||||
{RenderAnalogSensors}
|
<RenderAnalogSensors />
|
||||||
{selectedAnalogSensor && (
|
{selectedAnalogSensor && (
|
||||||
<DashboardSensorsAnalogDialog
|
<DashboardSensorsAnalogDialog
|
||||||
open={analogDialogOpen}
|
open={analogDialogOpen}
|
||||||
@@ -585,9 +480,12 @@ const Sensors = () => {
|
|||||||
onSave={onAnalogDialogSave}
|
onSave={onAnalogDialogSave}
|
||||||
creating={creating}
|
creating={creating}
|
||||||
selectedItem={selectedAnalogSensor}
|
selectedItem={selectedAnalogSensor}
|
||||||
analogGPIOList={sensorData.available_gpios}
|
validator={analogSensorItemValidation(
|
||||||
disabledTypeList={sensorData.exclude_types}
|
sensorData.as,
|
||||||
validator={analogSensorItemValidation(sensorData.as, selectedAnalogSensor)}
|
selectedAnalogSensor,
|
||||||
|
creating,
|
||||||
|
sensorData.platform
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{sensorData?.analog_enabled === true && me.admin && (
|
{sensorData?.analog_enabled === true && me.admin && (
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, 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 RemoveIcon from '@mui/icons-material/RemoveCircleOutline';
|
import RemoveIcon from '@mui/icons-material/RemoveCircleOutline';
|
||||||
import WarningIcon from '@mui/icons-material/Warning';
|
import WarningIcon from '@mui/icons-material/Warning';
|
||||||
import {
|
import {
|
||||||
@@ -14,6 +13,7 @@ import {
|
|||||||
Grid,
|
Grid,
|
||||||
InputAdornment,
|
InputAdornment,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
|
TextField,
|
||||||
Typography
|
Typography
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
|
|
||||||
@@ -34,8 +34,6 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,111 +43,13 @@ 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 = useMemo(
|
|
||||||
() =>
|
|
||||||
updateValue((updater) =>
|
|
||||||
setEditItem(
|
|
||||||
(prev) =>
|
|
||||||
updater(
|
|
||||||
prev as unknown as Record<string, unknown>
|
|
||||||
) as unknown as AnalogSensor
|
|
||||||
)
|
|
||||||
),
|
|
||||||
[setEditItem]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Memoize helper functions to check sensor type conditions
|
|
||||||
const isCounterOrRate = useMemo(
|
|
||||||
() =>
|
|
||||||
editItem.t === AnalogType.COUNTER ||
|
|
||||||
editItem.t === AnalogType.RATE ||
|
|
||||||
(editItem.t >= AnalogType.CNT_0 && editItem.t <= AnalogType.CNT_2),
|
|
||||||
[editItem.t]
|
|
||||||
);
|
|
||||||
const isCounter = useMemo(
|
|
||||||
() =>
|
|
||||||
editItem.t === AnalogType.COUNTER ||
|
|
||||||
(editItem.t >= AnalogType.CNT_0 && editItem.t <= AnalogType.CNT_2),
|
|
||||||
[editItem.t]
|
|
||||||
);
|
|
||||||
const isFreqType = useMemo(
|
|
||||||
() => editItem.t >= AnalogType.FREQ_0 && editItem.t <= AnalogType.FREQ_2,
|
|
||||||
[editItem.t]
|
|
||||||
);
|
|
||||||
const isPWM = useMemo(
|
|
||||||
() =>
|
|
||||||
editItem.t === AnalogType.PWM_0 ||
|
|
||||||
editItem.t === AnalogType.PWM_1 ||
|
|
||||||
editItem.t === AnalogType.PWM_2,
|
|
||||||
[editItem.t]
|
|
||||||
);
|
|
||||||
const isDACOutGPIO = useMemo(
|
|
||||||
() =>
|
|
||||||
editItem.t === AnalogType.DIGITAL_OUT &&
|
|
||||||
(editItem.g === 25 || editItem.g === 26),
|
|
||||||
[editItem.t, editItem.g]
|
|
||||||
);
|
|
||||||
const isDigitalOutGPIO = useMemo(
|
|
||||||
() =>
|
|
||||||
editItem.t === AnalogType.DIGITAL_OUT &&
|
|
||||||
editItem.g !== 25 &&
|
|
||||||
editItem.g !== 26,
|
|
||||||
[editItem.t, editItem.g]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Memoize menu items to avoid recreation on each render
|
|
||||||
const analogTypeMenuItems = useMemo(
|
|
||||||
() =>
|
|
||||||
AnalogTypeNames.map((val, i) => ({ name: val, value: i + 1 }))
|
|
||||||
.sort((a, b) => a.name.localeCompare(b.name))
|
|
||||||
.map(({ name, value }) => (
|
|
||||||
<MenuItem
|
|
||||||
key={name}
|
|
||||||
value={value}
|
|
||||||
disabled={disabledTypeList?.includes(value)}
|
|
||||||
>
|
|
||||||
{name}
|
|
||||||
</MenuItem>
|
|
||||||
)),
|
|
||||||
[disabledTypeList]
|
|
||||||
);
|
|
||||||
|
|
||||||
const uomMenuItems = useMemo(
|
|
||||||
() =>
|
|
||||||
DeviceValueUOM_s.map((val, i) => (
|
|
||||||
<MenuItem key={val} value={i}>
|
|
||||||
{val}
|
|
||||||
</MenuItem>
|
|
||||||
)),
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const analogGPIOMenuItems = () =>
|
|
||||||
// add selectedItem.g to the list
|
|
||||||
[
|
|
||||||
...(analogGPIOList?.includes(selectedItem.g) || selectedItem.g === undefined
|
|
||||||
? analogGPIOList
|
|
||||||
: [selectedItem.g, ...analogGPIOList])
|
|
||||||
]
|
|
||||||
.filter((gpio, idx, arr) => arr.indexOf(gpio) === idx)
|
|
||||||
.sort((a, b) => a - b)
|
|
||||||
.map((gpio: number) => {
|
|
||||||
return (
|
|
||||||
<MenuItem key={gpio} value={gpio}>
|
|
||||||
{gpio}
|
|
||||||
</MenuItem>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Reset form when dialog opens or selectedItem changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
setFieldErrors(undefined);
|
setFieldErrors(undefined);
|
||||||
@@ -157,16 +57,16 @@ const SensorsAnalogDialog = ({
|
|||||||
}
|
}
|
||||||
}, [open, selectedItem]);
|
}, [open, selectedItem]);
|
||||||
|
|
||||||
const handleClose = useCallback(
|
const handleClose = (
|
||||||
(_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => {
|
_event: React.SyntheticEvent,
|
||||||
if (reason !== 'backdropClick') {
|
reason: 'backdropClick' | 'escapeKeyDown'
|
||||||
onClose();
|
) => {
|
||||||
}
|
if (reason !== 'backdropClick') {
|
||||||
},
|
onClose();
|
||||||
[onClose]
|
}
|
||||||
);
|
};
|
||||||
|
|
||||||
const save = useCallback(async () => {
|
const save = async () => {
|
||||||
try {
|
try {
|
||||||
setFieldErrors(undefined);
|
setFieldErrors(undefined);
|
||||||
await validate(validator, editItem);
|
await validate(validator, editItem);
|
||||||
@@ -174,84 +74,97 @@ const SensorsAnalogDialog = ({
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
setFieldErrors(error as ValidateFieldsError);
|
setFieldErrors(error as ValidateFieldsError);
|
||||||
}
|
}
|
||||||
}, [validator, editItem, onSave]);
|
};
|
||||||
|
|
||||||
const remove = useCallback(() => {
|
const remove = () => {
|
||||||
onSave({ ...editItem, d: true });
|
editItem.d = true;
|
||||||
}, [editItem, onSave]);
|
onSave(editItem);
|
||||||
|
};
|
||||||
const dialogTitle = useMemo(
|
|
||||||
() =>
|
|
||||||
`${creating ? LL.ADD(1) + ' ' + LL.NEW(0) : LL.EDIT()} ${LL.ANALOG_SENSOR(0)}`,
|
|
||||||
[creating, LL]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
|
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
|
||||||
<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>
|
||||||
<ValidatedTextField
|
<TextField
|
||||||
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}
|
|
||||||
>
|
>
|
||||||
{analogTypeMenuItems}
|
{AnalogTypeNames.map((val, i) => (
|
||||||
</ValidatedTextField>
|
<MenuItem key={val} value={i}>
|
||||||
|
{val}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
</Grid>
|
</Grid>
|
||||||
{(isCounterOrRate ||
|
{((editItem.t >= AnalogType.COUNTER && editItem.t <= AnalogType.RATE) ||
|
||||||
isFreqType ||
|
(editItem.t >= AnalogType.FREQ_0 &&
|
||||||
editItem.t === AnalogType.ADC ||
|
editItem.t <= AnalogType.FREQ_2)) && (
|
||||||
editItem.t === AnalogType.TIMER) && (
|
|
||||||
<Grid>
|
<Grid>
|
||||||
<ValidatedTextField
|
<TextField
|
||||||
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}
|
|
||||||
>
|
>
|
||||||
{uomMenuItems}
|
{DeviceValueUOM_s.map((val, i) => (
|
||||||
</ValidatedTextField>
|
<MenuItem key={val} value={i}>
|
||||||
|
{val}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
</Grid>
|
</Grid>
|
||||||
)}
|
)}
|
||||||
{editItem.t === AnalogType.ADC && (
|
{editItem.t === AnalogType.ADC && (
|
||||||
<Grid>
|
<Grid>
|
||||||
<ValidatedTextField
|
<TextField
|
||||||
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: (
|
||||||
@@ -265,14 +178,14 @@ const SensorsAnalogDialog = ({
|
|||||||
)}
|
)}
|
||||||
{editItem.t === AnalogType.NTC && (
|
{editItem.t === AnalogType.NTC && (
|
||||||
<Grid>
|
<Grid>
|
||||||
<ValidatedTextField
|
<TextField
|
||||||
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: (
|
||||||
@@ -284,16 +197,16 @@ const SensorsAnalogDialog = ({
|
|||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
)}
|
)}
|
||||||
{isCounter && (
|
{editItem.t === AnalogType.COUNTER && (
|
||||||
<Grid>
|
<Grid>
|
||||||
<ValidatedTextField
|
<TextField
|
||||||
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' }
|
||||||
}}
|
}}
|
||||||
@@ -302,113 +215,113 @@ const SensorsAnalogDialog = ({
|
|||||||
)}
|
)}
|
||||||
{editItem.t === AnalogType.RGB && (
|
{editItem.t === AnalogType.RGB && (
|
||||||
<Grid>
|
<Grid>
|
||||||
<ValidatedTextField
|
<TextField
|
||||||
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>
|
||||||
)}
|
)}
|
||||||
{(isCounterOrRate ||
|
{editItem.t >= AnalogType.COUNTER && editItem.t <= AnalogType.RATE && (
|
||||||
isFreqType ||
|
|
||||||
editItem.t === AnalogType.ADC ||
|
|
||||||
editItem.t === AnalogType.TIMER) && (
|
|
||||||
<Grid>
|
<Grid>
|
||||||
<ValidatedTextField
|
<TextField
|
||||||
name="f"
|
name="f"
|
||||||
label={LL.FACTOR()}
|
label={LL.FACTOR()}
|
||||||
value={numberValue(editItem.f)}
|
value={numberValue(editItem.f)}
|
||||||
sx={{ width: '14ch' }}
|
sx={{ width: '11ch' }}
|
||||||
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>
|
||||||
)}
|
)}
|
||||||
{isDACOutGPIO && (
|
{editItem.t === AnalogType.DIGITAL_OUT &&
|
||||||
<Grid>
|
(editItem.g === 25 || editItem.g === 26) && (
|
||||||
<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>
|
||||||
<ValidatedTextField
|
<TextField
|
||||||
name="o"
|
name="o"
|
||||||
label={LL.VALUE(0)}
|
label={LL.VALUE(0)}
|
||||||
value={numberValue(editItem.o)}
|
value={numberValue(editItem.o)}
|
||||||
select
|
sx={{ width: '11ch' }}
|
||||||
|
type="number"
|
||||||
|
variant="outlined"
|
||||||
onChange={updateFormValue}
|
onChange={updateFormValue}
|
||||||
disabled={editItem.s}
|
slotProps={{
|
||||||
>
|
htmlInput: { min: '0', max: '255', step: '1' }
|
||||||
<MenuItem value={0}>{LL.OFF()}</MenuItem>
|
}}
|
||||||
<MenuItem value={1}>{LL.ON()}</MenuItem>
|
/>
|
||||||
</ValidatedTextField>
|
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid>
|
)}
|
||||||
<ValidatedTextField
|
{editItem.t === AnalogType.DIGITAL_OUT &&
|
||||||
name="f"
|
editItem.g !== 25 &&
|
||||||
label={LL.POLARITY()}
|
editItem.g !== 26 && (
|
||||||
value={editItem.f}
|
<>
|
||||||
sx={{ width: '15ch' }}
|
<Grid>
|
||||||
select
|
<TextField
|
||||||
onChange={updateFormValue}
|
name="o"
|
||||||
disabled={editItem.s}
|
label={LL.VALUE(0)}
|
||||||
>
|
value={numberValue(editItem.o)}
|
||||||
<MenuItem value={1}>{LL.ACTIVEHIGH()}</MenuItem>
|
select
|
||||||
<MenuItem value={-1}>{LL.ACTIVELOW()}</MenuItem>
|
variant="outlined"
|
||||||
</ValidatedTextField>
|
onChange={updateFormValue}
|
||||||
</Grid>
|
>
|
||||||
<Grid>
|
<MenuItem value={0}>{LL.OFF()}</MenuItem>
|
||||||
<ValidatedTextField
|
<MenuItem value={1}>{LL.ON()}</MenuItem>
|
||||||
name="u"
|
</TextField>
|
||||||
label={LL.STARTVALUE()}
|
</Grid>
|
||||||
sx={{ width: '15ch' }}
|
<Grid>
|
||||||
value={editItem.u}
|
<TextField
|
||||||
select
|
name="f"
|
||||||
onChange={updateFormValue}
|
label={LL.POLARITY()}
|
||||||
disabled={editItem.s}
|
value={editItem.f}
|
||||||
>
|
sx={{ width: '15ch' }}
|
||||||
<MenuItem value={0}>{LL.UNCHANGED()}</MenuItem>
|
select
|
||||||
<MenuItem value={1}>
|
onChange={updateFormValue}
|
||||||
{LL.ALWAYS()} {LL.OFF()}
|
>
|
||||||
</MenuItem>
|
<MenuItem value={1}>{LL.ACTIVEHIGH()}</MenuItem>
|
||||||
<MenuItem value={2}>
|
<MenuItem value={-1}>{LL.ACTIVELOW()}</MenuItem>
|
||||||
{LL.ALWAYS()} {LL.ON()}
|
</TextField>
|
||||||
</MenuItem>
|
</Grid>
|
||||||
</ValidatedTextField>
|
<Grid>
|
||||||
</Grid>
|
<TextField
|
||||||
</>
|
name="u"
|
||||||
)}
|
label={LL.STARTVALUE()}
|
||||||
{isPWM && (
|
sx={{ width: '15ch' }}
|
||||||
|
value={editItem.u}
|
||||||
|
select
|
||||||
|
onChange={updateFormValue}
|
||||||
|
>
|
||||||
|
<MenuItem value={0}>{LL.UNCHANGED()}</MenuItem>
|
||||||
|
<MenuItem value={1}>
|
||||||
|
{LL.ALWAYS()} {LL.OFF()}
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem value={2}>
|
||||||
|
{LL.ALWAYS()} {LL.ON()}
|
||||||
|
</MenuItem>
|
||||||
|
</TextField>
|
||||||
|
</Grid>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{(editItem.t === AnalogType.PWM_0 ||
|
||||||
|
editItem.t === AnalogType.PWM_1 ||
|
||||||
|
editItem.t === AnalogType.PWM_2) && (
|
||||||
<>
|
<>
|
||||||
<Grid>
|
<Grid>
|
||||||
<ValidatedTextField
|
<TextField
|
||||||
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: (
|
||||||
@@ -420,14 +333,14 @@ const SensorsAnalogDialog = ({
|
|||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid>
|
<Grid>
|
||||||
<ValidatedTextField
|
<TextField
|
||||||
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: (
|
||||||
@@ -443,28 +356,27 @@ const SensorsAnalogDialog = ({
|
|||||||
{editItem.t === AnalogType.PULSE && (
|
{editItem.t === AnalogType.PULSE && (
|
||||||
<>
|
<>
|
||||||
<Grid>
|
<Grid>
|
||||||
<ValidatedTextField
|
<TextField
|
||||||
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>
|
||||||
</ValidatedTextField>
|
</TextField>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid>
|
<Grid>
|
||||||
<ValidatedTextField
|
<TextField
|
||||||
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: (
|
||||||
@@ -478,43 +390,12 @@ const SensorsAnalogDialog = ({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Grid>
|
</Grid>
|
||||||
{fieldErrors && Object.keys(fieldErrors).length > 0 && (
|
|
||||||
<Box mt={1}>
|
|
||||||
{Object.values(fieldErrors).map((errArr, idx) =>
|
|
||||||
Array.isArray(errArr)
|
|
||||||
? errArr.map((err, j) => (
|
|
||||||
<Typography
|
|
||||||
key={`${idx}-${j}`}
|
|
||||||
color="error"
|
|
||||||
variant="caption"
|
|
||||||
display="block"
|
|
||||||
>
|
|
||||||
{err.message}
|
|
||||||
</Typography>
|
|
||||||
))
|
|
||||||
: null
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
{editItem.s && (
|
|
||||||
<Grid>
|
|
||||||
<Typography mt={1} color="warning.main" variant="body2">
|
|
||||||
<WarningIcon
|
|
||||||
fontSize="small"
|
|
||||||
sx={{ mr: 1, verticalAlign: 'middle' }}
|
|
||||||
color="warning"
|
|
||||||
/>
|
|
||||||
{LL.SYSTEM(0)} {LL.SENSOR(0)}
|
|
||||||
</Typography>
|
|
||||||
</Grid>
|
|
||||||
)}
|
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
{!creating && (
|
{!creating && (
|
||||||
<Box flexGrow={1} sx={{ '& button': { mt: 0 } }}>
|
<Box flexGrow={1} sx={{ '& button': { mt: 0 } }}>
|
||||||
<Button
|
<Button
|
||||||
startIcon={<RemoveIcon />}
|
startIcon={<RemoveIcon />}
|
||||||
disabled={editItem.s}
|
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
color="warning"
|
color="warning"
|
||||||
onClick={remove}
|
onClick={remove}
|
||||||
@@ -532,7 +413,7 @@ const SensorsAnalogDialog = ({
|
|||||||
{LL.CANCEL()}
|
{LL.CANCEL()}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
startIcon={<DoneIcon />}
|
startIcon={<WarningIcon color="warning" />}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
onClick={save}
|
onClick={save}
|
||||||
color="primary"
|
color="primary"
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, 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,
|
Box,
|
||||||
@@ -34,12 +33,6 @@ 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,
|
||||||
@@ -50,18 +43,7 @@ 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 = useMemo(
|
|
||||||
() =>
|
|
||||||
updateValue(
|
|
||||||
setEditItem as unknown as (
|
|
||||||
updater: (
|
|
||||||
prevState: Readonly<Record<string, unknown>>
|
|
||||||
) => Record<string, unknown>
|
|
||||||
) => void
|
|
||||||
),
|
|
||||||
[setEditItem]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
@@ -70,16 +52,16 @@ const SensorsTemperatureDialog = ({
|
|||||||
}
|
}
|
||||||
}, [open, selectedItem]);
|
}, [open, selectedItem]);
|
||||||
|
|
||||||
const handleClose = useCallback(
|
const handleClose = (
|
||||||
(_event: React.SyntheticEvent, reason?: string) => {
|
_event: React.SyntheticEvent,
|
||||||
if (reason !== 'backdropClick') {
|
reason: 'backdropClick' | 'escapeKeyDown'
|
||||||
onClose();
|
) => {
|
||||||
}
|
if (reason !== 'backdropClick') {
|
||||||
},
|
onClose();
|
||||||
[onClose]
|
}
|
||||||
);
|
};
|
||||||
|
|
||||||
const save = useCallback(async () => {
|
const save = async () => {
|
||||||
try {
|
try {
|
||||||
setFieldErrors(undefined);
|
setFieldErrors(undefined);
|
||||||
await validate(validator, editItem);
|
await validate(validator, editItem);
|
||||||
@@ -87,31 +69,15 @@ const SensorsTemperatureDialog = ({
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
setFieldErrors(error as ValidateFieldsError);
|
setFieldErrors(error as ValidateFieldsError);
|
||||||
}
|
}
|
||||||
}, [validator, editItem, onSave]);
|
};
|
||||||
|
|
||||||
const dialogTitle = useMemo(() => `${LL.EDIT()} ${LL.TEMP_SENSOR()}`, [LL]);
|
|
||||||
|
|
||||||
const offsetValue = useMemo(() => numberValue(editItem.o), [editItem.o]);
|
|
||||||
|
|
||||||
const slotProps = useMemo(
|
|
||||||
() => ({
|
|
||||||
input: {
|
|
||||||
startAdornment: <InputAdornment position="start">{TEMP_UNIT}</InputAdornment>
|
|
||||||
},
|
|
||||||
htmlInput: {
|
|
||||||
min: OFFSET_MIN,
|
|
||||||
max: OFFSET_MAX,
|
|
||||||
step: OFFSET_STEP
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
|
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
|
||||||
<DialogTitle>{dialogTitle}</DialogTitle>
|
<DialogTitle>
|
||||||
|
{LL.EDIT()} {LL.TEMP_SENSOR()}
|
||||||
|
</DialogTitle>
|
||||||
<DialogContent dividers>
|
<DialogContent dividers>
|
||||||
<Box color="warning.main" mb={2}>
|
<Box color="warning.main" p={0} pl={0} pr={0} mt={0} mb={2}>
|
||||||
<Typography variant="body2">
|
<Typography variant="body2">
|
||||||
{LL.ID_OF(LL.SENSOR(0))}: {editItem.id}
|
{LL.ID_OF(LL.SENSOR(0))}: {editItem.id}
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -119,7 +85,7 @@ const SensorsTemperatureDialog = ({
|
|||||||
<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}
|
||||||
@@ -131,27 +97,22 @@ const SensorsTemperatureDialog = ({
|
|||||||
<TextField
|
<TextField
|
||||||
name="o"
|
name="o"
|
||||||
label={LL.OFFSET()}
|
label={LL.OFFSET()}
|
||||||
value={offsetValue}
|
value={numberValue(editItem.o)}
|
||||||
sx={{ width: '11ch' }}
|
sx={{ width: '11ch' }}
|
||||||
type="number"
|
type="number"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
onChange={updateFormValue}
|
onChange={updateFormValue}
|
||||||
slotProps={slotProps}
|
slotProps={{
|
||||||
|
input: {
|
||||||
|
startAdornment: (
|
||||||
|
<InputAdornment position="start">°C</InputAdornment>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
htmlInput: { min: '-5', max: '5', step: '0.1' }
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
{editItem.s && (
|
|
||||||
<Grid>
|
|
||||||
<Typography mt={1} color="warning.main" variant="body2">
|
|
||||||
<WarningIcon
|
|
||||||
fontSize="small"
|
|
||||||
sx={{ mr: 1, verticalAlign: 'middle' }}
|
|
||||||
color="warning"
|
|
||||||
/>
|
|
||||||
{LL.SYSTEM(0)} {LL.SENSOR(0)}
|
|
||||||
</Typography>
|
|
||||||
</Grid>
|
|
||||||
)}
|
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<Button
|
<Button
|
||||||
@@ -163,7 +124,7 @@ const SensorsTemperatureDialog = ({
|
|||||||
{LL.CANCEL()}
|
{LL.CANCEL()}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
startIcon={<DoneIcon />}
|
startIcon={<WarningIcon color="warning" />}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
onClick={save}
|
onClick={save}
|
||||||
color="primary"
|
color="primary"
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
import { memo, useCallback, useContext } from 'react';
|
|
||||||
|
|
||||||
import PersonIcon from '@mui/icons-material/Person';
|
|
||||||
import {
|
|
||||||
Avatar,
|
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
Divider,
|
|
||||||
List,
|
|
||||||
ListItem,
|
|
||||||
ListItemText,
|
|
||||||
Typography
|
|
||||||
} from '@mui/material';
|
|
||||||
|
|
||||||
import { AuthenticatedContext } from '@/contexts/authentication';
|
|
||||||
import { SectionContent, useLayoutTitle } from 'components';
|
|
||||||
import { LanguageSelector } from 'components/inputs';
|
|
||||||
import { useI18nContext } from 'i18n/i18n-react';
|
|
||||||
|
|
||||||
const UserProfileComponent = () => {
|
|
||||||
const { LL } = useI18nContext();
|
|
||||||
const { me, signOut } = useContext(AuthenticatedContext);
|
|
||||||
|
|
||||||
useLayoutTitle(LL.USER_PROFILE());
|
|
||||||
|
|
||||||
const handleSignOut = useCallback(() => {
|
|
||||||
signOut(true);
|
|
||||||
}, [signOut]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SectionContent>
|
|
||||||
<List sx={{ flexGrow: 1 }}>
|
|
||||||
<ListItem disablePadding>
|
|
||||||
<Avatar sx={{ bgcolor: '#9e9e9e', color: 'white' }}>
|
|
||||||
<PersonIcon />
|
|
||||||
</Avatar>
|
|
||||||
<ListItemText
|
|
||||||
sx={{ pl: 2, color: '#2196f3' }}
|
|
||||||
primary={me.username}
|
|
||||||
secondary={'(' + (me.admin ? LL.ADMINISTRATOR() : LL.GUEST()) + ')'}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
</List>
|
|
||||||
<Box mt={2} mb={2} display="flex" alignItems="center">
|
|
||||||
<Typography mr={2} variant="body1" align="center">
|
|
||||||
{LL.LANGUAGE()}:
|
|
||||||
</Typography>
|
|
||||||
<LanguageSelector />
|
|
||||||
</Box>
|
|
||||||
<Divider />
|
|
||||||
<Button
|
|
||||||
sx={{ mt: 2 }}
|
|
||||||
variant="outlined"
|
|
||||||
color="primary"
|
|
||||||
onClick={handleSignOut}
|
|
||||||
>
|
|
||||||
{LL.SIGN_OUT()}
|
|
||||||
</Button>
|
|
||||||
</SectionContent>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const UserProfile = memo(UserProfileComponent);
|
|
||||||
|
|
||||||
export default UserProfile;
|
|
||||||
@@ -2,30 +2,27 @@ 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 totalMs = duration_min * 60000;
|
const days = Math.trunc((duration_min * 60000) / 86400000);
|
||||||
const days = Math.trunc(totalMs / 86400000);
|
const hours = Math.trunc((duration_min * 60000) / 3600000) % 24;
|
||||||
const hours = Math.trunc(totalMs / 3600000) % 24;
|
const minutes = Math.trunc((duration_min * 60000) / 60000) % 60;
|
||||||
const minutes = Math.trunc(duration_min) % 60;
|
|
||||||
|
|
||||||
const parts: string[] = [];
|
let formatted = '';
|
||||||
if (days) {
|
if (days) {
|
||||||
parts.push(LL.NUM_DAYS({ num: days }));
|
formatted += LL.NUM_DAYS({ num: days });
|
||||||
}
|
|
||||||
if (hours) {
|
|
||||||
parts.push(LL.NUM_HOURS({ num: hours }));
|
|
||||||
}
|
|
||||||
if (minutes) {
|
|
||||||
parts.push(LL.NUM_MINUTES({ num: minutes }));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return parts.join(' ');
|
if (hours) {
|
||||||
|
if (formatted) formatted += ' ';
|
||||||
|
formatted += LL.NUM_HOURS({ num: hours });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (minutes) {
|
||||||
|
if (formatted) formatted += ' ';
|
||||||
|
formatted += LL.NUM_MINUTES({ num: minutes });
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatted;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function formatValue(
|
export function formatValue(
|
||||||
@@ -33,21 +30,18 @@ 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 === DeviceValueUOM.NONE
|
(value === '' || uom === undefined || uom === 0
|
||||||
? ''
|
? ''
|
||||||
: ' ' + 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 });
|
||||||
@@ -56,12 +50,18 @@ 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 numberFormatter.format(value);
|
return new Intl.NumberFormat().format(value);
|
||||||
case DeviceValueUOM.DEGREES:
|
case DeviceValueUOM.DEGREES:
|
||||||
case DeviceValueUOM.DEGREES_R:
|
case DeviceValueUOM.DEGREES_R:
|
||||||
case DeviceValueUOM.FAHRENHEIT:
|
case DeviceValueUOM.FAHRENHEIT:
|
||||||
return numberFormatterWithDecimal.format(value) + ' ' + DeviceValueUOM_s[uom];
|
return (
|
||||||
|
new Intl.NumberFormat(undefined, {
|
||||||
|
minimumFractionDigits: 1
|
||||||
|
}).format(value) +
|
||||||
|
' ' +
|
||||||
|
DeviceValueUOM_s[uom]
|
||||||
|
);
|
||||||
default:
|
default:
|
||||||
return numberFormatter.format(value) + ' ' + DeviceValueUOM_s[uom];
|
return new Intl.NumberFormat().format(value) + ' ' + DeviceValueUOM_s[uom];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export interface Stat {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Activity {
|
export interface Activity {
|
||||||
readonly stats: readonly Stat[];
|
stats: Stat[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Device {
|
export interface Device {
|
||||||
@@ -82,43 +82,38 @@ 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; // name
|
n: string;
|
||||||
v: number; // value
|
v: number;
|
||||||
u: number; // uom
|
u: number;
|
||||||
o: number; // offset
|
o: number;
|
||||||
f: number; // factor
|
f: number;
|
||||||
t: number; // type
|
t: number;
|
||||||
d: boolean; // deleted flag
|
d: boolean; // deleted flag
|
||||||
s: boolean; // system sensor flag
|
o_n?: string;
|
||||||
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 {
|
||||||
readonly connected: boolean;
|
connected: boolean;
|
||||||
readonly devices: readonly Device[];
|
devices: Device[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DashboardItem {
|
export interface DashboardItem {
|
||||||
@@ -127,12 +122,11 @@ 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 {
|
||||||
readonly connected: boolean; // true if connected to EMS bus
|
connected: boolean; // true if connected to EMS bus
|
||||||
readonly nodes: readonly DashboardItem[];
|
nodes: DashboardItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DeviceValue {
|
export interface DeviceValue {
|
||||||
@@ -145,11 +139,10 @@ 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 {
|
||||||
readonly nodes: readonly DeviceValue[];
|
nodes: DeviceValue[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DeviceEntity {
|
export interface DeviceEntity {
|
||||||
@@ -202,7 +195,7 @@ export enum DeviceValueUOM {
|
|||||||
export const DeviceValueUOM_s = [
|
export const DeviceValueUOM_s = [
|
||||||
'',
|
'',
|
||||||
'°C',
|
'°C',
|
||||||
'°C Rel',
|
'°C',
|
||||||
'%',
|
'%',
|
||||||
'l/min',
|
'l/min',
|
||||||
'kWh',
|
'kWh',
|
||||||
@@ -228,10 +221,11 @@ 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,
|
||||||
@@ -246,34 +240,31 @@ 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 = [
|
||||||
'Digital In', // 1
|
'(disabled)',
|
||||||
'Counter', // 2
|
'Digital In',
|
||||||
'ADC In', // 3
|
'Counter',
|
||||||
'Timer', // 4
|
'ADC In',
|
||||||
'Rate', // 5
|
'Timer',
|
||||||
'Digital Out', // 6
|
'Rate',
|
||||||
'PWM 0', // 7
|
'Digital Out',
|
||||||
'PWM 1', // 8
|
'PWM 0',
|
||||||
'PWM 2', // 9
|
'PWM 1',
|
||||||
'NTC Temp', // 10
|
'PWM 2',
|
||||||
'RGB Led', // 11
|
'NTC Temp.',
|
||||||
'Pulse', // 12
|
'RGB Led',
|
||||||
'Freq 0', // 13
|
'Pulse',
|
||||||
'Freq 1', // 14
|
'Freq 0',
|
||||||
'Freq 2', // 15
|
'Freq 1',
|
||||||
'Counter 0', // 16
|
'Freq 2'
|
||||||
'Counter 1', // 17
|
];
|
||||||
'Counter 2' // 18
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export const BOARD_PROFILES = {
|
type BoardProfiles = Record<string, string>;
|
||||||
|
|
||||||
|
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',
|
||||||
@@ -287,9 +278,7 @@ export const BOARD_PROFILES = {
|
|||||||
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;
|
||||||
@@ -326,7 +315,6 @@ export interface WriteAnalogSensor {
|
|||||||
uom: number;
|
uom: number;
|
||||||
type: number;
|
type: number;
|
||||||
deleted: boolean;
|
deleted: boolean;
|
||||||
is_system: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum DeviceEntityMask {
|
export enum DeviceEntityMask {
|
||||||
@@ -358,7 +346,7 @@ export interface ScheduleItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Schedule {
|
export interface Schedule {
|
||||||
readonly schedule: readonly ScheduleItem[];
|
schedule: ScheduleItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ModuleItem {
|
export interface ModuleItem {
|
||||||
@@ -376,7 +364,7 @@ export interface ModuleItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Modules {
|
export interface Modules {
|
||||||
readonly modules: readonly ModuleItem[];
|
modules: ModuleItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum ScheduleFlag {
|
export enum ScheduleFlag {
|
||||||
@@ -425,7 +413,7 @@ export interface EntityItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Entities {
|
export interface Entities {
|
||||||
readonly entities: readonly EntityItem[];
|
entities: EntityItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// matches emsdevice.h DeviceType
|
// matches emsdevice.h DeviceType
|
||||||
@@ -481,4 +469,4 @@ export const DeviceValueTypeNames = [
|
|||||||
'ENUM',
|
'ENUM',
|
||||||
'RAW',
|
'RAW',
|
||||||
'CMD'
|
'CMD'
|
||||||
] as const;
|
];
|
||||||
|
|||||||
@@ -11,158 +11,273 @@ import type {
|
|||||||
TemperatureSensor
|
TemperatureSensor
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
// Constants
|
export const GPIO_VALIDATOR = {
|
||||||
const ERROR_MESSAGES = {
|
validator(
|
||||||
GPIO_INVALID: 'Must be an valid GPIO port',
|
_rule: InternalRuleItem,
|
||||||
NAME_DUPLICATE: 'Name already in use',
|
value: number,
|
||||||
GPIO_DUPLICATE: 'GPIO already in use',
|
callback: (error?: string) => void
|
||||||
VALUE_OUT_OF_RANGE: 'Value out of range',
|
) {
|
||||||
HEX_REQUIRED: 'Is required and must be in hex format'
|
if (
|
||||||
} as const;
|
value &&
|
||||||
|
(value === 1 ||
|
||||||
const VALIDATION_LIMITS = {
|
(value >= 6 && value <= 11) ||
|
||||||
PORT_MIN: 0,
|
value === 20 ||
|
||||||
PORT_MAX: 65535,
|
value === 24 ||
|
||||||
MODBUS_MAX_CLIENTS_MIN: 0,
|
(value >= 28 && value <= 31) ||
|
||||||
MODBUS_MAX_CLIENTS_MAX: 50,
|
value > 40 ||
|
||||||
MODBUS_TIMEOUT_MIN: 100,
|
value < 0)
|
||||||
MODBUS_TIMEOUT_MAX: 20000,
|
) {
|
||||||
SYSLOG_MARK_INTERVAL_MIN: 0,
|
callback('Must be an valid GPIO port');
|
||||||
SYSLOG_MARK_INTERVAL_MAX: 3600,
|
} else {
|
||||||
SHOWER_MIN_DURATION_MIN: 10,
|
callback();
|
||||||
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);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Generic unique name validator factory
|
export const GPIO_VALIDATORR = {
|
||||||
const createUniqueNameValidator = <T extends { name: string }>(
|
validator(
|
||||||
items: T[],
|
_rule: InternalRuleItem,
|
||||||
originalName?: string
|
value: number,
|
||||||
) => ({
|
callback: (error?: string) => void
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
value &&
|
||||||
|
(value === 1 ||
|
||||||
|
(value >= 6 && value <= 11) ||
|
||||||
|
(value >= 16 && value <= 17) ||
|
||||||
|
value === 20 ||
|
||||||
|
value === 24 ||
|
||||||
|
(value >= 28 && value <= 31) ||
|
||||||
|
value > 40 ||
|
||||||
|
value < 0)
|
||||||
|
) {
|
||||||
|
callback('Must be an valid GPIO port');
|
||||||
|
} else {
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GPIO_VALIDATORC3 = {
|
||||||
|
validator(
|
||||||
|
_rule: InternalRuleItem,
|
||||||
|
value: number,
|
||||||
|
callback: (error?: string) => void
|
||||||
|
) {
|
||||||
|
if (value && ((value >= 11 && value <= 19) || value > 21 || value < 0)) {
|
||||||
|
callback('Must be an valid GPIO port');
|
||||||
|
} else {
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GPIO_VALIDATORS2 = {
|
||||||
|
validator(
|
||||||
|
_rule: InternalRuleItem,
|
||||||
|
value: number,
|
||||||
|
callback: (error?: string) => void
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
value &&
|
||||||
|
((value >= 19 && value <= 20) ||
|
||||||
|
(value >= 22 && value <= 32) ||
|
||||||
|
value > 40 ||
|
||||||
|
value < 0)
|
||||||
|
) {
|
||||||
|
callback('Must be an valid GPIO port');
|
||||||
|
} else {
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GPIO_VALIDATORS3 = {
|
||||||
|
validator(
|
||||||
|
_rule: InternalRuleItem,
|
||||||
|
value: number,
|
||||||
|
callback: (error?: string) => void
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
value &&
|
||||||
|
((value >= 19 && value <= 20) ||
|
||||||
|
(value >= 22 && value <= 37) ||
|
||||||
|
(value >= 39 && value <= 42) ||
|
||||||
|
value > 48 ||
|
||||||
|
value < 0)
|
||||||
|
) {
|
||||||
|
callback('Must be an valid GPIO port');
|
||||||
|
} else {
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createSettingsValidator = (settings: Settings) =>
|
||||||
|
new Schema({
|
||||||
|
...(settings.board_profile === 'CUSTOM' &&
|
||||||
|
settings.platform === 'ESP32' && {
|
||||||
|
led_gpio: [
|
||||||
|
{ required: true, message: 'LED GPIO is required' },
|
||||||
|
GPIO_VALIDATOR
|
||||||
|
],
|
||||||
|
dallas_gpio: [
|
||||||
|
{ required: true, message: 'GPIO is required' },
|
||||||
|
GPIO_VALIDATOR
|
||||||
|
],
|
||||||
|
pbutton_gpio: [
|
||||||
|
{ required: true, message: 'Button GPIO is required' },
|
||||||
|
GPIO_VALIDATOR
|
||||||
|
],
|
||||||
|
tx_gpio: [
|
||||||
|
{ required: true, message: 'Tx GPIO is required' },
|
||||||
|
GPIO_VALIDATOR
|
||||||
|
],
|
||||||
|
rx_gpio: [{ required: true, message: 'Rx GPIO is required' }, GPIO_VALIDATOR]
|
||||||
|
}),
|
||||||
|
...(settings.board_profile === 'CUSTOM' &&
|
||||||
|
settings.platform === 'ESP32C3' && {
|
||||||
|
led_gpio: [
|
||||||
|
{ required: true, message: 'LED GPIO is required' },
|
||||||
|
GPIO_VALIDATORC3
|
||||||
|
],
|
||||||
|
dallas_gpio: [
|
||||||
|
{ required: true, message: 'GPIO is required' },
|
||||||
|
GPIO_VALIDATORC3
|
||||||
|
],
|
||||||
|
pbutton_gpio: [
|
||||||
|
{ required: true, message: 'Button GPIO is required' },
|
||||||
|
GPIO_VALIDATORC3
|
||||||
|
],
|
||||||
|
tx_gpio: [
|
||||||
|
{ required: true, message: 'Tx GPIO is required' },
|
||||||
|
GPIO_VALIDATORC3
|
||||||
|
],
|
||||||
|
rx_gpio: [
|
||||||
|
{ required: true, message: 'Rx GPIO is required' },
|
||||||
|
GPIO_VALIDATORC3
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
...(settings.board_profile === 'CUSTOM' &&
|
||||||
|
settings.platform === 'ESP32S2' && {
|
||||||
|
led_gpio: [
|
||||||
|
{ required: true, message: 'LED GPIO is required' },
|
||||||
|
GPIO_VALIDATORS2
|
||||||
|
],
|
||||||
|
dallas_gpio: [
|
||||||
|
{ required: true, message: 'GPIO is required' },
|
||||||
|
GPIO_VALIDATORS2
|
||||||
|
],
|
||||||
|
pbutton_gpio: [
|
||||||
|
{ required: true, message: 'Button GPIO is required' },
|
||||||
|
GPIO_VALIDATORS2
|
||||||
|
],
|
||||||
|
tx_gpio: [
|
||||||
|
{ required: true, message: 'Tx GPIO is required' },
|
||||||
|
GPIO_VALIDATORS2
|
||||||
|
],
|
||||||
|
rx_gpio: [
|
||||||
|
{ required: true, message: 'Rx GPIO is required' },
|
||||||
|
GPIO_VALIDATORS2
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
...(settings.board_profile === 'CUSTOM' &&
|
||||||
|
settings.platform === 'ESP32S3' && {
|
||||||
|
led_gpio: [
|
||||||
|
{ required: true, message: 'LED GPIO is required' },
|
||||||
|
GPIO_VALIDATORS3
|
||||||
|
],
|
||||||
|
dallas_gpio: [
|
||||||
|
{ required: true, message: 'GPIO is required' },
|
||||||
|
GPIO_VALIDATORS3
|
||||||
|
],
|
||||||
|
pbutton_gpio: [
|
||||||
|
{ required: true, message: 'Button GPIO is required' },
|
||||||
|
GPIO_VALIDATORS3
|
||||||
|
],
|
||||||
|
tx_gpio: [
|
||||||
|
{ required: true, message: 'Tx GPIO is required' },
|
||||||
|
GPIO_VALIDATORS3
|
||||||
|
],
|
||||||
|
rx_gpio: [
|
||||||
|
{ required: true, message: 'Rx GPIO is required' },
|
||||||
|
GPIO_VALIDATORS3
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
...(settings.syslog_enabled && {
|
||||||
|
syslog_host: [
|
||||||
|
{ required: true, message: 'Host is required' },
|
||||||
|
IP_OR_HOSTNAME_VALIDATOR
|
||||||
|
],
|
||||||
|
syslog_port: [
|
||||||
|
{ required: true, message: 'Port is required' },
|
||||||
|
{ type: 'number', min: 0, max: 65535, message: 'Invalid Port' }
|
||||||
|
],
|
||||||
|
syslog_mark_interval: [
|
||||||
|
{ required: true, message: 'Mark interval is required' },
|
||||||
|
{ type: 'number', min: 0, max: 10, message: 'Must be between 0 and 10' }
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
...(settings.modbus_enabled && {
|
||||||
|
modbus_max_clients: [
|
||||||
|
{ required: true, message: 'Max clients is required' },
|
||||||
|
{ type: 'number', min: 0, max: 50, message: 'Invalid number' }
|
||||||
|
],
|
||||||
|
modbus_port: [
|
||||||
|
{ required: true, message: 'Port is required' },
|
||||||
|
{ type: 'number', min: 0, max: 65535, message: 'Invalid Port' }
|
||||||
|
],
|
||||||
|
modbus_timeout: [
|
||||||
|
{ required: true, message: 'Timeout is required' },
|
||||||
|
{
|
||||||
|
type: 'number',
|
||||||
|
min: 100,
|
||||||
|
max: 20000,
|
||||||
|
message: 'Must be between 100 and 20000'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
...(settings.shower_timer && {
|
||||||
|
shower_min_duration: [
|
||||||
|
{
|
||||||
|
type: 'number',
|
||||||
|
min: 10,
|
||||||
|
max: 360,
|
||||||
|
message: 'Time must be between 10 and 360 seconds'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
...(settings.shower_alert && {
|
||||||
|
shower_alert_trigger: [
|
||||||
|
{
|
||||||
|
type: 'number',
|
||||||
|
min: 1,
|
||||||
|
max: 20,
|
||||||
|
message: 'Time must be between 1 and 20 minutes'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
shower_alert_coldshot: [
|
||||||
|
{
|
||||||
|
type: 'number',
|
||||||
|
min: 1,
|
||||||
|
max: 10,
|
||||||
|
message: 'Time must be between 1 and 10 seconds'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
...(settings.remote_timeout_en && {
|
||||||
|
remote_timeout: [
|
||||||
|
{
|
||||||
|
type: 'number',
|
||||||
|
min: 1,
|
||||||
|
max: 240,
|
||||||
|
message: 'Timeout must be between 1 and 240 hours'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
export const uniqueNameValidator = (schedule: ScheduleItem[], o_name?: string) => ({
|
||||||
validator(
|
validator(
|
||||||
_rule: InternalRuleItem,
|
_rule: InternalRuleItem,
|
||||||
name: string,
|
name: string,
|
||||||
@@ -170,114 +285,102 @@ const createUniqueNameValidator = <T extends { name: string }>(
|
|||||||
) {
|
) {
|
||||||
if (
|
if (
|
||||||
name !== '' &&
|
name !== '' &&
|
||||||
(originalName === undefined ||
|
(o_name === undefined || o_name.toLowerCase() !== name.toLowerCase()) &&
|
||||||
originalName.toLowerCase() !== name.toLowerCase()) &&
|
schedule.find((si) => si.name.toLowerCase() === name.toLowerCase())
|
||||||
items.find((item) => item.name.toLowerCase() === name.toLowerCase())
|
|
||||||
) {
|
) {
|
||||||
callback(ERROR_MESSAGES.NAME_DUPLICATE);
|
callback('Name already in use');
|
||||||
return;
|
} else {
|
||||||
|
callback();
|
||||||
}
|
}
|
||||||
callback();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Generic field name validator (for cases where the name field has different property names)
|
|
||||||
const createUniqueFieldNameValidator = <T>(
|
|
||||||
items: T[],
|
|
||||||
getName: (item: T) => string,
|
|
||||||
originalName?: string
|
|
||||||
) => ({
|
|
||||||
validator(
|
|
||||||
_rule: InternalRuleItem,
|
|
||||||
name: string,
|
|
||||||
callback: (error?: string) => void
|
|
||||||
) {
|
|
||||||
if (
|
|
||||||
name !== '' &&
|
|
||||||
(originalName === undefined ||
|
|
||||||
originalName.toLowerCase() !== name.toLowerCase()) &&
|
|
||||||
items.find((item) => getName(item).toLowerCase() === name.toLowerCase())
|
|
||||||
) {
|
|
||||||
callback(ERROR_MESSAGES.NAME_DUPLICATE);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
callback();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const NAME_PATTERN_BASE = '[a-zA-Z0-9_]';
|
|
||||||
const NAME_PATTERN_MESSAGE = `Must be <${VALIDATION_LIMITS.NAME_MAX_LENGTH + 1} characters: alphanumeric or '_'`;
|
|
||||||
|
|
||||||
const NAME_PATTERN = {
|
|
||||||
type: 'string' as const,
|
|
||||||
pattern: new RegExp(
|
|
||||||
`^${NAME_PATTERN_BASE}{0,${VALIDATION_LIMITS.NAME_MAX_LENGTH}}$`
|
|
||||||
),
|
|
||||||
message: NAME_PATTERN_MESSAGE
|
|
||||||
};
|
|
||||||
|
|
||||||
const NAME_PATTERN_REQUIRED = {
|
|
||||||
type: 'string' as const,
|
|
||||||
pattern: new RegExp(
|
|
||||||
`^${NAME_PATTERN_BASE}{1,${VALIDATION_LIMITS.NAME_MAX_LENGTH}}$`
|
|
||||||
),
|
|
||||||
message: NAME_PATTERN_MESSAGE
|
|
||||||
};
|
|
||||||
|
|
||||||
export const uniqueNameValidator = (schedule: ScheduleItem[], o_name?: string) =>
|
|
||||||
createUniqueNameValidator(schedule, o_name);
|
|
||||||
|
|
||||||
export const schedulerItemValidation = (
|
export const schedulerItemValidation = (
|
||||||
schedule: ScheduleItem[],
|
schedule: ScheduleItem[],
|
||||||
scheduleItem: ScheduleItem
|
scheduleItem: ScheduleItem
|
||||||
) =>
|
) =>
|
||||||
new Schema({
|
new Schema({
|
||||||
name: [NAME_PATTERN, uniqueNameValidator(schedule, scheduleItem.o_name)],
|
name: [
|
||||||
|
{
|
||||||
|
type: 'string',
|
||||||
|
pattern: /^[a-zA-Z0-9_]{0,19}$/,
|
||||||
|
message: "Must be <20 characters: alphanumeric or '_'"
|
||||||
|
},
|
||||||
|
...[uniqueNameValidator(schedule, scheduleItem.o_name)]
|
||||||
|
],
|
||||||
cmd: [
|
cmd: [
|
||||||
{ required: true, message: 'Command is required' },
|
{ required: true, message: 'Command is required' },
|
||||||
{
|
{
|
||||||
type: 'string',
|
type: 'string',
|
||||||
min: VALIDATION_LIMITS.COMMAND_MIN,
|
min: 1,
|
||||||
max: VALIDATION_LIMITS.COMMAND_MAX,
|
max: 300,
|
||||||
message: `Command must be ${VALIDATION_LIMITS.COMMAND_MIN}-${VALIDATION_LIMITS.COMMAND_MAX} characters`
|
message: 'Command must be 1-300 characters'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
export const uniqueCustomNameValidator = (entity: EntityItem[], o_name?: string) =>
|
export const uniqueCustomNameValidator = (
|
||||||
createUniqueNameValidator(entity, o_name);
|
entity: EntityItem[],
|
||||||
|
o_name?: string
|
||||||
const hexValidator = {
|
) => ({
|
||||||
validator(
|
validator(
|
||||||
_rule: InternalRuleItem,
|
_rule: InternalRuleItem,
|
||||||
value: string,
|
name: string,
|
||||||
callback: (error?: string) => void
|
callback: (error?: string) => void
|
||||||
) {
|
) {
|
||||||
if (!value || Number.isNaN(Number.parseInt(value, VALIDATION_LIMITS.HEX_BASE))) {
|
if (
|
||||||
callback(ERROR_MESSAGES.HEX_REQUIRED);
|
(o_name === undefined || o_name.toLowerCase() !== name.toLowerCase()) &&
|
||||||
return;
|
entity.find((ei) => ei.name.toLowerCase() === name.toLowerCase())
|
||||||
|
) {
|
||||||
|
callback('Name already in use');
|
||||||
|
} else {
|
||||||
|
callback();
|
||||||
}
|
}
|
||||||
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,
|
{
|
||||||
uniqueCustomNameValidator(entity, entityItem.o_name)
|
type: 'string',
|
||||||
|
pattern: /^[a-zA-Z0-9_]{1,19}$/,
|
||||||
|
message: "Must be <20 characters: alphanumeric or '_'"
|
||||||
|
},
|
||||||
|
...[uniqueCustomNameValidator(entity, entityItem.o_name)]
|
||||||
|
],
|
||||||
|
device_id: [
|
||||||
|
{
|
||||||
|
validator(
|
||||||
|
_rule: InternalRuleItem,
|
||||||
|
value: string,
|
||||||
|
callback: (error?: string) => void
|
||||||
|
) {
|
||||||
|
if (isNaN(parseInt(value, 16))) {
|
||||||
|
callback('Is required and must be in hex format');
|
||||||
|
}
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
type_id: [
|
||||||
|
{
|
||||||
|
validator(
|
||||||
|
_rule: InternalRuleItem,
|
||||||
|
value: string,
|
||||||
|
callback: (error?: string) => void
|
||||||
|
) {
|
||||||
|
if (isNaN(parseInt(value, 16))) {
|
||||||
|
callback('Is required and must be in hex format');
|
||||||
|
}
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
}
|
||||||
],
|
],
|
||||||
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' }]
|
||||||
});
|
});
|
||||||
@@ -285,34 +388,93 @@ 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: [NAME_PATTERN, uniqueTemperatureNameValidator(sensors, sensor.o_n)]
|
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,
|
||||||
return new Schema({
|
platform: string
|
||||||
// name is required and must be unique
|
) =>
|
||||||
|
new Schema({
|
||||||
n: [
|
n: [
|
||||||
{ required: true, message: 'Name is required' },
|
{
|
||||||
NAME_PATTERN,
|
type: 'string',
|
||||||
uniqueAnalogNameValidator(sensors, sensor.o_n)
|
pattern: /^[a-zA-Z0-9_]{0,19}$/,
|
||||||
|
message: "Must be <20 characters: alphanumeric or '_'"
|
||||||
|
},
|
||||||
|
...[uniqueAnalogNameValidator(sensors, sensor.o_n)]
|
||||||
|
],
|
||||||
|
g: [
|
||||||
|
{ required: true, message: 'GPIO is required' },
|
||||||
|
platform === 'ESP32S3'
|
||||||
|
? GPIO_VALIDATORS3
|
||||||
|
: platform === 'ESP32S2'
|
||||||
|
? GPIO_VALIDATORS2
|
||||||
|
: platform === 'ESP32C3'
|
||||||
|
? GPIO_VALIDATORC3
|
||||||
|
: GPIO_VALIDATOR,
|
||||||
|
...(creating ? [isGPIOUniqueValidator(sensors)] : [])
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
};
|
|
||||||
|
|
||||||
export const deviceValueItemValidation = (dv: DeviceValue) =>
|
export const deviceValueItemValidation = (dv: DeviceValue) =>
|
||||||
new Schema({
|
new Schema({
|
||||||
@@ -326,12 +488,11 @@ export const deviceValueItemValidation = (dv: DeviceValue) =>
|
|||||||
) {
|
) {
|
||||||
if (
|
if (
|
||||||
typeof value === 'number' &&
|
typeof value === 'number' &&
|
||||||
dv.m !== undefined &&
|
dv.m &&
|
||||||
dv.x !== undefined &&
|
dv.x &&
|
||||||
(value < dv.m || value > dv.x)
|
(value < dv.m || value > dv.x)
|
||||||
) {
|
) {
|
||||||
callback(ERROR_MESSAGES.VALUE_OUT_OF_RANGE);
|
callback('Value out of range');
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
callback();
|
callback();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useMemo, useState } from 'react';
|
import { 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';
|
||||||
@@ -27,19 +27,6 @@ export const isAPEnabled = ({ provision_mode }: APSettingsType) =>
|
|||||||
provision_mode === APProvisionMode.AP_MODE_ALWAYS ||
|
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,
|
||||||
@@ -63,38 +50,33 @@ const APSettings = () => {
|
|||||||
|
|
||||||
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
||||||
|
|
||||||
const updateFormValue = useMemo(
|
const updateFormValue = updateValueDirty(
|
||||||
() =>
|
origData,
|
||||||
updateValueDirty(
|
dirtyFlags,
|
||||||
origData as unknown as Record<string, unknown>,
|
setDirtyFlags,
|
||||||
dirtyFlags,
|
updateDataValue as (value: unknown) => void
|
||||||
setDirtyFlags,
|
|
||||||
updateDataValue as (value: unknown) => void
|
|
||||||
),
|
|
||||||
[origData, dirtyFlags, setDirtyFlags, updateDataValue]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Memoize AP enabled state
|
|
||||||
const apEnabled = useMemo(() => (data ? isAPEnabled(data) : false), [data]);
|
|
||||||
|
|
||||||
// Memoize validation and submit handler
|
|
||||||
const validateAndSubmit = useCallback(async () => {
|
|
||||||
if (!data) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
setFieldErrors(undefined);
|
|
||||||
await validate(createAPSettingsValidator(data), data);
|
|
||||||
await saveData();
|
|
||||||
} catch (error) {
|
|
||||||
setFieldErrors(error as ValidateFieldsError);
|
|
||||||
}
|
|
||||||
}, [data, saveData]);
|
|
||||||
|
|
||||||
const content = () => {
|
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
|
||||||
@@ -118,7 +100,7 @@ const APSettings = () => {
|
|||||||
{LL.AP_PROVIDE_TEXT_3()}
|
{LL.AP_PROVIDE_TEXT_3()}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
</ValidatedTextField>
|
</ValidatedTextField>
|
||||||
{apEnabled && (
|
{isAPEnabled(data) && (
|
||||||
<>
|
<>
|
||||||
<ValidatedTextField
|
<ValidatedTextField
|
||||||
fieldErrors={fieldErrors || {}}
|
fieldErrors={fieldErrors || {}}
|
||||||
@@ -152,7 +134,7 @@ const APSettings = () => {
|
|||||||
onChange={updateFormValue}
|
onChange={updateFormValue}
|
||||||
margin="normal"
|
margin="normal"
|
||||||
>
|
>
|
||||||
{CHANNEL_RANGE.map((i) => (
|
{range(1, 14).map((i) => (
|
||||||
<MenuItem key={i} value={i}>
|
<MenuItem key={i} value={i}>
|
||||||
{i}
|
{i}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
@@ -180,7 +162,7 @@ const APSettings = () => {
|
|||||||
onChange={updateFormValue}
|
onChange={updateFormValue}
|
||||||
margin="normal"
|
margin="normal"
|
||||||
>
|
>
|
||||||
{MAX_CLIENTS_RANGE.map((i) => (
|
{range(1, 9).map((i) => (
|
||||||
<MenuItem key={i} value={i}>
|
<MenuItem key={i} value={i}>
|
||||||
{i}
|
{i}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useMemo, 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 CancelIcon from '@mui/icons-material/Cancel';
|
||||||
@@ -37,13 +37,13 @@ import { 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, BoardProfileKey, Settings } from '../main/types';
|
import type { APIcall, 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 as BoardProfileKey]}
|
{BOARD_PROFILES[code]}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -72,7 +72,7 @@ const ApplicationSettings = () => {
|
|||||||
const { LL } = useI18nContext();
|
const { LL } = useI18nContext();
|
||||||
|
|
||||||
const updateFormValue = updateValueDirty(
|
const updateFormValue = updateValueDirty(
|
||||||
origData as unknown as Record<string, unknown>,
|
origData,
|
||||||
dirtyFlags,
|
dirtyFlags,
|
||||||
setDirtyFlags,
|
setDirtyFlags,
|
||||||
updateDataValue as (value: unknown) => void
|
updateDataValue as (value: unknown) => void
|
||||||
@@ -106,61 +106,50 @@ const ApplicationSettings = () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Memoized input props to prevent recreation on every render
|
const doRestart = async () => {
|
||||||
const SecondsInputProps = useMemo(
|
|
||||||
() => ({
|
|
||||||
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
|
|
||||||
}),
|
|
||||||
[LL]
|
|
||||||
);
|
|
||||||
|
|
||||||
const MinutesInputProps = useMemo(
|
|
||||||
() => ({
|
|
||||||
endAdornment: <InputAdornment position="end">{LL.MINUTES()}</InputAdornment>
|
|
||||||
}),
|
|
||||||
[LL]
|
|
||||||
);
|
|
||||||
|
|
||||||
const HoursInputProps = useMemo(
|
|
||||||
() => ({
|
|
||||||
endAdornment: <InputAdornment position="end">{LL.HOURS()}</InputAdornment>
|
|
||||||
}),
|
|
||||||
[LL]
|
|
||||||
);
|
|
||||||
|
|
||||||
const doRestart = useCallback(async () => {
|
|
||||||
setRestarting(true);
|
setRestarting(true);
|
||||||
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
|
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
|
||||||
(error: Error) => {
|
(error: Error) => {
|
||||||
toast.error(error.message);
|
toast.error(error.message);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}, [sendAPI]);
|
};
|
||||||
|
|
||||||
const updateBoardProfile = useCallback(
|
const updateBoardProfile = async (board_profile: string) => {
|
||||||
async (board_profile: string) => {
|
await readBoardProfile(board_profile).catch((error: Error) => {
|
||||||
await readBoardProfile(board_profile).catch((error: Error) => {
|
toast.error(error.message);
|
||||||
toast.error(error.message);
|
});
|
||||||
});
|
};
|
||||||
},
|
|
||||||
[readBoardProfile]
|
|
||||||
);
|
|
||||||
|
|
||||||
useLayoutTitle(LL.APPLICATION());
|
useLayoutTitle(LL.APPLICATION());
|
||||||
|
|
||||||
const validateAndSubmit = useCallback(async () => {
|
const SecondsInputProps = {
|
||||||
try {
|
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
|
||||||
setFieldErrors(undefined);
|
};
|
||||||
await validate(createSettingsValidator(data), data);
|
const MinutesInputProps = {
|
||||||
} catch (error) {
|
endAdornment: <InputAdornment position="end">{LL.MINUTES()}</InputAdornment>
|
||||||
setFieldErrors(error as ValidateFieldsError);
|
};
|
||||||
} finally {
|
const HoursInputProps = {
|
||||||
await saveData();
|
endAdornment: <InputAdornment position="end">{LL.HOURS()}</InputAdornment>
|
||||||
}
|
};
|
||||||
}, [data, saveData]);
|
|
||||||
|
|
||||||
const changeBoardProfile = useCallback(
|
const content = () => {
|
||||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
if (!data || !hardwareData) {
|
||||||
|
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;
|
const boardProfile = event.target.value;
|
||||||
updateFormValue(event);
|
updateFormValue(event);
|
||||||
if (boardProfile === 'CUSTOM') {
|
if (boardProfile === 'CUSTOM') {
|
||||||
@@ -171,22 +160,12 @@ const ApplicationSettings = () => {
|
|||||||
} else {
|
} else {
|
||||||
void updateBoardProfile(boardProfile);
|
void updateBoardProfile(boardProfile);
|
||||||
}
|
}
|
||||||
},
|
};
|
||||||
[data, updateBoardProfile, updateFormValue, updateDataValue]
|
|
||||||
);
|
|
||||||
|
|
||||||
const restart = useCallback(async () => {
|
const restart = async () => {
|
||||||
await validateAndSubmit();
|
await validateAndSubmit();
|
||||||
await doRestart();
|
await doRestart();
|
||||||
}, [validateAndSubmit, doRestart]);
|
};
|
||||||
|
|
||||||
// Memoize board profile select items to prevent recreation
|
|
||||||
const boardProfileItems = useMemo(() => boardProfileSelectItems(), []);
|
|
||||||
|
|
||||||
const content = () => {
|
|
||||||
if (!data || !hardwareData) {
|
|
||||||
return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -328,9 +307,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>
|
||||||
@@ -495,7 +474,7 @@ const ApplicationSettings = () => {
|
|||||||
margin="normal"
|
margin="normal"
|
||||||
select
|
select
|
||||||
>
|
>
|
||||||
{boardProfileItems}
|
{boardProfileSelectItems()}
|
||||||
<Divider />
|
<Divider />
|
||||||
<MenuItem key={'CUSTOM'} value={'CUSTOM'}>
|
<MenuItem key={'CUSTOM'} value={'CUSTOM'}>
|
||||||
{LL.CUSTOM()}…
|
{LL.CUSTOM()}…
|
||||||
@@ -602,7 +581,6 @@ 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>
|
||||||
@@ -858,9 +836,8 @@ const ApplicationSettings = () => {
|
|||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
{restartNeeded && (
|
{restartNeeded && (
|
||||||
<MessageBox level="warning" message={LL.RESTART_TEXT(0)}>
|
<MessageBox my={2} level="warning" message={LL.RESTART_TEXT(0)}>
|
||||||
<Button
|
<Button
|
||||||
sx={{ ml: 2 }}
|
|
||||||
startIcon={<PowerSettingsNewIcon />}
|
startIcon={<PowerSettingsNewIcon />}
|
||||||
variant="contained"
|
variant="contained"
|
||||||
color="error"
|
color="error"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useMemo, useState } from 'react';
|
import { 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';
|
||||||
@@ -19,13 +19,6 @@ import {
|
|||||||
import { useI18nContext } from 'i18n/i18n-react';
|
import { useI18nContext } from 'i18n/i18n-react';
|
||||||
import { saveFile } from 'utils';
|
import { saveFile } from 'utils';
|
||||||
|
|
||||||
interface DownloadButton {
|
|
||||||
key: string;
|
|
||||||
type: string;
|
|
||||||
label: string | number;
|
|
||||||
isGridButton: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DownloadUpload = () => {
|
const DownloadUpload = () => {
|
||||||
const { LL } = useI18nContext();
|
const { LL } = useI18nContext();
|
||||||
|
|
||||||
@@ -51,126 +44,95 @@ const DownloadUpload = () => {
|
|||||||
|
|
||||||
const { data, send: loadData, error } = useRequest(SystemApi.readSystemStatus);
|
const { data, send: loadData, error } = useRequest(SystemApi.readSystemStatus);
|
||||||
|
|
||||||
const doRestart = useCallback(async () => {
|
const doRestart = async () => {
|
||||||
setRestarting(true);
|
setRestarting(true);
|
||||||
try {
|
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
|
||||||
await sendAPI({ device: 'system', cmd: 'restart', id: 0 });
|
(error: Error) => {
|
||||||
} catch (error) {
|
toast.error(error.message);
|
||||||
toast.error((error as Error).message);
|
}
|
||||||
setRestarting(false);
|
);
|
||||||
}
|
};
|
||||||
}, [sendAPI]);
|
|
||||||
|
|
||||||
useLayoutTitle(LL.DOWNLOAD_UPLOAD());
|
useLayoutTitle(LL.DOWNLOAD_UPLOAD());
|
||||||
|
|
||||||
const downloadButtons: DownloadButton[] = useMemo(
|
const content = () => {
|
||||||
() => [
|
if (!data) {
|
||||||
{
|
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
|
||||||
key: 'settings',
|
}
|
||||||
type: 'settings',
|
|
||||||
label: LL.SETTINGS_OF(LL.APPLICATION()),
|
|
||||||
isGridButton: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'customizations',
|
|
||||||
type: 'customizations',
|
|
||||||
label: LL.CUSTOMIZATIONS(),
|
|
||||||
isGridButton: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'entities',
|
|
||||||
type: 'entities',
|
|
||||||
label: LL.CUSTOM_ENTITIES(0),
|
|
||||||
isGridButton: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'schedule',
|
|
||||||
type: 'schedule',
|
|
||||||
label: LL.SCHEDULE(0),
|
|
||||||
isGridButton: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'allvalues',
|
|
||||||
type: 'allvalues',
|
|
||||||
label: LL.ALLVALUES(),
|
|
||||||
isGridButton: false
|
|
||||||
}
|
|
||||||
],
|
|
||||||
[LL]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleDownload = useCallback(
|
|
||||||
(type: string) => () => {
|
|
||||||
void sendExportData(type);
|
|
||||||
},
|
|
||||||
[sendExportData]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (restarting) {
|
|
||||||
return <SystemMonitor />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!data) {
|
|
||||||
return (
|
return (
|
||||||
<SectionContent>
|
<>
|
||||||
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
|
<Typography sx={{ pb: 2 }} variant="h6" color="primary">
|
||||||
</SectionContent>
|
{LL.DOWNLOAD(0)}
|
||||||
);
|
</Typography>
|
||||||
}
|
|
||||||
|
|
||||||
const gridButtons = downloadButtons.filter((btn) => btn.isGridButton);
|
<Typography mb={1} variant="body1" color="warning">
|
||||||
const standaloneButton = downloadButtons.find((btn) => !btn.isGridButton);
|
{LL.DOWNLOAD_SETTINGS_TEXT()}.
|
||||||
|
</Typography>
|
||||||
|
<Grid container spacing={1}>
|
||||||
|
<Button
|
||||||
|
sx={{ ml: 2 }}
|
||||||
|
startIcon={<DownloadIcon />}
|
||||||
|
variant="outlined"
|
||||||
|
color="primary"
|
||||||
|
onClick={() => sendExportData('settings')}
|
||||||
|
>
|
||||||
|
{LL.SETTINGS_OF(LL.APPLICATION())}
|
||||||
|
</Button>
|
||||||
|
|
||||||
return (
|
<Button
|
||||||
<SectionContent>
|
sx={{ ml: 2 }}
|
||||||
<Typography sx={{ pb: 2 }} variant="h6" color="primary">
|
startIcon={<DownloadIcon />}
|
||||||
{LL.DOWNLOAD(0)}
|
variant="outlined"
|
||||||
</Typography>
|
color="primary"
|
||||||
|
onClick={() => sendExportData('customizations')}
|
||||||
<Typography mb={1} variant="body1" color="warning">
|
>
|
||||||
{LL.DOWNLOAD_SETTINGS_TEXT()}.
|
{LL.CUSTOMIZATIONS()}
|
||||||
</Typography>
|
</Button>
|
||||||
|
<Button
|
||||||
<Grid container spacing={2}>
|
sx={{ ml: 2 }}
|
||||||
{gridButtons.map((button) => (
|
startIcon={<DownloadIcon />}
|
||||||
<Grid key={button.key}>
|
variant="outlined"
|
||||||
<Button
|
color="primary"
|
||||||
startIcon={<DownloadIcon />}
|
onClick={() => sendExportData('entities')}
|
||||||
variant="outlined"
|
>
|
||||||
color="primary"
|
{LL.CUSTOM_ENTITIES(0)}
|
||||||
onClick={handleDownload(button.type)}
|
</Button>
|
||||||
>
|
<Button
|
||||||
{button.label}
|
sx={{ ml: 2 }}
|
||||||
</Button>
|
startIcon={<DownloadIcon />}
|
||||||
</Grid>
|
variant="outlined"
|
||||||
))}
|
color="primary"
|
||||||
</Grid>
|
onClick={() => sendExportData('schedule')}
|
||||||
|
>
|
||||||
<Typography mt={2} mb={1} variant="body1" color="warning">
|
{LL.SCHEDULE(0)}
|
||||||
{LL.DOWNLOAD_SETTINGS_TEXT2()}.
|
</Button>
|
||||||
</Typography>
|
</Grid>
|
||||||
|
|
||||||
{standaloneButton && (
|
|
||||||
<Button
|
<Button
|
||||||
|
sx={{ ml: 2, mt: 2 }}
|
||||||
startIcon={<DownloadIcon />}
|
startIcon={<DownloadIcon />}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
color="primary"
|
color="primary"
|
||||||
onClick={handleDownload(standaloneButton.type)}
|
onClick={() => sendExportData('allvalues')}
|
||||||
>
|
>
|
||||||
{standaloneButton.label}
|
{LL.ALLVALUES()}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
|
||||||
|
|
||||||
<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 }}>
|
<Box color="warning.main" sx={{ pb: 2 }}>
|
||||||
<Typography variant="body1">{LL.UPLOAD_TEXT()}.</Typography>
|
<Typography variant="body1">{LL.UPLOAD_TEXT()}.</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<SingleUpload text={LL.UPLOAD_DRAG()} doRestart={doRestart} />
|
<SingleUpload text={LL.UPLOAD_DRAG()} doRestart={doRestart} />
|
||||||
</SectionContent>
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionContent>{restarting ? <SystemMonitor /> : content()}</SectionContent>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
import { useCallback, useMemo, 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,
|
||||||
@@ -33,8 +30,6 @@ import type { MqttSettingsType } from 'types';
|
|||||||
import { numberValue, updateValueDirty, useRest } from 'utils';
|
import { numberValue, updateValueDirty, useRest } from 'utils';
|
||||||
import { createMqttSettingsValidator, validate } from 'validators';
|
import { createMqttSettingsValidator, validate } from 'validators';
|
||||||
|
|
||||||
import { callAction } from '../../api/app';
|
|
||||||
|
|
||||||
const MqttSettings = () => {
|
const MqttSettings = () => {
|
||||||
const {
|
const {
|
||||||
loadData,
|
loadData,
|
||||||
@@ -57,104 +52,48 @@ const MqttSettings = () => {
|
|||||||
|
|
||||||
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
||||||
|
|
||||||
const sendResetMQTT = useCallback(() => {
|
const updateFormValue = updateValueDirty(
|
||||||
void callAction({ action: 'resetMQTT' })
|
origData,
|
||||||
.then(() => {
|
dirtyFlags,
|
||||||
toast.success('MQTT ' + LL.REFRESH() + ' successful');
|
setDirtyFlags,
|
||||||
})
|
updateDataValue as (value: unknown) => void
|
||||||
.catch((error) => {
|
|
||||||
toast.error(String(error.error?.message || 'An error occurred'));
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const updateFormValue = useMemo(
|
|
||||||
() =>
|
|
||||||
updateValueDirty(
|
|
||||||
origData as unknown as Record<string, unknown>,
|
|
||||||
dirtyFlags,
|
|
||||||
setDirtyFlags,
|
|
||||||
updateDataValue as (value: unknown) => void
|
|
||||||
),
|
|
||||||
[origData, dirtyFlags, setDirtyFlags, updateDataValue]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const SecondsInputProps = useMemo(
|
const SecondsInputProps = {
|
||||||
() => ({
|
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
|
||||||
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
|
};
|
||||||
}),
|
|
||||||
[LL]
|
|
||||||
);
|
|
||||||
|
|
||||||
const emptyFieldErrors = useMemo(() => ({}), []);
|
const content = () => {
|
||||||
|
if (!data) {
|
||||||
const validateAndSubmit = useCallback(async () => {
|
return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />;
|
||||||
if (!data) return;
|
|
||||||
try {
|
|
||||||
setFieldErrors(undefined);
|
|
||||||
await validate(createMqttSettingsValidator(data), data);
|
|
||||||
await saveData();
|
|
||||||
} catch (error) {
|
|
||||||
setFieldErrors(error as ValidateFieldsError);
|
|
||||||
}
|
}
|
||||||
}, [data, saveData]);
|
|
||||||
|
|
||||||
const publishIntervalFields = useMemo(
|
const validateAndSubmit = async () => {
|
||||||
() => [
|
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 }
|
|
||||||
],
|
|
||||||
[LL]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!data) {
|
|
||||||
return (
|
return (
|
||||||
<SectionContent>
|
|
||||||
{blocker ? <BlockNavigation blocker={blocker} /> : null}
|
|
||||||
<FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />
|
|
||||||
</SectionContent>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SectionContent>
|
|
||||||
{blocker ? <BlockNavigation blocker={blocker} /> : null}
|
|
||||||
<>
|
<>
|
||||||
<Box display="flex" gap={2} mb={1}>
|
<BlockFormControlLabel
|
||||||
<BlockFormControlLabel
|
control={
|
||||||
control={
|
<Checkbox
|
||||||
<Checkbox
|
name="enabled"
|
||||||
name="enabled"
|
checked={data.enabled}
|
||||||
checked={data.enabled}
|
onChange={updateFormValue}
|
||||||
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 ?? emptyFieldErrors}
|
fieldErrors={fieldErrors || {}}
|
||||||
name="host"
|
name="host"
|
||||||
label={LL.ADDRESS_OF(LL.BROKER())}
|
label={LL.ADDRESS_OF(LL.BROKER())}
|
||||||
multiline
|
multiline
|
||||||
@@ -166,7 +105,7 @@ const MqttSettings = () => {
|
|||||||
</Grid>
|
</Grid>
|
||||||
<Grid>
|
<Grid>
|
||||||
<ValidatedTextField
|
<ValidatedTextField
|
||||||
fieldErrors={fieldErrors ?? emptyFieldErrors}
|
fieldErrors={fieldErrors || {}}
|
||||||
name="port"
|
name="port"
|
||||||
label="Port"
|
label="Port"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
@@ -178,7 +117,7 @@ const MqttSettings = () => {
|
|||||||
</Grid>
|
</Grid>
|
||||||
<Grid>
|
<Grid>
|
||||||
<ValidatedTextField
|
<ValidatedTextField
|
||||||
fieldErrors={fieldErrors ?? emptyFieldErrors}
|
fieldErrors={fieldErrors || {}}
|
||||||
name="base"
|
name="base"
|
||||||
label={LL.BASE_TOPIC()}
|
label={LL.BASE_TOPIC()}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
@@ -190,7 +129,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}
|
||||||
@@ -219,7 +158,7 @@ const MqttSettings = () => {
|
|||||||
</Grid>
|
</Grid>
|
||||||
<Grid>
|
<Grid>
|
||||||
<ValidatedTextField
|
<ValidatedTextField
|
||||||
fieldErrors={fieldErrors ?? emptyFieldErrors}
|
fieldErrors={fieldErrors || {}}
|
||||||
name="keep_alive"
|
name="keep_alive"
|
||||||
label="Keep Alive"
|
label="Keep Alive"
|
||||||
slotProps={{
|
slotProps={{
|
||||||
@@ -266,7 +205,6 @@ 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"
|
||||||
/>
|
/>
|
||||||
@@ -398,17 +336,13 @@ const MqttSettings = () => {
|
|||||||
>
|
>
|
||||||
<MenuItem value={0}>{LL.MQTT_ENTITY_FORMAT_0()}</MenuItem>
|
<MenuItem value={0}>{LL.MQTT_ENTITY_FORMAT_0()}</MenuItem>
|
||||||
<MenuItem value={3}>
|
<MenuItem value={3}>
|
||||||
{LL.MQTT_ENTITY_FORMAT_1()} (v3.5)
|
{LL.MQTT_ENTITY_FORMAT_1()} (v3.6)
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem value={4}>
|
<MenuItem value={4}>
|
||||||
{LL.MQTT_ENTITY_FORMAT_2()} (v3.5)
|
{LL.MQTT_ENTITY_FORMAT_2()} (v3.6)
|
||||||
</MenuItem>
|
|
||||||
<MenuItem value={1}>
|
|
||||||
{LL.MQTT_ENTITY_FORMAT_1()} (latest)
|
|
||||||
</MenuItem>
|
|
||||||
<MenuItem value={2}>
|
|
||||||
{LL.MQTT_ENTITY_FORMAT_2()} (latest)
|
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
<MenuItem value={1}>{LL.MQTT_ENTITY_FORMAT_1()}</MenuItem>
|
||||||
|
<MenuItem value={2}>{LL.MQTT_ENTITY_FORMAT_2()}</MenuItem>
|
||||||
</TextField>
|
</TextField>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
@@ -418,42 +352,119 @@ const MqttSettings = () => {
|
|||||||
{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}>
|
||||||
{publishIntervalFields.map((field) => (
|
<Grid>
|
||||||
<Grid key={field.name}>
|
<ValidatedTextField
|
||||||
{field.validated ? (
|
fieldErrors={fieldErrors || {}}
|
||||||
<ValidatedTextField
|
name="publish_time_heartbeat"
|
||||||
fieldErrors={fieldErrors ?? emptyFieldErrors}
|
label="Heartbeat"
|
||||||
name={field.name}
|
slotProps={{
|
||||||
label={field.label}
|
input: SecondsInputProps
|
||||||
slotProps={{
|
}}
|
||||||
input: SecondsInputProps
|
variant="outlined"
|
||||||
}}
|
value={numberValue(data.publish_time_heartbeat)}
|
||||||
variant="outlined"
|
type="number"
|
||||||
value={numberValue(
|
onChange={updateFormValue}
|
||||||
data[field.name as keyof MqttSettingsType] as number
|
margin="normal"
|
||||||
)}
|
/>
|
||||||
type="number"
|
</Grid>
|
||||||
onChange={updateFormValue}
|
<Grid>
|
||||||
margin="normal"
|
<TextField
|
||||||
/>
|
name="publish_time_boiler"
|
||||||
) : (
|
label={LL.MQTT_INT_BOILER()}
|
||||||
<TextField
|
variant="outlined"
|
||||||
name={field.name}
|
value={numberValue(data.publish_time_boiler)}
|
||||||
label={field.label}
|
type="number"
|
||||||
variant="outlined"
|
onChange={updateFormValue}
|
||||||
value={numberValue(
|
margin="normal"
|
||||||
data[field.name as keyof MqttSettingsType] as number
|
slotProps={{
|
||||||
)}
|
input: SecondsInputProps
|
||||||
type="number"
|
}}
|
||||||
onChange={updateFormValue}
|
/>
|
||||||
margin="normal"
|
</Grid>
|
||||||
slotProps={{
|
<Grid>
|
||||||
input: SecondsInputProps
|
<TextField
|
||||||
}}
|
name="publish_time_thermostat"
|
||||||
/>
|
label={LL.MQTT_INT_THERMOSTATS()}
|
||||||
)}
|
variant="outlined"
|
||||||
</Grid>
|
value={numberValue(data.publish_time_thermostat)}
|
||||||
))}
|
type="number"
|
||||||
|
onChange={updateFormValue}
|
||||||
|
margin="normal"
|
||||||
|
slotProps={{
|
||||||
|
input: SecondsInputProps
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid>
|
||||||
|
<TextField
|
||||||
|
name="publish_time_solar"
|
||||||
|
label={LL.MQTT_INT_SOLAR()}
|
||||||
|
variant="outlined"
|
||||||
|
value={numberValue(data.publish_time_solar)}
|
||||||
|
type="number"
|
||||||
|
onChange={updateFormValue}
|
||||||
|
margin="normal"
|
||||||
|
slotProps={{
|
||||||
|
input: SecondsInputProps
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid>
|
||||||
|
<TextField
|
||||||
|
name="publish_time_mixer"
|
||||||
|
label={LL.MQTT_INT_MIXER()}
|
||||||
|
variant="outlined"
|
||||||
|
value={numberValue(data.publish_time_mixer)}
|
||||||
|
type="number"
|
||||||
|
onChange={updateFormValue}
|
||||||
|
margin="normal"
|
||||||
|
slotProps={{
|
||||||
|
input: SecondsInputProps
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid>
|
||||||
|
<TextField
|
||||||
|
name="publish_time_water"
|
||||||
|
label={LL.MQTT_INT_WATER()}
|
||||||
|
variant="outlined"
|
||||||
|
value={numberValue(data.publish_time_water)}
|
||||||
|
type="number"
|
||||||
|
onChange={updateFormValue}
|
||||||
|
margin="normal"
|
||||||
|
slotProps={{
|
||||||
|
input: SecondsInputProps
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid>
|
||||||
|
<TextField
|
||||||
|
name="publish_time_sensor"
|
||||||
|
label={LL.SENSORS()}
|
||||||
|
variant="outlined"
|
||||||
|
value={numberValue(data.publish_time_sensor)}
|
||||||
|
type="number"
|
||||||
|
onChange={updateFormValue}
|
||||||
|
margin="normal"
|
||||||
|
slotProps={{
|
||||||
|
input: SecondsInputProps
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid>
|
||||||
|
<TextField
|
||||||
|
name="publish_time_other"
|
||||||
|
label={LL.DEFAULT(0)}
|
||||||
|
variant="outlined"
|
||||||
|
value={numberValue(data.publish_time_other)}
|
||||||
|
type="number"
|
||||||
|
onChange={updateFormValue}
|
||||||
|
margin="normal"
|
||||||
|
slotProps={{
|
||||||
|
input: SecondsInputProps
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
{dirtyFlags && dirtyFlags.length !== 0 && (
|
{dirtyFlags && dirtyFlags.length !== 0 && (
|
||||||
<ButtonRow>
|
<ButtonRow>
|
||||||
@@ -480,6 +491,13 @@ const MqttSettings = () => {
|
|||||||
</ButtonRow>
|
</ButtonRow>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionContent>
|
||||||
|
{blocker ? <BlockNavigation blocker={blocker} /> : null}
|
||||||
|
{content()}
|
||||||
</SectionContent>
|
</SectionContent>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useMemo, useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
||||||
@@ -39,7 +39,7 @@ import { formatLocalDateTime, updateValueDirty, useRest } from 'utils';
|
|||||||
import { validate } from 'validators';
|
import { validate } from 'validators';
|
||||||
import { NTP_SETTINGS_VALIDATOR } from 'validators/ntp';
|
import { NTP_SETTINGS_VALIDATOR } from 'validators/ntp';
|
||||||
|
|
||||||
import { TIME_ZONES, selectedTimeZone, useTimeZoneSelectItems } from './TZ';
|
import { TIME_ZONES, selectedTimeZone, timeZoneSelectItems } from './TZ';
|
||||||
|
|
||||||
const NTPSettings = () => {
|
const NTPSettings = () => {
|
||||||
const {
|
const {
|
||||||
@@ -61,19 +61,9 @@ const NTPSettings = () => {
|
|||||||
const { LL } = useI18nContext();
|
const { LL } = useI18nContext();
|
||||||
useLayoutTitle('NTP');
|
useLayoutTitle('NTP');
|
||||||
|
|
||||||
// Memoized timezone select items for better performance
|
|
||||||
const timeZoneItems = useTimeZoneSelectItems();
|
|
||||||
|
|
||||||
// Memoized selected timezone value
|
|
||||||
const selectedTzValue = useMemo(
|
|
||||||
() => (data ? selectedTimeZone(data.tz_label, data.tz_format) : undefined),
|
|
||||||
[data?.tz_label, data?.tz_format]
|
|
||||||
);
|
|
||||||
|
|
||||||
const [localTime, setLocalTime] = useState<string>('');
|
const [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),
|
||||||
@@ -82,79 +72,110 @@ const NTPSettings = () => {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Memoize updateFormValue to prevent recreation on every render
|
const updateFormValue = updateValueDirty(
|
||||||
const updateFormValue = useMemo(
|
origData,
|
||||||
() =>
|
dirtyFlags,
|
||||||
updateValueDirty(
|
setDirtyFlags,
|
||||||
origData as unknown as Record<string, unknown>,
|
updateDataValue as (value: unknown) => void
|
||||||
dirtyFlags,
|
|
||||||
setDirtyFlags,
|
|
||||||
updateDataValue as (value: unknown) => void
|
|
||||||
),
|
|
||||||
[origData, dirtyFlags, setDirtyFlags, updateDataValue]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Memoize updateLocalTime handler
|
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
||||||
const updateLocalTime = useCallback(
|
|
||||||
(event: React.ChangeEvent<HTMLInputElement>) => setLocalTime(event.target.value),
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Memoize openSetTime handler
|
const updateLocalTime = (event: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
const openSetTime = useCallback(() => {
|
setLocalTime(event.target.value);
|
||||||
|
|
||||||
|
const openSetTime = () => {
|
||||||
setLocalTime(formatLocalDateTime(new Date()));
|
setLocalTime(formatLocalDateTime(new Date()));
|
||||||
setSettingTime(true);
|
setSettingTime(true);
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
// Memoize configureTime handler
|
const configureTime = async () => {
|
||||||
const configureTime = useCallback(async () => {
|
|
||||||
setProcessing(true);
|
setProcessing(true);
|
||||||
|
|
||||||
try {
|
await updateTime({ local_time: formatLocalDateTime(new Date(localTime)) })
|
||||||
await updateTime({ local_time: formatLocalDateTime(new Date(localTime)) });
|
.then(async () => {
|
||||||
toast.success(LL.TIME_SET());
|
toast.success(LL.TIME_SET());
|
||||||
setSettingTime(false);
|
setSettingTime(false);
|
||||||
await loadData();
|
await loadData();
|
||||||
} catch {
|
})
|
||||||
toast.error(LL.PROBLEM_UPDATING());
|
.catch(() => {
|
||||||
} finally {
|
toast.error(LL.PROBLEM_UPDATING());
|
||||||
setProcessing(false);
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setProcessing(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderSetTimeDialog = () => (
|
||||||
|
<Dialog
|
||||||
|
sx={dialogStyle}
|
||||||
|
open={settingTime}
|
||||||
|
onClose={() => setSettingTime(false)}
|
||||||
|
>
|
||||||
|
<DialogTitle>{LL.SET_TIME(1)}</DialogTitle>
|
||||||
|
<DialogContent dividers>
|
||||||
|
<Box color="warning.main" p={0} pl={0} pr={0} mt={0} mb={2}>
|
||||||
|
<Typography variant="body2">{LL.SET_TIME_TEXT()}</Typography>
|
||||||
|
</Box>
|
||||||
|
<TextField
|
||||||
|
label={LL.LOCAL_TIME(0)}
|
||||||
|
type="datetime-local"
|
||||||
|
value={localTime}
|
||||||
|
onChange={updateLocalTime}
|
||||||
|
disabled={processing}
|
||||||
|
fullWidth
|
||||||
|
slotProps={{
|
||||||
|
inputLabel: {
|
||||||
|
shrink: true
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button
|
||||||
|
startIcon={<CancelIcon />}
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => setSettingTime(false)}
|
||||||
|
color="secondary"
|
||||||
|
>
|
||||||
|
{LL.CANCEL()}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
startIcon={<AccessTimeIcon />}
|
||||||
|
variant="outlined"
|
||||||
|
onClick={configureTime}
|
||||||
|
disabled={processing}
|
||||||
|
color="primary"
|
||||||
|
>
|
||||||
|
{LL.UPDATE()}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
|
||||||
|
const content = () => {
|
||||||
|
if (!data) {
|
||||||
|
return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />;
|
||||||
}
|
}
|
||||||
}, [localTime, updateTime, LL, loadData]);
|
|
||||||
|
|
||||||
// Memoize close dialog handler
|
const validateAndSubmit = async () => {
|
||||||
const handleCloseSetTime = useCallback(() => setSettingTime(false), []);
|
try {
|
||||||
|
setFieldErrors(undefined);
|
||||||
|
await validate(NTP_SETTINGS_VALIDATOR, data);
|
||||||
|
await saveData();
|
||||||
|
} catch (error) {
|
||||||
|
setFieldErrors(error as ValidateFieldsError);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Memoize validate and submit handler
|
const changeTimeZone = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const validateAndSubmit = useCallback(async () => {
|
|
||||||
if (!data) return;
|
|
||||||
try {
|
|
||||||
setFieldErrors(undefined);
|
|
||||||
await validate(NTP_SETTINGS_VALIDATOR, data);
|
|
||||||
await saveData();
|
|
||||||
} catch (error) {
|
|
||||||
setFieldErrors(error as ValidateFieldsError);
|
|
||||||
}
|
|
||||||
}, [data, saveData]);
|
|
||||||
|
|
||||||
// Memoize timezone change handler
|
|
||||||
const changeTimeZone = useCallback(
|
|
||||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
void updateState(readNTPSettings(), (settings: NTPSettingsType) => ({
|
void updateState(readNTPSettings(), (settings: NTPSettingsType) => ({
|
||||||
...settings,
|
...settings,
|
||||||
tz_label: event.target.value,
|
tz_label: event.target.value,
|
||||||
tz_format: TIME_ZONES[event.target.value]
|
tz_format: TIME_ZONES[event.target.value]
|
||||||
}));
|
}));
|
||||||
updateFormValue(event);
|
updateFormValue(event);
|
||||||
},
|
};
|
||||||
[updateFormValue]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Memoize render content to prevent unnecessary re-renders
|
|
||||||
const renderContent = useMemo(() => {
|
|
||||||
if (!data) {
|
|
||||||
return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -184,13 +205,13 @@ const NTPSettings = () => {
|
|||||||
label={LL.TIME_ZONE()}
|
label={LL.TIME_ZONE()}
|
||||||
fullWidth
|
fullWidth
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
value={selectedTzValue}
|
value={selectedTimeZone(data.tz_label, data.tz_format)}
|
||||||
onChange={changeTimeZone}
|
onChange={changeTimeZone}
|
||||||
margin="normal"
|
margin="normal"
|
||||||
select
|
select
|
||||||
>
|
>
|
||||||
<MenuItem disabled>{LL.TIME_ZONE()}...</MenuItem>
|
<MenuItem disabled>{LL.TIME_ZONE()}...</MenuItem>
|
||||||
{timeZoneItems}
|
{timeZoneSelectItems()}
|
||||||
</ValidatedTextField>
|
</ValidatedTextField>
|
||||||
|
|
||||||
<Box display="flex" flexWrap="wrap">
|
<Box display="flex" flexWrap="wrap">
|
||||||
@@ -209,6 +230,7 @@ const NTPSettings = () => {
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
{renderSetTimeDialog()}
|
||||||
|
|
||||||
{dirtyFlags && dirtyFlags.length !== 0 && (
|
{dirtyFlags && dirtyFlags.length !== 0 && (
|
||||||
<ButtonRow>
|
<ButtonRow>
|
||||||
@@ -236,66 +258,12 @@ const NTPSettings = () => {
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}, [
|
};
|
||||||
data,
|
|
||||||
errorMessage,
|
|
||||||
loadData,
|
|
||||||
updateFormValue,
|
|
||||||
fieldErrors,
|
|
||||||
selectedTzValue,
|
|
||||||
changeTimeZone,
|
|
||||||
timeZoneItems,
|
|
||||||
dirtyFlags,
|
|
||||||
openSetTime,
|
|
||||||
saving,
|
|
||||||
validateAndSubmit,
|
|
||||||
LL
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionContent>
|
<SectionContent>
|
||||||
{blocker ? <BlockNavigation blocker={blocker} /> : null}
|
{blocker ? <BlockNavigation blocker={blocker} /> : null}
|
||||||
{renderContent}
|
{content()}
|
||||||
<Dialog sx={dialogStyle} open={settingTime} onClose={handleCloseSetTime}>
|
|
||||||
<DialogTitle>{LL.SET_TIME(1)}</DialogTitle>
|
|
||||||
<DialogContent dividers>
|
|
||||||
<Box color="warning.main" p={0} pl={0} pr={0} mt={0} mb={2}>
|
|
||||||
<Typography variant="body2">{LL.SET_TIME_TEXT()}</Typography>
|
|
||||||
</Box>
|
|
||||||
<TextField
|
|
||||||
label={LL.LOCAL_TIME(0)}
|
|
||||||
type="datetime-local"
|
|
||||||
value={localTime}
|
|
||||||
onChange={updateLocalTime}
|
|
||||||
disabled={processing}
|
|
||||||
fullWidth
|
|
||||||
slotProps={{
|
|
||||||
inputLabel: {
|
|
||||||
shrink: true
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
<Button
|
|
||||||
startIcon={<CancelIcon />}
|
|
||||||
variant="outlined"
|
|
||||||
onClick={handleCloseSetTime}
|
|
||||||
color="secondary"
|
|
||||||
>
|
|
||||||
{LL.CANCEL()}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
startIcon={<AccessTimeIcon />}
|
|
||||||
variant="outlined"
|
|
||||||
onClick={configureTime}
|
|
||||||
disabled={processing}
|
|
||||||
color="primary"
|
|
||||||
>
|
|
||||||
{LL.UPDATE()}
|
|
||||||
</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
</SectionContent>
|
</SectionContent>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useMemo, useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
||||||
import CancelIcon from '@mui/icons-material/Cancel';
|
import CancelIcon from '@mui/icons-material/Cancel';
|
||||||
@@ -30,159 +30,140 @@ import { SectionContent, useLayoutTitle } from 'components';
|
|||||||
import ListMenuItem from 'components/layout/ListMenuItem';
|
import ListMenuItem from 'components/layout/ListMenuItem';
|
||||||
import { useI18nContext } from 'i18n/i18n-react';
|
import { useI18nContext } from 'i18n/i18n-react';
|
||||||
|
|
||||||
import SystemMonitor from '../status/SystemMonitor';
|
|
||||||
|
|
||||||
const Settings = () => {
|
const Settings = () => {
|
||||||
const { LL } = useI18nContext();
|
const { LL } = useI18nContext();
|
||||||
useLayoutTitle(LL.SETTINGS(0));
|
useLayoutTitle(LL.SETTINGS(0));
|
||||||
|
|
||||||
const [confirmFactoryReset, setConfirmFactoryReset] = useState(false);
|
const [confirmFactoryReset, setConfirmFactoryReset] = useState<boolean>(false);
|
||||||
const [restarting, setRestarting] = useState<boolean>();
|
|
||||||
|
|
||||||
const { send: sendAPI } = useRequest((data: APIcall) => API(data), {
|
const { send: sendAPI } = useRequest((data: APIcall) => API(data), {
|
||||||
immediate: false
|
immediate: false
|
||||||
});
|
});
|
||||||
|
|
||||||
const doFormat = useCallback(async () => {
|
const doFormat = async () => {
|
||||||
await sendAPI({ device: 'system', cmd: 'format', id: 0 }).then(() => {
|
await sendAPI({ device: 'system', cmd: 'format', id: 0 }).then(() => {
|
||||||
setRestarting(true);
|
|
||||||
setConfirmFactoryReset(false);
|
setConfirmFactoryReset(false);
|
||||||
});
|
});
|
||||||
}, [sendAPI]);
|
};
|
||||||
|
|
||||||
const handleFactoryResetClose = useCallback(() => {
|
const renderFactoryResetDialog = () => (
|
||||||
setConfirmFactoryReset(false);
|
<Dialog
|
||||||
}, []);
|
sx={dialogStyle}
|
||||||
|
open={confirmFactoryReset}
|
||||||
const handleFactoryResetClick = useCallback(() => {
|
onClose={() => setConfirmFactoryReset(false)}
|
||||||
setConfirmFactoryReset(true);
|
>
|
||||||
}, []);
|
<DialogTitle>{LL.FACTORY_RESET()}</DialogTitle>
|
||||||
|
<DialogContent dividers>{LL.SYSTEM_FACTORY_TEXT_DIALOG()}</DialogContent>
|
||||||
const content = useMemo(() => {
|
<DialogActions>
|
||||||
return (
|
<Button
|
||||||
<>
|
startIcon={<CancelIcon />}
|
||||||
<List>
|
variant="outlined"
|
||||||
<ListMenuItem
|
onClick={() => setConfirmFactoryReset(false)}
|
||||||
icon={TuneIcon}
|
color="secondary"
|
||||||
bgcolor="#134ba2"
|
|
||||||
label={LL.APPLICATION()}
|
|
||||||
text={LL.APPLICATION_SETTINGS_1()}
|
|
||||||
to="application"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ListMenuItem
|
|
||||||
icon={SettingsEthernetIcon}
|
|
||||||
bgcolor="#40828f"
|
|
||||||
label={LL.NETWORK(0)}
|
|
||||||
text={LL.CONFIGURE(LL.SETTINGS_OF(LL.NETWORK(1)))}
|
|
||||||
to="network"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ListMenuItem
|
|
||||||
icon={SettingsInputAntennaIcon}
|
|
||||||
bgcolor="#5f9a5f"
|
|
||||||
label={LL.ACCESS_POINT(0)}
|
|
||||||
text={LL.CONFIGURE(LL.ACCESS_POINT(1))}
|
|
||||||
to="ap"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ListMenuItem
|
|
||||||
icon={AccessTimeIcon}
|
|
||||||
bgcolor="#c5572c"
|
|
||||||
label="NTP"
|
|
||||||
text={LL.CONFIGURE(LL.LOCAL_TIME(1))}
|
|
||||||
to="ntp"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ListMenuItem
|
|
||||||
icon={DeviceHubIcon}
|
|
||||||
bgcolor="#68374d"
|
|
||||||
label="MQTT"
|
|
||||||
text={LL.CONFIGURE('MQTT')}
|
|
||||||
to="mqtt"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ListMenuItem
|
|
||||||
icon={LockIcon}
|
|
||||||
label={LL.SECURITY(0)}
|
|
||||||
text={LL.SECURITY_1()}
|
|
||||||
to="security"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ListMenuItem
|
|
||||||
icon={ViewModuleIcon}
|
|
||||||
bgcolor="#efc34b"
|
|
||||||
label={LL.MODULES()}
|
|
||||||
text={LL.MODULES_1()}
|
|
||||||
to="modules"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ListMenuItem
|
|
||||||
icon={ImportExportIcon}
|
|
||||||
bgcolor="#5d89f7"
|
|
||||||
label={LL.DOWNLOAD_UPLOAD()}
|
|
||||||
text={LL.DOWNLOAD_UPLOAD_1()}
|
|
||||||
to="downloadUpload"
|
|
||||||
/>
|
|
||||||
</List>
|
|
||||||
|
|
||||||
<Dialog
|
|
||||||
sx={dialogStyle}
|
|
||||||
open={confirmFactoryReset}
|
|
||||||
onClose={handleFactoryResetClose}
|
|
||||||
>
|
>
|
||||||
<DialogTitle>{LL.FACTORY_RESET()}</DialogTitle>
|
{LL.CANCEL()}
|
||||||
<DialogContent dividers>{LL.SYSTEM_FACTORY_TEXT_DIALOG()}</DialogContent>
|
</Button>
|
||||||
<DialogActions>
|
<Button
|
||||||
<Button
|
startIcon={<SettingsBackupRestoreIcon />}
|
||||||
startIcon={<CancelIcon />}
|
variant="outlined"
|
||||||
variant="outlined"
|
onClick={doFormat}
|
||||||
onClick={handleFactoryResetClose}
|
color="error"
|
||||||
color="secondary"
|
|
||||||
>
|
|
||||||
{LL.CANCEL()}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
startIcon={<SettingsBackupRestoreIcon />}
|
|
||||||
variant="outlined"
|
|
||||||
onClick={doFormat}
|
|
||||||
color="error"
|
|
||||||
>
|
|
||||||
{LL.FACTORY_RESET()}
|
|
||||||
</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<Divider />
|
|
||||||
|
|
||||||
<Box
|
|
||||||
mt={2}
|
|
||||||
display="flex"
|
|
||||||
justifyContent="flex-end"
|
|
||||||
flexWrap="nowrap"
|
|
||||||
whiteSpace="nowrap"
|
|
||||||
>
|
>
|
||||||
<Button
|
{LL.FACTORY_RESET()}
|
||||||
startIcon={<SettingsBackupRestoreIcon />}
|
</Button>
|
||||||
variant="outlined"
|
</DialogActions>
|
||||||
onClick={handleFactoryResetClick}
|
</Dialog>
|
||||||
color="error"
|
);
|
||||||
>
|
|
||||||
{LL.FACTORY_RESET()}
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}, [
|
|
||||||
LL,
|
|
||||||
handleFactoryResetClick,
|
|
||||||
handleFactoryResetClose,
|
|
||||||
doFormat,
|
|
||||||
confirmFactoryReset,
|
|
||||||
restarting
|
|
||||||
]);
|
|
||||||
|
|
||||||
return <SectionContent>{restarting ? <SystemMonitor /> : content}</SectionContent>;
|
return (
|
||||||
|
<SectionContent>
|
||||||
|
<List>
|
||||||
|
<ListMenuItem
|
||||||
|
icon={TuneIcon}
|
||||||
|
bgcolor="#134ba2"
|
||||||
|
label={LL.APPLICATION()}
|
||||||
|
text={LL.APPLICATION_SETTINGS_1()}
|
||||||
|
to="application"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ListMenuItem
|
||||||
|
icon={SettingsEthernetIcon}
|
||||||
|
bgcolor="#40828f"
|
||||||
|
label={LL.NETWORK(0)}
|
||||||
|
text={LL.CONFIGURE(LL.SETTINGS_OF(LL.NETWORK(1)))}
|
||||||
|
to="network"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ListMenuItem
|
||||||
|
icon={SettingsInputAntennaIcon}
|
||||||
|
bgcolor="#5f9a5f"
|
||||||
|
label={LL.ACCESS_POINT(0)}
|
||||||
|
text={LL.CONFIGURE(LL.ACCESS_POINT(1))}
|
||||||
|
to="ap"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ListMenuItem
|
||||||
|
icon={AccessTimeIcon}
|
||||||
|
bgcolor="#c5572c"
|
||||||
|
label="NTP"
|
||||||
|
text={LL.CONFIGURE(LL.LOCAL_TIME(1))}
|
||||||
|
to="ntp"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ListMenuItem
|
||||||
|
icon={DeviceHubIcon}
|
||||||
|
bgcolor="#68374d"
|
||||||
|
label="MQTT"
|
||||||
|
text={LL.CONFIGURE('MQTT')}
|
||||||
|
to="mqtt"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ListMenuItem
|
||||||
|
icon={LockIcon}
|
||||||
|
label={LL.SECURITY(0)}
|
||||||
|
text={LL.SECURITY_1()}
|
||||||
|
to="security"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ListMenuItem
|
||||||
|
icon={ViewModuleIcon}
|
||||||
|
bgcolor="#efc34b"
|
||||||
|
label={LL.MODULES()}
|
||||||
|
text={LL.MODULES_1()}
|
||||||
|
to="modules"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ListMenuItem
|
||||||
|
icon={ImportExportIcon}
|
||||||
|
bgcolor="#5d89f7"
|
||||||
|
label={LL.DOWNLOAD_UPLOAD()}
|
||||||
|
text={LL.DOWNLOAD_UPLOAD_1()}
|
||||||
|
to="downloadUpload"
|
||||||
|
/>
|
||||||
|
</List>
|
||||||
|
|
||||||
|
{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>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Settings;
|
export default Settings;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useMemo } from 'react';
|
|
||||||
|
|
||||||
import { MenuItem } from '@mui/material';
|
import { MenuItem } from '@mui/material';
|
||||||
|
|
||||||
export const TIME_ZONES: Record<string, string> = {
|
type TimeZones = 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,33 +465,14 @@ export const TIME_ZONES: Record<string, string> = {
|
|||||||
'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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Memoized version for use in components
|
|
||||||
export function useTimeZoneSelectItems() {
|
|
||||||
return useMemo(
|
|
||||||
() =>
|
|
||||||
TIME_ZONE_LABELS.map((label) => (
|
|
||||||
<MenuItem key={label} value={label}>
|
|
||||||
{label}
|
|
||||||
</MenuItem>
|
|
||||||
)),
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback export for backward compatibility - now memoized
|
|
||||||
const precomputedTimeZoneItems = TIME_ZONE_LABELS.map((label) => (
|
|
||||||
<MenuItem key={label} value={label}>
|
|
||||||
{label}
|
|
||||||
</MenuItem>
|
|
||||||
));
|
|
||||||
|
|
||||||
export function timeZoneSelectItems() {
|
export function timeZoneSelectItems() {
|
||||||
return precomputedTimeZoneItems;
|
return Object.keys(TIME_ZONES).map((label) => (
|
||||||
|
<MenuItem key={label} value={label}>
|
||||||
|
{label}
|
||||||
|
</MenuItem>
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { memo, useCallback, useMemo, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Navigate,
|
Navigate,
|
||||||
Route,
|
Route,
|
||||||
@@ -28,7 +28,8 @@ 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 /> }
|
||||||
],
|
],
|
||||||
@@ -52,17 +53,14 @@ const Network = () => {
|
|||||||
setSelectedNetwork(undefined);
|
setSelectedNetwork(undefined);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const contextValue = useMemo(
|
|
||||||
() => ({
|
|
||||||
...(selectedNetwork && { selectedNetwork }),
|
|
||||||
selectNetwork,
|
|
||||||
deselectNetwork
|
|
||||||
}),
|
|
||||||
[selectedNetwork, selectNetwork, deselectNetwork]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WiFiConnectionContext.Provider value={contextValue}>
|
<WiFiConnectionContext.Provider
|
||||||
|
value={{
|
||||||
|
...(selectedNetwork && { selectedNetwork }),
|
||||||
|
selectNetwork,
|
||||||
|
deselectNetwork
|
||||||
|
}}
|
||||||
|
>
|
||||||
<RouterTabs value={routerTab}>
|
<RouterTabs value={routerTab}>
|
||||||
<Tab
|
<Tab
|
||||||
value="/settings/network/settings"
|
value="/settings/network/settings"
|
||||||
@@ -82,4 +80,4 @@ const Network = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default memo(Network);
|
export default Network;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { memo, useCallback, useContext, useEffect, useState } from 'react';
|
import { 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';
|
||||||
@@ -109,37 +109,38 @@ const NetworkSettings = () => {
|
|||||||
|
|
||||||
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
||||||
|
|
||||||
const validateAndSubmit = useCallback(async () => {
|
useEffect(() => deselectNetwork, [deselectNetwork]);
|
||||||
if (!data) return;
|
|
||||||
try {
|
|
||||||
setFieldErrors(undefined);
|
|
||||||
await validate(createNetworkSettingsValidator(data), data);
|
|
||||||
await saveData();
|
|
||||||
} catch (error) {
|
|
||||||
setFieldErrors(error as ValidateFieldsError);
|
|
||||||
}
|
|
||||||
deselectNetwork();
|
|
||||||
}, [data, saveData, deselectNetwork]);
|
|
||||||
|
|
||||||
const setCancel = useCallback(async () => {
|
|
||||||
deselectNetwork();
|
|
||||||
await loadData();
|
|
||||||
}, [deselectNetwork, loadData]);
|
|
||||||
|
|
||||||
const doRestart = useCallback(async () => {
|
|
||||||
setRestarting(true);
|
|
||||||
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
|
|
||||||
(error: Error) => {
|
|
||||||
toast.error(error.message);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}, [sendAPI]);
|
|
||||||
|
|
||||||
const content = () => {
|
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">
|
||||||
@@ -164,7 +165,7 @@ const NetworkSettings = () => {
|
|||||||
selectedNetwork.bssid
|
selectedNetwork.bssid
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<IconButton onClick={setCancel} aria-label={LL.CANCEL()}>
|
<IconButton onClick={setCancel}>
|
||||||
<DeleteIcon />
|
<DeleteIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
@@ -355,9 +356,8 @@ const NetworkSettings = () => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{restartNeeded && (
|
{restartNeeded && (
|
||||||
<MessageBox level="warning" message={LL.RESTART_TEXT(0)}>
|
<MessageBox my={2} level="warning" message={LL.RESTART_TEXT(0)}>
|
||||||
<Button
|
<Button
|
||||||
sx={{ ml: 2 }}
|
|
||||||
startIcon={<PowerSettingsNewIcon />}
|
startIcon={<PowerSettingsNewIcon />}
|
||||||
variant="contained"
|
variant="contained"
|
||||||
color="error"
|
color="error"
|
||||||
@@ -405,4 +405,4 @@ const NetworkSettings = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default memo(NetworkSettings);
|
export default NetworkSettings;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { memo, useCallback, useRef, useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
|
|
||||||
import PermScanWifiIcon from '@mui/icons-material/PermScanWifi';
|
import PermScanWifiIcon from '@mui/icons-material/PermScanWifi';
|
||||||
import { Button } from '@mui/material';
|
import { Button } from '@mui/material';
|
||||||
@@ -48,12 +48,12 @@ const WiFiNetworkScanner = () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const renderNetworkScanner = useCallback(() => {
|
const renderNetworkScanner = () => {
|
||||||
if (!networkList) {
|
if (!networkList) {
|
||||||
return <FormLoader errorMessage={errorMessage || ''} />;
|
return <FormLoader errorMessage={errorMessage || ''} />;
|
||||||
}
|
}
|
||||||
return <WiFiNetworkSelector networkList={networkList} />;
|
return <WiFiNetworkSelector networkList={networkList} />;
|
||||||
}, [networkList, errorMessage]);
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionContent>
|
<SectionContent>
|
||||||
@@ -73,4 +73,4 @@ const WiFiNetworkScanner = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default memo(WiFiNetworkScanner);
|
export default WiFiNetworkScanner;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { memo, useCallback, useContext } from 'react';
|
import { useContext } from 'react';
|
||||||
|
|
||||||
import LockIcon from '@mui/icons-material/Lock';
|
import LockIcon from '@mui/icons-material/Lock';
|
||||||
import LockOpenIcon from '@mui/icons-material/LockOpen';
|
import LockOpenIcon from '@mui/icons-material/LockOpen';
|
||||||
@@ -63,41 +63,38 @@ const WiFiNetworkSelector = ({ networkList }: { networkList: WiFiNetworkList })
|
|||||||
|
|
||||||
const wifiConnectionContext = useContext(WiFiConnectionContext);
|
const wifiConnectionContext = useContext(WiFiConnectionContext);
|
||||||
|
|
||||||
const renderNetwork = useCallback(
|
const renderNetwork = (network: WiFiNetwork) => (
|
||||||
(network: WiFiNetwork) => (
|
<ListItem
|
||||||
<ListItem
|
key={network.bssid}
|
||||||
key={network.bssid}
|
onClick={() => wifiConnectionContext.selectNetwork(network)}
|
||||||
onClick={() => wifiConnectionContext.selectNetwork(network)}
|
>
|
||||||
>
|
<ListItemAvatar>
|
||||||
<ListItemAvatar>
|
<Avatar>{isNetworkOpen(network) ? <LockOpenIcon /> : <LockIcon />}</Avatar>
|
||||||
<Avatar>{isNetworkOpen(network) ? <LockOpenIcon /> : <LockIcon />}</Avatar>
|
</ListItemAvatar>
|
||||||
</ListItemAvatar>
|
<ListItemText
|
||||||
<ListItemText
|
primary={network.ssid}
|
||||||
primary={network.ssid}
|
secondary={
|
||||||
secondary={
|
'Security: ' +
|
||||||
'Security: ' +
|
networkSecurityMode(network) +
|
||||||
networkSecurityMode(network) +
|
', Ch: ' +
|
||||||
', Ch: ' +
|
network.channel +
|
||||||
network.channel +
|
', bssid: ' +
|
||||||
', bssid: ' +
|
network.bssid
|
||||||
network.bssid
|
}
|
||||||
}
|
/>
|
||||||
/>
|
<ListItemIcon>
|
||||||
<ListItemIcon>
|
<Badge badgeContent={network.rssi + 'dBm'}>
|
||||||
<Badge badgeContent={network.rssi + 'dBm'}>
|
<WifiIcon sx={{ color: networkQualityHighlight(network, theme) }} />
|
||||||
<WifiIcon sx={{ color: networkQualityHighlight(network, theme) }} />
|
</Badge>
|
||||||
</Badge>
|
</ListItemIcon>
|
||||||
</ListItemIcon>
|
</ListItem>
|
||||||
</ListItem>
|
|
||||||
),
|
|
||||||
[wifiConnectionContext, theme]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (networkList.networks.length === 0) {
|
if (networkList.networks.length === 0) {
|
||||||
return <MessageBox message={LL.NETWORK_NO_WIFI()} level="info" />;
|
return <MessageBox mt={2} mb={1} message={LL.NETWORK_NO_WIFI()} level="info" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <List>{networkList.networks.map(renderNetwork)}</List>;
|
return <List>{networkList.networks.map(renderNetwork)}</List>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default memo(WiFiNetworkSelector);
|
export default WiFiNetworkSelector;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { memo, useEffect } from 'react';
|
import { 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, generateToken]);
|
}, [open]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
@@ -86,4 +86,4 @@ const GenerateToken = ({ username, onClose }: GenerateTokenProps) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default memo(GenerateToken);
|
export default GenerateToken;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { memo, useCallback, useContext, useMemo, useState } from 'react';
|
import { 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';
|
||||||
@@ -55,16 +55,14 @@ const ManageUsers = () => {
|
|||||||
const blocker = useBlocker(changed !== 0);
|
const blocker = useBlocker(changed !== 0);
|
||||||
const { LL } = useI18nContext();
|
const { LL } = useI18nContext();
|
||||||
|
|
||||||
const table_theme = useMemo(
|
const table_theme = useTheme({
|
||||||
() =>
|
Table: `
|
||||||
useTheme({
|
|
||||||
Table: `
|
|
||||||
--data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) minmax(120px, max-content) 120px;
|
--data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) minmax(120px, max-content) 120px;
|
||||||
`,
|
`,
|
||||||
BaseRow: `
|
BaseRow: `
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
`,
|
`,
|
||||||
HeaderRow: `
|
HeaderRow: `
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
background-color: black;
|
background-color: black;
|
||||||
color: #90CAF9;
|
color: #90CAF9;
|
||||||
@@ -74,7 +72,7 @@ const ManageUsers = () => {
|
|||||||
border-bottom: 1px solid #565656;
|
border-bottom: 1px solid #565656;
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
Row: `
|
Row: `
|
||||||
.td {
|
.td {
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
border-top: 1px solid #565656;
|
border-top: 1px solid #565656;
|
||||||
@@ -87,7 +85,7 @@ const ManageUsers = () => {
|
|||||||
background-color: #1e1e1e;
|
background-color: #1e1e1e;
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
BaseCell: `
|
BaseCell: `
|
||||||
&:nth-of-type(2) {
|
&:nth-of-type(2) {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
@@ -95,81 +93,72 @@ const ManageUsers = () => {
|
|||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
}),
|
});
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const noAdminConfigured = useCallback(
|
|
||||||
() => !data?.users.find((u) => u.admin),
|
|
||||||
[data]
|
|
||||||
);
|
|
||||||
|
|
||||||
const removeUser = useCallback(
|
|
||||||
(toRemove: UserType) => {
|
|
||||||
if (!data) return;
|
|
||||||
const users = data.users.filter((u) => u.username !== toRemove.username);
|
|
||||||
updateDataValue({ ...data, users });
|
|
||||||
setChanged(changed + 1);
|
|
||||||
},
|
|
||||||
[data, updateDataValue, changed]
|
|
||||||
);
|
|
||||||
|
|
||||||
const createUser = useCallback(() => {
|
|
||||||
setCreating(true);
|
|
||||||
setUser({
|
|
||||||
username: '',
|
|
||||||
password: '',
|
|
||||||
admin: true
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const editUser = useCallback((toEdit: UserType) => {
|
|
||||||
setCreating(false);
|
|
||||||
setUser({ ...toEdit });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const cancelEditingUser = useCallback(() => {
|
|
||||||
setUser(undefined);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const doneEditingUser = useCallback(() => {
|
|
||||||
if (user && data) {
|
|
||||||
const users = [
|
|
||||||
...data.users.filter(
|
|
||||||
(u: { username: string }) => u.username !== user.username
|
|
||||||
),
|
|
||||||
user
|
|
||||||
];
|
|
||||||
updateDataValue({ ...data, users });
|
|
||||||
setUser(undefined);
|
|
||||||
setChanged(changed + 1);
|
|
||||||
}
|
|
||||||
}, [user, data, updateDataValue, changed]);
|
|
||||||
|
|
||||||
const closeGenerateToken = useCallback(() => {
|
|
||||||
setGeneratingToken(undefined);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const generateTokenForUser = useCallback((username: string) => {
|
|
||||||
setGeneratingToken(username);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const onSubmit = useCallback(async () => {
|
|
||||||
await saveData();
|
|
||||||
await authenticatedContext.refresh();
|
|
||||||
setChanged(0);
|
|
||||||
}, [saveData, authenticatedContext]);
|
|
||||||
|
|
||||||
const onCancelSubmit = useCallback(async () => {
|
|
||||||
await loadData();
|
|
||||||
setChanged(0);
|
|
||||||
}, [loadData]);
|
|
||||||
|
|
||||||
const content = () => {
|
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;
|
||||||
@@ -178,14 +167,10 @@ const ManageUsers = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// add id to the type, needed for the table
|
// add id to the type, needed for the table
|
||||||
const user_table = useMemo(
|
const user_table = data.users.map((u) => ({
|
||||||
() =>
|
...u,
|
||||||
data.users.map((u) => ({
|
id: u.username
|
||||||
...u,
|
})) as UserType2[];
|
||||||
id: u.username
|
|
||||||
})) as UserType2[],
|
|
||||||
[data.users]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -211,24 +196,15 @@ 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={() => generateTokenForUser(u.username)}
|
onClick={() => generateToken(u.username)}
|
||||||
>
|
>
|
||||||
<VpnKeyIcon />
|
<VpnKeyIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<IconButton
|
<IconButton size="small" onClick={() => removeUser(u)}>
|
||||||
size="small"
|
|
||||||
onClick={() => removeUser(u)}
|
|
||||||
aria-label={LL.REMOVE()}
|
|
||||||
>
|
|
||||||
<DeleteIcon />
|
<DeleteIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<IconButton
|
<IconButton size="small" onClick={() => editUser(u)}>
|
||||||
size="small"
|
|
||||||
onClick={() => editUser(u)}
|
|
||||||
aria-label={LL.EDIT()}
|
|
||||||
>
|
|
||||||
<EditIcon />
|
<EditIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Cell>
|
</Cell>
|
||||||
@@ -310,4 +286,4 @@ const ManageUsers = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default memo(ManageUsers);
|
export default ManageUsers;
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { memo, useMemo } 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';
|
||||||
@@ -13,21 +12,12 @@ const Security = () => {
|
|||||||
const { LL } = useI18nContext();
|
const { LL } = useI18nContext();
|
||||||
useLayoutTitle(LL.SECURITY(0));
|
useLayoutTitle(LL.SECURITY(0));
|
||||||
|
|
||||||
const location = useLocation();
|
const matchedRoutes = matchRoutes(
|
||||||
|
[
|
||||||
const matchedRoutes = useMemo(
|
{ path: '/settings/security/settings', element: <ManageUsers />, dog: 'woof' },
|
||||||
() =>
|
{ path: '/settings/security/users', element: <SecuritySettings /> }
|
||||||
matchRoutes(
|
],
|
||||||
[
|
useLocation()
|
||||||
{
|
|
||||||
path: '/settings/security/settings',
|
|
||||||
element: <ManageUsers />
|
|
||||||
},
|
|
||||||
{ path: '/settings/security/users', element: <SecuritySettings /> }
|
|
||||||
],
|
|
||||||
location
|
|
||||||
),
|
|
||||||
[location]
|
|
||||||
);
|
);
|
||||||
const routerTab = matchedRoutes?.[0]?.route.path || false;
|
const routerTab = matchedRoutes?.[0]?.route.path || false;
|
||||||
|
|
||||||
@@ -52,4 +42,4 @@ const Security = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default memo(Security);
|
export default Security;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { memo, useCallback, useContext, useState } from 'react';
|
import { 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';
|
||||||
@@ -44,29 +44,28 @@ const SecuritySettings = () => {
|
|||||||
const authenticatedContext = useContext(AuthenticatedContext);
|
const authenticatedContext = useContext(AuthenticatedContext);
|
||||||
|
|
||||||
const updateFormValue = updateValueDirty(
|
const updateFormValue = updateValueDirty(
|
||||||
origData as unknown as Record<string, unknown>,
|
origData,
|
||||||
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 ValidateFieldsError);
|
|
||||||
}
|
|
||||||
}, [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
|
||||||
@@ -116,4 +115,4 @@ const SecuritySettings = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default memo(SecuritySettings);
|
export default SecuritySettings;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { memo, useCallback, useEffect, useState } from 'react';
|
import { 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';
|
||||||
@@ -45,14 +45,7 @@ const User: FC<UserFormProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const { LL } = useI18nContext();
|
const { LL } = useI18nContext();
|
||||||
|
|
||||||
const updateFormValue = updateValue((updater) => {
|
const updateFormValue = updateValue(setUser);
|
||||||
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 +55,7 @@ const User: FC<UserFormProps> = ({
|
|||||||
}
|
}
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
const validateAndDone = useCallback(async () => {
|
const validateAndDone = async () => {
|
||||||
if (user) {
|
if (user) {
|
||||||
try {
|
try {
|
||||||
setFieldErrors(undefined);
|
setFieldErrors(undefined);
|
||||||
@@ -72,7 +65,7 @@ const User: FC<UserFormProps> = ({
|
|||||||
setFieldErrors(error as ValidateFieldsError);
|
setFieldErrors(error as ValidateFieldsError);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [user, validator, onDoneEditing]);
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
@@ -144,4 +137,4 @@ const User: FC<UserFormProps> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default memo(User);
|
export default User;
|
||||||
|
|||||||
@@ -34,43 +34,37 @@ 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();
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!data) {
|
const { LL } = useI18nContext();
|
||||||
return (
|
useLayoutTitle(LL.ACCESS_POINT(0));
|
||||||
<SectionContent>
|
|
||||||
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
|
|
||||||
</SectionContent>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
const theme = useTheme();
|
||||||
<SectionContent>
|
|
||||||
|
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 (
|
||||||
<List>
|
<List>
|
||||||
<ListItem>
|
<ListItem>
|
||||||
<ListItemAvatar>
|
<ListItemAvatar>
|
||||||
@@ -78,26 +72,19 @@ const APStatus = () => {
|
|||||||
<SettingsInputAntennaIcon />
|
<SettingsInputAntennaIcon />
|
||||||
</Avatar>
|
</Avatar>
|
||||||
</ListItemAvatar>
|
</ListItemAvatar>
|
||||||
<ListItemText
|
<ListItemText primary={LL.STATUS_OF('')} secondary={apStatus(data)} />
|
||||||
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 sx={{ bgcolor: 'primary.main' }}>IP</Avatar>
|
<Avatar>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 sx={{ bgcolor: 'primary.main' }}>
|
<Avatar>
|
||||||
<DeviceHubIcon />
|
<DeviceHubIcon />
|
||||||
</Avatar>
|
</Avatar>
|
||||||
</ListItemAvatar>
|
</ListItemAvatar>
|
||||||
@@ -106,22 +93,21 @@ 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 sx={{ bgcolor: 'primary.main' }}>
|
<Avatar>
|
||||||
<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;
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { useCallback, useMemo } from 'react';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Cell,
|
Cell,
|
||||||
@@ -19,12 +17,6 @@ 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);
|
||||||
|
|
||||||
@@ -36,16 +28,14 @@ const SystemActivity = () => {
|
|||||||
|
|
||||||
useLayoutTitle(LL.DATA_TRAFFIC());
|
useLayoutTitle(LL.DATA_TRAFFIC());
|
||||||
|
|
||||||
const stats_theme = tableTheme(
|
const stats_theme = tableTheme({
|
||||||
useMemo(
|
Table: `
|
||||||
() => ({
|
|
||||||
Table: `
|
|
||||||
--data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) 90px 90px 80px;
|
--data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) 90px 90px 80px;
|
||||||
`,
|
`,
|
||||||
BaseRow: `
|
BaseRow: `
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
`,
|
`,
|
||||||
HeaderRow: `
|
HeaderRow: `
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
background-color: black;
|
background-color: black;
|
||||||
color: #90CAF9;
|
color: #90CAF9;
|
||||||
@@ -55,7 +45,7 @@ const SystemActivity = () => {
|
|||||||
border-bottom: 1px solid #565656;
|
border-bottom: 1px solid #565656;
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
Row: `
|
Row: `
|
||||||
.td {
|
.td {
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
border-top: 1px solid #565656;
|
border-top: 1px solid #565656;
|
||||||
@@ -69,40 +59,34 @@ const SystemActivity = () => {
|
|||||||
background-color: #1e1e1e;
|
background-color: #1e1e1e;
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
BaseCell: `
|
BaseCell: `
|
||||||
&:not(:first-of-type) {
|
&:not(:first-of-type) {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
}),
|
});
|
||||||
[]
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
const showName = useCallback(
|
const showName = (id: number) => {
|
||||||
(id: number) => {
|
const name: keyof Translation['STATUS_NAMES'] =
|
||||||
const name: keyof Translation['STATUS_NAMES'] =
|
id.toString() as keyof Translation['STATUS_NAMES'];
|
||||||
id.toString() as keyof Translation['STATUS_NAMES'];
|
return LL.STATUS_NAMES[name]();
|
||||||
return LL.STATUS_NAMES[name]();
|
};
|
||||||
},
|
|
||||||
[LL]
|
|
||||||
);
|
|
||||||
|
|
||||||
const showQuality = useCallback((stat: Stat) => {
|
const showQuality = (stat: Stat) => {
|
||||||
if (stat.q === 0 || stat.s + stat.f === 0) {
|
if (stat.q === 0 || stat.s + stat.f === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (stat.q === 100) {
|
if (stat.q === 100) {
|
||||||
return <div style={{ color: QUALITY_COLORS.PERFECT }}>{stat.q}%</div>;
|
return <div style={{ color: '#00FF7F' }}>{stat.q}%</div>;
|
||||||
}
|
}
|
||||||
if (stat.q >= 95) {
|
if (stat.q >= 95) {
|
||||||
return <div style={{ color: QUALITY_COLORS.WARNING }}>{stat.q}%</div>;
|
return <div style={{ color: 'orange' }}>{stat.q}%</div>;
|
||||||
} else {
|
} else {
|
||||||
return <div style={{ color: QUALITY_COLORS.POOR }}>{stat.q}%</div>;
|
return <div style={{ color: 'red' }}>{stat.q}%</div>;
|
||||||
}
|
}
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const content = useMemo(() => {
|
const content = () => {
|
||||||
if (!data) {
|
if (!data) {
|
||||||
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
|
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
|
||||||
}
|
}
|
||||||
@@ -137,9 +121,9 @@ const SystemActivity = () => {
|
|||||||
)}
|
)}
|
||||||
</Table>
|
</Table>
|
||||||
);
|
);
|
||||||
}, [data, loadData, error?.message, stats_theme, LL, showName, showQuality]);
|
};
|
||||||
|
|
||||||
return <SectionContent>{content}</SectionContent>;
|
return <SectionContent>{content()}</SectionContent>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default SystemActivity;
|
export default SystemActivity;
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
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';
|
||||||
@@ -26,61 +24,10 @@ 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();
|
||||||
|
|
||||||
@@ -92,72 +39,175 @@ const HardwareStatus = () => {
|
|||||||
void loadData();
|
void loadData();
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!data) {
|
const content = () => {
|
||||||
return (
|
if (!data) {
|
||||||
<SectionContent>
|
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
|
||||||
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
|
}
|
||||||
</SectionContent>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionContent>
|
|
||||||
<List>
|
<List>
|
||||||
<HardwareListItem
|
<ListItem>
|
||||||
icon={<TapAndPlayIcon />}
|
<ListItemAvatar>
|
||||||
primary={`${LL.HARDWARE()} ${LL.DEVICE()}`}
|
{data.model ? (
|
||||||
secondary={data.model || data.cpu_type}
|
<Avatar sx={{ bgcolor: '#003289', color: 'white' }}>
|
||||||
avatarColor={data.model ? AVATAR_COLORS.BBQKEES : AVATAR_COLORS.DEFAULT}
|
<img
|
||||||
customIcon={
|
alt="BBQKees"
|
||||||
data.model ? (
|
src={BBQKeesIcon}
|
||||||
<img
|
style={{ width: 16, verticalAlign: 'middle' }}
|
||||||
alt="BBQKees"
|
/>
|
||||||
src={BBQKeesIcon}
|
</Avatar>
|
||||||
style={{ width: 16, verticalAlign: 'middle' }}
|
) : (
|
||||||
/>
|
<Avatar sx={{ bgcolor: '#5f9a5f', color: 'white' }}>
|
||||||
) : undefined
|
<TapAndPlayIcon />
|
||||||
}
|
</Avatar>
|
||||||
/>
|
)}
|
||||||
<HardwareListItem
|
</ListItemAvatar>
|
||||||
icon={<DevicesIcon />}
|
<ListItemText
|
||||||
primary="SDK"
|
primary={LL.HARDWARE() + ' ' + LL.DEVICE()}
|
||||||
secondary={`${data.arduino_version} / ESP-IDF ${data.sdk_version}`}
|
secondary={data.model ? data.model : data.cpu_type}
|
||||||
/>
|
|
||||||
<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`}
|
|
||||||
/>
|
/>
|
||||||
|
</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>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<HardwareListItem
|
<Divider variant="inset" component="li" />
|
||||||
icon={<SdStorageIcon />}
|
<ListItem>
|
||||||
primary={LL.FLASH()}
|
<ListItemAvatar>
|
||||||
secondary={`${formatNumber(data.flash_chip_size)} KB , ${formatFlashSpeed(data.flash_chip_speed)}`}
|
<Avatar sx={{ bgcolor: '#5f9a5f', color: 'white' }}>
|
||||||
/>
|
<SdStorageIcon />
|
||||||
<HardwareListItem
|
</Avatar>
|
||||||
icon={<SdCardAlertIcon />}
|
</ListItemAvatar>
|
||||||
primary={LL.APPSIZE()}
|
<ListItemText
|
||||||
secondary={`${data.partition}: ${formatNumber(data.app_used)} KB / ${formatNumber(data.app_free)} KB`}
|
primary={LL.FLASH()}
|
||||||
/>
|
secondary={
|
||||||
<HardwareListItem
|
formatNumber(data.flash_chip_size) +
|
||||||
icon={<FolderIcon />}
|
' KB , ' +
|
||||||
primary={LL.FILESYSTEM()}
|
(data.flash_chip_speed / 1000000).toFixed(0) +
|
||||||
secondary={`${formatNumber(data.fs_used)} KB / ${formatNumber(data.fs_free)} KB`}
|
' 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>
|
</List>
|
||||||
</SectionContent>
|
);
|
||||||
);
|
};
|
||||||
|
|
||||||
|
return <SectionContent>{content()}</SectionContent>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default HardwareStatus;
|
export default HardwareStatus;
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { type FC, memo, useMemo } from 'react';
|
|
||||||
|
|
||||||
import AutoAwesomeMotionIcon from '@mui/icons-material/AutoAwesomeMotion';
|
import AutoAwesomeMotionIcon from '@mui/icons-material/AutoAwesomeMotion';
|
||||||
import DeviceHubIcon from '@mui/icons-material/DeviceHub';
|
import DeviceHubIcon from '@mui/icons-material/DeviceHub';
|
||||||
import ReportIcon from '@mui/icons-material/Report';
|
import ReportIcon from '@mui/icons-material/Report';
|
||||||
@@ -24,28 +22,17 @@ 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) return theme.palette.info.main;
|
if (!enabled) {
|
||||||
return connected ? theme.palette.success.main : theme.palette.error.main;
|
return theme.palette.info.main;
|
||||||
|
}
|
||||||
|
if (connected) {
|
||||||
|
return theme.palette.success.main;
|
||||||
|
}
|
||||||
|
return theme.palette.error.main;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const mqttPublishHighlight = (
|
export const mqttPublishHighlight = (
|
||||||
@@ -54,100 +41,114 @@ 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 = ({ mqtt_queued }: MqttStatusType, theme: Theme) =>
|
export const mqttQueueHighlight = (
|
||||||
mqtt_queued <= 1 ? theme.palette.success.main : theme.palette.warning.main;
|
{ mqtt_queued }: MqttStatusType,
|
||||||
|
theme: Theme
|
||||||
|
) => {
|
||||||
|
if (mqtt_queued <= 1) return theme.palette.success.main;
|
||||||
|
|
||||||
interface ConnectionStatusProps {
|
return theme.palette.warning.main;
|
||||||
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();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Memoize error message separately to avoid re-renders on error object changes
|
const { LL } = useI18nContext();
|
||||||
const errorMessage = error?.message || '';
|
useLayoutTitle('MQTT');
|
||||||
|
|
||||||
const mqttStatusText = useMemo(() => {
|
const theme = useTheme();
|
||||||
if (!data) return '';
|
|
||||||
if (!data.enabled) return LL.NOT_ENABLED();
|
|
||||||
return data.connected
|
|
||||||
? `${LL.CONNECTED(0)} (${data.connect_count})`
|
|
||||||
: `${LL.DISCONNECTED()} (${data.connect_count})`;
|
|
||||||
}, [data, LL]);
|
|
||||||
|
|
||||||
if (!data) {
|
const mqttStatus = ({ enabled, connected, connect_count }: MqttStatusType) => {
|
||||||
return (
|
if (!enabled) {
|
||||||
<SectionContent>
|
return LL.NOT_ENABLED();
|
||||||
<FormLoader onRetry={loadData} errorMessage={errorMessage} />
|
}
|
||||||
</SectionContent>
|
if (connected) {
|
||||||
|
return LL.CONNECTED(0) + ' (' + connect_count + ')';
|
||||||
|
}
|
||||||
|
return LL.DISCONNECTED() + ' (' + connect_count + ')';
|
||||||
|
};
|
||||||
|
|
||||||
|
const disconnectReason = ({ disconnect_reason }: MqttStatusType) => {
|
||||||
|
switch (disconnect_reason) {
|
||||||
|
case MqttDisconnectReason.TCP_DISCONNECTED:
|
||||||
|
return 'TCP disconnected';
|
||||||
|
case MqttDisconnectReason.MQTT_UNACCEPTABLE_PROTOCOL_VERSION:
|
||||||
|
return 'Unacceptable protocol version';
|
||||||
|
case MqttDisconnectReason.MQTT_IDENTIFIER_REJECTED:
|
||||||
|
return 'Client ID rejected';
|
||||||
|
case MqttDisconnectReason.MQTT_SERVER_UNAVAILABLE:
|
||||||
|
return 'Server unavailable';
|
||||||
|
case MqttDisconnectReason.MQTT_MALFORMED_CREDENTIALS:
|
||||||
|
return 'Malformed credentials';
|
||||||
|
case MqttDisconnectReason.MQTT_NOT_AUTHORIZED:
|
||||||
|
return 'Not authorized';
|
||||||
|
case MqttDisconnectReason.TLS_BAD_FINGERPRINT:
|
||||||
|
return 'TLS fingerprint invalid';
|
||||||
|
default:
|
||||||
|
return 'Unknown';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const content = () => {
|
||||||
|
if (!data) {
|
||||||
|
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderConnectionStatus = () => (
|
||||||
|
<>
|
||||||
|
{!data.connected && (
|
||||||
|
<>
|
||||||
|
<ListItem>
|
||||||
|
<ListItemAvatar>
|
||||||
|
<Avatar>
|
||||||
|
<ReportIcon />
|
||||||
|
</Avatar>
|
||||||
|
</ListItemAvatar>
|
||||||
|
<ListItemText
|
||||||
|
primary={LL.DISCONNECT_REASON()}
|
||||||
|
secondary={disconnectReason(data)}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
<Divider variant="inset" component="li" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<ListItem>
|
||||||
|
<ListItemAvatar>
|
||||||
|
<Avatar>#</Avatar>
|
||||||
|
</ListItemAvatar>
|
||||||
|
<ListItemText primary={LL.ID_OF(LL.CLIENT())} secondary={data.client_id} />
|
||||||
|
</ListItem>
|
||||||
|
<Divider variant="inset" component="li" />
|
||||||
|
<ListItem>
|
||||||
|
<ListItemAvatar>
|
||||||
|
<Avatar sx={{ bgcolor: mqttQueueHighlight(data, theme) }}>
|
||||||
|
<AutoAwesomeMotionIcon />
|
||||||
|
</Avatar>
|
||||||
|
</ListItemAvatar>
|
||||||
|
<ListItemText primary={LL.MQTT_QUEUE()} secondary={data.mqtt_queued} />
|
||||||
|
</ListItem>
|
||||||
|
<Divider variant="inset" component="li" />
|
||||||
|
<ListItem>
|
||||||
|
<ListItemAvatar>
|
||||||
|
<Avatar sx={{ bgcolor: mqttPublishHighlight(data, theme) }}>
|
||||||
|
<SpeakerNotesOffIcon />
|
||||||
|
</Avatar>
|
||||||
|
</ListItemAvatar>
|
||||||
|
<ListItemText primary={LL.ERRORS_OF('MQTT')} secondary={data.mqtt_fails} />
|
||||||
|
</ListItem>
|
||||||
|
<Divider variant="inset" component="li" />
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionContent>
|
|
||||||
<List>
|
<List>
|
||||||
<ListItem>
|
<ListItem>
|
||||||
<ListItemAvatar>
|
<ListItemAvatar>
|
||||||
@@ -155,13 +156,15 @@ const MqttStatus = () => {
|
|||||||
<DeviceHubIcon />
|
<DeviceHubIcon />
|
||||||
</Avatar>
|
</Avatar>
|
||||||
</ListItemAvatar>
|
</ListItemAvatar>
|
||||||
<ListItemText primary={LL.STATUS_OF('')} secondary={mqttStatusText} />
|
<ListItemText primary={LL.STATUS_OF('')} secondary={mqttStatus(data)} />
|
||||||
</ListItem>
|
</ListItem>
|
||||||
<Divider variant="inset" component="li" />
|
<Divider variant="inset" component="li" />
|
||||||
{data.enabled && <ConnectionStatus data={data} theme={theme} />}
|
{data.enabled && renderConnectionStatus()}
|
||||||
</List>
|
</List>
|
||||||
</SectionContent>
|
);
|
||||||
);
|
};
|
||||||
|
|
||||||
|
return <SectionContent>{content()}</SectionContent>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default MqttStatus;
|
export default MqttStatus;
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { useMemo } from 'react';
|
|
||||||
|
|
||||||
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
||||||
import DnsIcon from '@mui/icons-material/Dns';
|
import DnsIcon from '@mui/icons-material/Dns';
|
||||||
import SwapVerticalCircleIcon from '@mui/icons-material/SwapVerticalCircle';
|
import SwapVerticalCircleIcon from '@mui/icons-material/SwapVerticalCircle';
|
||||||
@@ -25,23 +23,6 @@ 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);
|
||||||
|
|
||||||
@@ -52,6 +33,24 @@ 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) => {
|
||||||
@@ -67,64 +66,66 @@ const NTPStatus = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const content = useMemo(() => {
|
const content = () => {
|
||||||
if (!data) {
|
if (!data) {
|
||||||
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
|
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<List>
|
<>
|
||||||
<ListItem>
|
<List>
|
||||||
<ListItemAvatar>
|
<ListItem>
|
||||||
<Avatar sx={{ bgcolor: ntpStatusHighlight(data, theme) }}>
|
<ListItemAvatar>
|
||||||
<UpdateIcon />
|
<Avatar sx={{ bgcolor: ntpStatusHighlight(data, theme) }}>
|
||||||
</Avatar>
|
<UpdateIcon />
|
||||||
</ListItemAvatar>
|
</Avatar>
|
||||||
<ListItemText primary={LL.STATUS_OF('')} secondary={ntpStatus(data)} />
|
</ListItemAvatar>
|
||||||
</ListItem>
|
<ListItemText primary={LL.STATUS_OF('')} secondary={ntpStatus(data)} />
|
||||||
<Divider variant="inset" component="li" />
|
</ListItem>
|
||||||
{isNtpEnabled(data) && (
|
<Divider variant="inset" component="li" />
|
||||||
<>
|
{isNtpEnabled(data) && (
|
||||||
<ListItem>
|
<>
|
||||||
<ListItemAvatar>
|
<ListItem>
|
||||||
<Avatar>
|
<ListItemAvatar>
|
||||||
<DnsIcon />
|
<Avatar>
|
||||||
</Avatar>
|
<DnsIcon />
|
||||||
</ListItemAvatar>
|
</Avatar>
|
||||||
<ListItemText primary={LL.NTP_SERVER()} secondary={data.server} />
|
</ListItemAvatar>
|
||||||
</ListItem>
|
<ListItemText primary={LL.NTP_SERVER()} secondary={data.server} />
|
||||||
<Divider variant="inset" component="li" />
|
</ListItem>
|
||||||
</>
|
<Divider variant="inset" component="li" />
|
||||||
)}
|
</>
|
||||||
<ListItem>
|
)}
|
||||||
<ListItemAvatar>
|
<ListItem>
|
||||||
<Avatar>
|
<ListItemAvatar>
|
||||||
<AccessTimeIcon />
|
<Avatar>
|
||||||
</Avatar>
|
<AccessTimeIcon />
|
||||||
</ListItemAvatar>
|
</Avatar>
|
||||||
<ListItemText
|
</ListItemAvatar>
|
||||||
primary={LL.LOCAL_TIME(0)}
|
<ListItemText
|
||||||
secondary={formatDateTime(data.local_time)}
|
primary={LL.LOCAL_TIME(0)}
|
||||||
/>
|
secondary={formatDateTime(data.local_time)}
|
||||||
</ListItem>
|
/>
|
||||||
<Divider variant="inset" component="li" />
|
</ListItem>
|
||||||
<ListItem>
|
<Divider variant="inset" component="li" />
|
||||||
<ListItemAvatar>
|
<ListItem>
|
||||||
<Avatar>
|
<ListItemAvatar>
|
||||||
<SwapVerticalCircleIcon />
|
<Avatar>
|
||||||
</Avatar>
|
<SwapVerticalCircleIcon />
|
||||||
</ListItemAvatar>
|
</Avatar>
|
||||||
<ListItemText
|
</ListItemAvatar>
|
||||||
primary={LL.UTC_TIME()}
|
<ListItemText
|
||||||
secondary={formatDateTime(data.utc_time)}
|
primary={LL.UTC_TIME()}
|
||||||
/>
|
secondary={formatDateTime(data.utc_time)}
|
||||||
</ListItem>
|
/>
|
||||||
<Divider variant="inset" component="li" />
|
</ListItem>
|
||||||
</List>
|
<Divider variant="inset" component="li" />
|
||||||
|
</List>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}, [data, error, loadData, LL, theme]);
|
};
|
||||||
|
|
||||||
return <SectionContent>{content}</SectionContent>;
|
return <SectionContent>{content()}</SectionContent>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default NTPStatus;
|
export default NTPStatus;
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { useMemo } from 'react';
|
|
||||||
|
|
||||||
import DeviceHubIcon from '@mui/icons-material/DeviceHub';
|
import DeviceHubIcon from '@mui/icons-material/DeviceHub';
|
||||||
import DnsIcon from '@mui/icons-material/Dns';
|
import DnsIcon from '@mui/icons-material/Dns';
|
||||||
import GiteIcon from '@mui/icons-material/Gite';
|
import GiteIcon from '@mui/icons-material/Gite';
|
||||||
@@ -27,17 +25,10 @@ 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:
|
||||||
@@ -64,6 +55,11 @@ 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';
|
||||||
@@ -85,33 +81,6 @@ 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);
|
||||||
|
|
||||||
@@ -124,30 +93,51 @@ const NetworkStatus = () => {
|
|||||||
|
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
|
||||||
const content = useMemo(() => {
|
const networkStatus = ({ status }: NetworkStatusType) => {
|
||||||
|
switch (status) {
|
||||||
|
case NetworkConnectionStatus.ETHERNET_STATUS_CONNECTED:
|
||||||
|
return LL.CONNECTED(0) + ' (Ethernet)';
|
||||||
|
case NetworkConnectionStatus.WIFI_STATUS_NO_SHIELD:
|
||||||
|
return LL.INACTIVE(1);
|
||||||
|
case NetworkConnectionStatus.WIFI_STATUS_IDLE:
|
||||||
|
return LL.IDLE();
|
||||||
|
case NetworkConnectionStatus.WIFI_STATUS_NO_SSID_AVAIL:
|
||||||
|
return 'No SSID Available';
|
||||||
|
case NetworkConnectionStatus.WIFI_STATUS_CONNECTED:
|
||||||
|
return LL.CONNECTED(0) + ' (WiFi) (' + data.reconnect_count + ')';
|
||||||
|
case NetworkConnectionStatus.WIFI_STATUS_CONNECT_FAILED:
|
||||||
|
return (
|
||||||
|
LL.CONNECTED(1) + ' ' + LL.FAILED(0) + ' (' + data.reconnect_count + ')'
|
||||||
|
);
|
||||||
|
case NetworkConnectionStatus.WIFI_STATUS_CONNECTION_LOST:
|
||||||
|
return LL.CONNECTED(1) + ' ' + LL.LOST() + ' (' + data.reconnect_count + ')';
|
||||||
|
case NetworkConnectionStatus.WIFI_STATUS_DISCONNECTED:
|
||||||
|
return LL.DISCONNECTED();
|
||||||
|
default:
|
||||||
|
return LL.UNKNOWN();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const content = () => {
|
||||||
if (!data) {
|
if (!data) {
|
||||||
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
|
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusText = getNetworkStatusText(data.status, data.reconnect_count, LL);
|
|
||||||
const statusColor = networkStatusHighlight(data, theme);
|
|
||||||
const qualityColor = networkQualityHighlight(data, theme);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<List>
|
<List>
|
||||||
<ListItem>
|
<ListItem>
|
||||||
<ListItemAvatar>
|
<ListItemAvatar>
|
||||||
<Avatar sx={{ bgcolor: statusColor }}>
|
<Avatar sx={{ bgcolor: networkStatusHighlight(data, theme) }}>
|
||||||
{isWiFi(data) && <WifiIcon />}
|
{isWiFi(data) && <WifiIcon />}
|
||||||
{isEthernet(data) && <RouterIcon />}
|
{isEthernet(data) && <RouterIcon />}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
</ListItemAvatar>
|
</ListItemAvatar>
|
||||||
<ListItemText primary="Status" secondary={statusText} />
|
<ListItemText primary="Status" secondary={networkStatus(data)} />
|
||||||
</ListItem>
|
</ListItem>
|
||||||
<Divider variant="inset" component="li" />
|
<Divider variant="inset" component="li" />
|
||||||
<ListItem>
|
<ListItem>
|
||||||
<ListItemAvatar>
|
<ListItemAvatar>
|
||||||
<Avatar sx={{ bgcolor: statusColor }}>
|
<Avatar sx={{ bgcolor: networkStatusHighlight(data, theme) }}>
|
||||||
<GiteIcon />
|
<GiteIcon />
|
||||||
</Avatar>
|
</Avatar>
|
||||||
</ListItemAvatar>
|
</ListItemAvatar>
|
||||||
@@ -158,13 +148,13 @@ const NetworkStatus = () => {
|
|||||||
<>
|
<>
|
||||||
<ListItem>
|
<ListItem>
|
||||||
<ListItemAvatar>
|
<ListItemAvatar>
|
||||||
<Avatar sx={{ bgcolor: qualityColor }}>
|
<Avatar sx={{ bgcolor: networkQualityHighlight(data, theme) }}>
|
||||||
<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" />
|
||||||
@@ -228,9 +218,9 @@ const NetworkStatus = () => {
|
|||||||
)}
|
)}
|
||||||
</List>
|
</List>
|
||||||
);
|
);
|
||||||
}, [data, error, loadData, LL, theme]);
|
};
|
||||||
|
|
||||||
return <SectionContent>{content}</SectionContent>;
|
return <SectionContent>{content()}</SectionContent>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default NetworkStatus;
|
export default NetworkStatus;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useContext, useMemo, useState } from 'react';
|
import { useContext, useState } from 'react';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
||||||
@@ -8,10 +8,10 @@ import DeviceHubIcon from '@mui/icons-material/DeviceHub';
|
|||||||
import DirectionsBusIcon from '@mui/icons-material/DirectionsBus';
|
import DirectionsBusIcon from '@mui/icons-material/DirectionsBus';
|
||||||
import LogoDevIcon from '@mui/icons-material/LogoDev';
|
import LogoDevIcon from '@mui/icons-material/LogoDev';
|
||||||
import MemoryIcon from '@mui/icons-material/Memory';
|
import MemoryIcon from '@mui/icons-material/Memory';
|
||||||
import MonitorHeartIcon from '@mui/icons-material/MonitorHeart';
|
|
||||||
import PowerSettingsNewIcon from '@mui/icons-material/PowerSettingsNew';
|
import PowerSettingsNewIcon from '@mui/icons-material/PowerSettingsNew';
|
||||||
import RouterIcon from '@mui/icons-material/Router';
|
import RouterIcon from '@mui/icons-material/Router';
|
||||||
import SettingsInputAntennaIcon from '@mui/icons-material/SettingsInputAntenna';
|
import SettingsInputAntennaIcon from '@mui/icons-material/SettingsInputAntenna';
|
||||||
|
import TimerIcon from '@mui/icons-material/Timer';
|
||||||
import WifiIcon from '@mui/icons-material/Wifi';
|
import WifiIcon from '@mui/icons-material/Wifi';
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
@@ -37,34 +37,12 @@ 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, SystemStatusCodes } from 'types';
|
import { NTPSyncStatus, NetworkConnectionStatus } from 'types';
|
||||||
import { useInterval } from 'utils';
|
import { useInterval } from 'utils';
|
||||||
import { formatDateTime } from 'utils/time';
|
import { formatDateTime } from 'utils/time';
|
||||||
|
|
||||||
import SystemMonitor from './SystemMonitor';
|
import SystemMonitor from './SystemMonitor';
|
||||||
|
|
||||||
// Pure functions moved outside component to avoid recreation on each render
|
|
||||||
const formatNumber = (num: number) => new Intl.NumberFormat().format(num);
|
|
||||||
|
|
||||||
const formatDurationSec = (
|
|
||||||
duration_sec: number,
|
|
||||||
LL: ReturnType<typeof useI18nContext>['LL']
|
|
||||||
) => {
|
|
||||||
const ms = duration_sec * 1000;
|
|
||||||
const days = Math.trunc(ms / 86400000);
|
|
||||||
const hours = Math.trunc(ms / 3600000) % 24;
|
|
||||||
const minutes = Math.trunc(ms / 60000) % 60;
|
|
||||||
const seconds = Math.trunc(ms / 1000) % 60;
|
|
||||||
|
|
||||||
const parts: string[] = [];
|
|
||||||
if (days) parts.push(LL.NUM_DAYS({ num: days }));
|
|
||||||
if (hours) parts.push(LL.NUM_HOURS({ num: hours }));
|
|
||||||
if (minutes) parts.push(LL.NUM_MINUTES({ num: minutes }));
|
|
||||||
parts.push(LL.NUM_SECONDS({ num: seconds }));
|
|
||||||
|
|
||||||
return parts.join(' ');
|
|
||||||
};
|
|
||||||
|
|
||||||
const SystemStatus = () => {
|
const SystemStatus = () => {
|
||||||
const { LL } = useI18nContext();
|
const { LL } = useI18nContext();
|
||||||
|
|
||||||
@@ -84,6 +62,7 @@ const SystemStatus = () => {
|
|||||||
send: loadData,
|
send: loadData,
|
||||||
error
|
error
|
||||||
} = useRequest(readSystemStatus, {
|
} = useRequest(readSystemStatus, {
|
||||||
|
initialData: [],
|
||||||
async middleware(_, next) {
|
async middleware(_, next) {
|
||||||
if (!restarting) {
|
if (!restarting) {
|
||||||
await next();
|
await next();
|
||||||
@@ -97,46 +76,51 @@ const SystemStatus = () => {
|
|||||||
|
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
|
||||||
// Memoize derived status values to avoid recalculation on every render
|
const formatDurationSec = (duration_sec: number) => {
|
||||||
const busStatus = useMemo(() => {
|
const days = Math.trunc((duration_sec * 1000) / 86400000);
|
||||||
if (!data) return 'EMS state unknown';
|
const hours = Math.trunc((duration_sec * 1000) / 3600000) % 24;
|
||||||
|
const minutes = Math.trunc((duration_sec * 1000) / 60000) % 60;
|
||||||
|
const seconds = Math.trunc((duration_sec * 1000) / 1000) % 60;
|
||||||
|
|
||||||
switch (data.bus_status) {
|
let formatted = '';
|
||||||
case busConnectionStatus.BUS_STATUS_CONNECTED:
|
if (days) {
|
||||||
return `EMS ${LL.CONNECTED(0)} (${formatDurationSec(data.bus_uptime, LL)})`;
|
formatted += LL.NUM_DAYS({ num: days }) + ' ';
|
||||||
case busConnectionStatus.BUS_STATUS_TX_ERRORS:
|
|
||||||
return 'EMS ' + LL.TX_ISSUES();
|
|
||||||
case busConnectionStatus.BUS_STATUS_OFFLINE:
|
|
||||||
return 'EMS ' + LL.DISCONNECTED();
|
|
||||||
default:
|
|
||||||
return 'EMS state unknown';
|
|
||||||
}
|
}
|
||||||
}, [data?.bus_status, data?.bus_uptime, LL]);
|
if (hours) {
|
||||||
|
formatted += LL.NUM_HOURS({ num: hours }) + ' ';
|
||||||
// Memoize derived status values to avoid recalculation on every render
|
|
||||||
const systemStatus = useMemo(() => {
|
|
||||||
if (!data) return '??';
|
|
||||||
|
|
||||||
switch (data.status) {
|
|
||||||
case SystemStatusCodes.SYSTEM_STATUS_PENDING_UPLOAD:
|
|
||||||
case SystemStatusCodes.SYSTEM_STATUS_UPLOADING:
|
|
||||||
return LL.WAIT_FIRMWARE();
|
|
||||||
case SystemStatusCodes.SYSTEM_STATUS_ERROR_UPLOAD:
|
|
||||||
return LL.ERROR();
|
|
||||||
case SystemStatusCodes.SYSTEM_STATUS_PENDING_RESTART:
|
|
||||||
case SystemStatusCodes.SYSTEM_STATUS_RESTART_REQUESTED:
|
|
||||||
return LL.RESTARTING_PRE();
|
|
||||||
case SystemStatusCodes.SYSTEM_STATUS_INVALID_GPIO:
|
|
||||||
return LL.GPIO_OF(LL.FAILED(0));
|
|
||||||
default:
|
|
||||||
// SystemStatusCodes.SYSTEM_STATUS_NORMAL
|
|
||||||
return 'OK';
|
|
||||||
}
|
}
|
||||||
}, [data?.status, LL]);
|
if (minutes) {
|
||||||
|
formatted += LL.NUM_MINUTES({ num: minutes }) + ' ';
|
||||||
|
}
|
||||||
|
formatted += LL.NUM_SECONDS({ num: seconds });
|
||||||
|
return formatted;
|
||||||
|
};
|
||||||
|
|
||||||
const busStatusHighlight = useMemo(() => {
|
function formatNumber(num: number) {
|
||||||
if (!data) return theme.palette.warning.main;
|
return new Intl.NumberFormat().format(num);
|
||||||
|
}
|
||||||
|
|
||||||
|
const busStatus = () => {
|
||||||
|
if (data) {
|
||||||
|
switch (data.bus_status) {
|
||||||
|
case busConnectionStatus.BUS_STATUS_CONNECTED:
|
||||||
|
return (
|
||||||
|
'EMS ' +
|
||||||
|
LL.CONNECTED(0) +
|
||||||
|
' (' +
|
||||||
|
formatDurationSec(data.bus_uptime) +
|
||||||
|
')'
|
||||||
|
);
|
||||||
|
case busConnectionStatus.BUS_STATUS_TX_ERRORS:
|
||||||
|
return 'EMS ' + LL.TX_ISSUES();
|
||||||
|
case busConnectionStatus.BUS_STATUS_OFFLINE:
|
||||||
|
return 'EMS ' + LL.DISCONNECTED();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'EMS state unknown';
|
||||||
|
};
|
||||||
|
|
||||||
|
const busStatusHighlight = () => {
|
||||||
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;
|
||||||
@@ -147,28 +131,27 @@ const SystemStatus = () => {
|
|||||||
default:
|
default:
|
||||||
return theme.palette.warning.main;
|
return theme.palette.warning.main;
|
||||||
}
|
}
|
||||||
}, [data?.bus_status, theme.palette]);
|
};
|
||||||
|
|
||||||
const ntpStatus = useMemo(() => {
|
|
||||||
if (!data) return LL.UNKNOWN();
|
|
||||||
|
|
||||||
|
const ntpStatus = () => {
|
||||||
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 data.ntp_time
|
return (
|
||||||
? `${LL.ACTIVE()} (${formatDateTime(data.ntp_time)})`
|
LL.ACTIVE() +
|
||||||
: LL.ACTIVE();
|
(data.ntp_time !== undefined
|
||||||
|
? ' (' + formatDateTime(data.ntp_time) + ')'
|
||||||
|
: '')
|
||||||
|
);
|
||||||
default:
|
default:
|
||||||
return LL.UNKNOWN();
|
return LL.UNKNOWN();
|
||||||
}
|
}
|
||||||
}, [data?.ntp_status, data?.ntp_time, LL]);
|
};
|
||||||
|
|
||||||
const ntpStatusHighlight = useMemo(() => {
|
|
||||||
if (!data) return theme.palette.error.main;
|
|
||||||
|
|
||||||
|
const ntpStatusHighlight = () => {
|
||||||
switch (data.ntp_status) {
|
switch (data.ntp_status) {
|
||||||
case NTPSyncStatus.NTP_DISABLED:
|
case NTPSyncStatus.NTP_DISABLED:
|
||||||
return theme.palette.info.main;
|
return theme.palette.info.main;
|
||||||
@@ -179,11 +162,9 @@ const SystemStatus = () => {
|
|||||||
default:
|
default:
|
||||||
return theme.palette.error.main;
|
return theme.palette.error.main;
|
||||||
}
|
}
|
||||||
}, [data?.ntp_status, theme.palette]);
|
};
|
||||||
|
|
||||||
const networkStatusHighlight = useMemo(() => {
|
|
||||||
if (!data) return theme.palette.warning.main;
|
|
||||||
|
|
||||||
|
const networkStatusHighlight = () => {
|
||||||
switch (data.network_status) {
|
switch (data.network_status) {
|
||||||
case NetworkConnectionStatus.WIFI_STATUS_IDLE:
|
case NetworkConnectionStatus.WIFI_STATUS_IDLE:
|
||||||
case NetworkConnectionStatus.WIFI_STATUS_DISCONNECTED:
|
case NetworkConnectionStatus.WIFI_STATUS_DISCONNECTED:
|
||||||
@@ -198,11 +179,9 @@ const SystemStatus = () => {
|
|||||||
default:
|
default:
|
||||||
return theme.palette.warning.main;
|
return theme.palette.warning.main;
|
||||||
}
|
}
|
||||||
}, [data?.network_status, theme.palette]);
|
};
|
||||||
|
|
||||||
const networkStatus = useMemo(() => {
|
|
||||||
if (!data) return LL.UNKNOWN();
|
|
||||||
|
|
||||||
|
const networkStatus = () => {
|
||||||
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);
|
||||||
@@ -211,27 +190,24 @@ 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();
|
||||||
}
|
}
|
||||||
}, [data?.network_status, data?.wifi_rssi, LL]);
|
};
|
||||||
|
|
||||||
const activeHighlight = useCallback(
|
const activeHighlight = (value: boolean) =>
|
||||||
(value: boolean) =>
|
value ? theme.palette.success.main : theme.palette.info.main;
|
||||||
value ? theme.palette.success.main : theme.palette.info.main,
|
|
||||||
[theme.palette]
|
|
||||||
);
|
|
||||||
|
|
||||||
const doRestart = useCallback(async () => {
|
const doRestart = async () => {
|
||||||
setConfirmRestart(false);
|
setConfirmRestart(false);
|
||||||
setRestarting(true);
|
setRestarting(true);
|
||||||
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
|
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
|
||||||
@@ -239,83 +215,38 @@ const SystemStatus = () => {
|
|||||||
toast.error(error.message);
|
toast.error(error.message);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}, [sendAPI]);
|
};
|
||||||
|
|
||||||
const handleCloseRestartDialog = useCallback(() => {
|
const renderRestartDialog = () => (
|
||||||
setConfirmRestart(false);
|
<Dialog
|
||||||
}, []);
|
sx={dialogStyle}
|
||||||
|
open={confirmRestart}
|
||||||
const renderRestartDialog = useMemo(
|
onClose={() => setConfirmRestart(false)}
|
||||||
() => (
|
>
|
||||||
<Dialog
|
<DialogTitle>{LL.RESTART()}</DialogTitle>
|
||||||
sx={dialogStyle}
|
<DialogContent dividers>{LL.RESTART_CONFIRM()}</DialogContent>
|
||||||
open={confirmRestart}
|
<DialogActions>
|
||||||
onClose={handleCloseRestartDialog}
|
<Button
|
||||||
>
|
startIcon={<CancelIcon />}
|
||||||
<DialogTitle>{LL.RESTART()}</DialogTitle>
|
variant="outlined"
|
||||||
<DialogContent dividers>{LL.RESTART_CONFIRM()}</DialogContent>
|
onClick={() => setConfirmRestart(false)}
|
||||||
<DialogActions>
|
color="secondary"
|
||||||
<Button
|
>
|
||||||
startIcon={<CancelIcon />}
|
{LL.CANCEL()}
|
||||||
variant="outlined"
|
</Button>
|
||||||
onClick={handleCloseRestartDialog}
|
<Button
|
||||||
color="secondary"
|
startIcon={<PowerSettingsNewIcon />}
|
||||||
>
|
variant="outlined"
|
||||||
{LL.CANCEL()}
|
onClick={doRestart}
|
||||||
</Button>
|
color="error"
|
||||||
<Button
|
>
|
||||||
startIcon={<PowerSettingsNewIcon />}
|
{LL.RESTART()}
|
||||||
variant="outlined"
|
</Button>
|
||||||
onClick={doRestart}
|
</DialogActions>
|
||||||
color="error"
|
</Dialog>
|
||||||
>
|
|
||||||
{LL.RESTART()}
|
|
||||||
</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
),
|
|
||||||
[confirmRestart, handleCloseRestartDialog, doRestart, LL]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Memoize formatted values
|
const content = () => {
|
||||||
const firmwareVersion = useMemo(
|
|
||||||
() => `v${data?.emsesp_version || ''}`,
|
|
||||||
[data?.emsesp_version]
|
|
||||||
);
|
|
||||||
|
|
||||||
const uptimeText = useMemo(
|
|
||||||
() => (data ? formatDurationSec(data.uptime, LL) : ''),
|
|
||||||
[data?.uptime, LL]
|
|
||||||
);
|
|
||||||
|
|
||||||
const freeMemoryText = useMemo(
|
|
||||||
() => (data ? `${formatNumber(data.free_heap)} KB ${LL.FREE_MEMORY()}` : ''),
|
|
||||||
[data?.free_heap, LL]
|
|
||||||
);
|
|
||||||
|
|
||||||
const networkIcon = useMemo(
|
|
||||||
() =>
|
|
||||||
data?.network_status === NetworkConnectionStatus.WIFI_STATUS_CONNECTED
|
|
||||||
? WifiIcon
|
|
||||||
: RouterIcon,
|
|
||||||
[data?.network_status]
|
|
||||||
);
|
|
||||||
|
|
||||||
const mqttStatusText = useMemo(
|
|
||||||
() => (data?.mqtt_status ? LL.CONNECTED(0) : LL.INACTIVE(0)),
|
|
||||||
[data?.mqtt_status, LL]
|
|
||||||
);
|
|
||||||
|
|
||||||
const apStatusText = useMemo(
|
|
||||||
() => (data?.ap_status ? LL.ACTIVE() : LL.INACTIVE(0)),
|
|
||||||
[data?.ap_status, LL]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleRestartClick = useCallback(() => {
|
|
||||||
setConfirmRestart(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const content = useMemo(() => {
|
|
||||||
if (!data || !LL) {
|
if (!data || !LL) {
|
||||||
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
|
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
|
||||||
}
|
}
|
||||||
@@ -327,26 +258,26 @@ const SystemStatus = () => {
|
|||||||
icon={BuildIcon}
|
icon={BuildIcon}
|
||||||
bgcolor="#72caf9"
|
bgcolor="#72caf9"
|
||||||
label="EMS-ESP Firmware"
|
label="EMS-ESP Firmware"
|
||||||
text={firmwareVersion}
|
text={'v' + data.emsesp_version}
|
||||||
to="version"
|
to="version"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ListItem>
|
<ListItem>
|
||||||
<ListItemAvatar>
|
<ListItemAvatar>
|
||||||
<Avatar sx={{ bgcolor: '#c5572c', color: 'white' }}>
|
<Avatar sx={{ bgcolor: '#c5572c', color: 'white' }}>
|
||||||
<MonitorHeartIcon />
|
<TimerIcon />
|
||||||
</Avatar>
|
</Avatar>
|
||||||
</ListItemAvatar>
|
</ListItemAvatar>
|
||||||
<ListItemText
|
<ListItemText
|
||||||
primary={LL.STATUS_OF(LL.SYSTEM(0))}
|
primary={LL.UPTIME()}
|
||||||
secondary={`${systemStatus} (${LL.UPTIME()}: ${uptimeText})`}
|
secondary={formatDurationSec(data.uptime)}
|
||||||
/>
|
/>
|
||||||
{me.admin && (
|
{me.admin && (
|
||||||
<Button
|
<Button
|
||||||
startIcon={<PowerSettingsNewIcon />}
|
startIcon={<PowerSettingsNewIcon />}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
color="error"
|
color="error"
|
||||||
onClick={handleRestartClick}
|
onClick={() => setConfirmRestart(true)}
|
||||||
>
|
>
|
||||||
{LL.RESTART()}
|
{LL.RESTART()}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -358,25 +289,29 @@ const SystemStatus = () => {
|
|||||||
icon={MemoryIcon}
|
icon={MemoryIcon}
|
||||||
bgcolor="#68374d"
|
bgcolor="#68374d"
|
||||||
label={LL.HARDWARE()}
|
label={LL.HARDWARE()}
|
||||||
text={freeMemoryText}
|
text={formatNumber(data.free_heap) + ' KB' + ' ' + LL.FREE_MEMORY()}
|
||||||
to="/status/hardwarestatus"
|
to="/status/hardwarestatus"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ListMenuItem
|
<ListMenuItem
|
||||||
disabled={!me.admin}
|
disabled={!me.admin}
|
||||||
icon={DirectionsBusIcon}
|
icon={DirectionsBusIcon}
|
||||||
bgcolor={busStatusHighlight}
|
bgcolor={busStatusHighlight()}
|
||||||
label={LL.DATA_TRAFFIC()}
|
label={LL.DATA_TRAFFIC()}
|
||||||
text={busStatus}
|
text={busStatus()}
|
||||||
to="/status/activity"
|
to="/status/activity"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ListMenuItem
|
<ListMenuItem
|
||||||
disabled={!me.admin}
|
disabled={!me.admin}
|
||||||
icon={networkIcon}
|
icon={
|
||||||
bgcolor={networkStatusHighlight}
|
data.network_status === NetworkConnectionStatus.WIFI_STATUS_CONNECTED
|
||||||
|
? WifiIcon
|
||||||
|
: RouterIcon
|
||||||
|
}
|
||||||
|
bgcolor={networkStatusHighlight()}
|
||||||
label={LL.NETWORK(1)}
|
label={LL.NETWORK(1)}
|
||||||
text={networkStatus}
|
text={networkStatus()}
|
||||||
to="/status/network"
|
to="/status/network"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -385,16 +320,16 @@ const SystemStatus = () => {
|
|||||||
icon={DeviceHubIcon}
|
icon={DeviceHubIcon}
|
||||||
bgcolor={activeHighlight(data.mqtt_status)}
|
bgcolor={activeHighlight(data.mqtt_status)}
|
||||||
label="MQTT"
|
label="MQTT"
|
||||||
text={mqttStatusText}
|
text={data.mqtt_status ? LL.CONNECTED(0) : LL.INACTIVE(0)}
|
||||||
to="/status/mqtt"
|
to="/status/mqtt"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ListMenuItem
|
<ListMenuItem
|
||||||
disabled={!me.admin}
|
disabled={!me.admin}
|
||||||
icon={AccessTimeIcon}
|
icon={AccessTimeIcon}
|
||||||
bgcolor={ntpStatusHighlight}
|
bgcolor={ntpStatusHighlight()}
|
||||||
label="NTP"
|
label="NTP"
|
||||||
text={ntpStatus}
|
text={ntpStatus()}
|
||||||
to="/status/ntp"
|
to="/status/ntp"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -403,7 +338,7 @@ const SystemStatus = () => {
|
|||||||
icon={SettingsInputAntennaIcon}
|
icon={SettingsInputAntennaIcon}
|
||||||
bgcolor={activeHighlight(data.ap_status)}
|
bgcolor={activeHighlight(data.ap_status)}
|
||||||
label={LL.ACCESS_POINT(0)}
|
label={LL.ACCESS_POINT(0)}
|
||||||
text={apStatusText}
|
text={data.ap_status ? LL.ACTIVE() : LL.INACTIVE(0)}
|
||||||
to="/status/ap"
|
to="/status/ap"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -417,33 +352,14 @@ const SystemStatus = () => {
|
|||||||
/>
|
/>
|
||||||
</List>
|
</List>
|
||||||
|
|
||||||
{renderRestartDialog}
|
{renderRestartDialog()}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}, [
|
};
|
||||||
data,
|
|
||||||
LL,
|
|
||||||
firmwareVersion,
|
|
||||||
uptimeText,
|
|
||||||
freeMemoryText,
|
|
||||||
networkIcon,
|
|
||||||
mqttStatusText,
|
|
||||||
apStatusText,
|
|
||||||
busStatus,
|
|
||||||
busStatusHighlight,
|
|
||||||
networkStatusHighlight,
|
|
||||||
networkStatus,
|
|
||||||
ntpStatusHighlight,
|
|
||||||
ntpStatus,
|
|
||||||
activeHighlight,
|
|
||||||
me.admin,
|
|
||||||
handleRestartClick,
|
|
||||||
error,
|
|
||||||
loadData,
|
|
||||||
renderRestartDialog
|
|
||||||
]);
|
|
||||||
|
|
||||||
return <SectionContent>{restarting ? <SystemMonitor /> : content}</SectionContent>;
|
return (
|
||||||
|
<SectionContent>{restarting ? <SystemMonitor /> : content()}</SectionContent>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default SystemStatus;
|
export default SystemStatus;
|
||||||
|
|||||||
@@ -1,11 +1,4 @@
|
|||||||
import {
|
import { useEffect, useRef, useState } from 'react';
|
||||||
memo,
|
|
||||||
useCallback,
|
|
||||||
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';
|
||||||
@@ -38,8 +31,6 @@ import type { LogEntry, LogSettings } from 'types';
|
|||||||
import { LogLevel } from 'types';
|
import { LogLevel } from 'types';
|
||||||
import { updateValueDirty, useRest } from 'utils';
|
import { updateValueDirty, useRest } from 'utils';
|
||||||
|
|
||||||
const MAX_LOG_ENTRIES = 1000; // Limit log entries to prevent memory issues
|
|
||||||
|
|
||||||
const TextColors: Record<LogLevel, string> = {
|
const TextColors: Record<LogLevel, string> = {
|
||||||
[LogLevel.ERROR]: '#ff0000', // red
|
[LogLevel.ERROR]: '#ff0000', // red
|
||||||
[LogLevel.WARNING]: '#ff0000', // red
|
[LogLevel.WARNING]: '#ff0000', // red
|
||||||
@@ -56,6 +47,11 @@ 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:
|
||||||
@@ -75,39 +71,6 @@ 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();
|
||||||
|
|
||||||
@@ -139,89 +102,54 @@ 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 [boxPosition, setBoxPosition] = useState({ top: 0, left: 0 });
|
const [lastId, setLastId] = useState<number>(-1);
|
||||||
|
|
||||||
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 as unknown as Record<string, unknown>,
|
origData,
|
||||||
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)
|
|
||||||
|
|
||||||
// Memoize message handler to avoid recreating on every render
|
|
||||||
const handleLogMessage = useCallback((message: { data: string }) => {
|
|
||||||
const rawData = message.data;
|
|
||||||
const logentry = JSON.parse(rawData) as LogEntry;
|
|
||||||
setLogEntries((log) => {
|
|
||||||
// Skip if this is a duplicate entry (check last entry id)
|
|
||||||
if (log.length > 0) {
|
|
||||||
const lastEntry = log[log.length - 1];
|
|
||||||
if (lastEntry && logentry.i <= lastEntry.i) {
|
|
||||||
return log;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const newLog = [...log, logentry];
|
|
||||||
// Limit log entries to prevent memory issues - only slice when necessary
|
|
||||||
if (newLog.length > MAX_LOG_ENTRIES) {
|
|
||||||
return newLog.slice(-MAX_LOG_ENTRIES);
|
|
||||||
}
|
|
||||||
return newLog;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useSSE(fetchLogES, {
|
useSSE(fetchLogES, {
|
||||||
immediate: true,
|
immediate: true,
|
||||||
interceptByGlobalResponded: false
|
interceptByGlobalResponded: false
|
||||||
})
|
})
|
||||||
.onMessage(handleLogMessage)
|
.onMessage((message: { data: string }) => {
|
||||||
|
const rawData = message.data;
|
||||||
|
const logentry = JSON.parse(rawData) as LogEntry;
|
||||||
|
if (lastId < logentry.i) {
|
||||||
|
setLogEntries((log) => [...log, logentry]);
|
||||||
|
setLastId(logentry.i);
|
||||||
|
}
|
||||||
|
})
|
||||||
.onError(() => {
|
.onError(() => {
|
||||||
toast.error('No connection to Log service');
|
toast.error('No connection to Log service');
|
||||||
});
|
});
|
||||||
|
|
||||||
const onDownload = useCallback(() => {
|
const paddedLevelLabel = (level: LogLevel) => {
|
||||||
const result = logEntries
|
const label = levelLabel(level);
|
||||||
.map((i) => `${i.t} ${levelLabel(i.l)} ${i.i}: [${i.n}] ${i.m}`)
|
return data?.compact ? ' ' + label[0] : label.padStart(8, '\xa0');
|
||||||
.join('\n');
|
};
|
||||||
|
|
||||||
|
const paddedNameLabel = (name: string) => {
|
||||||
|
const label = '[' + name + ']';
|
||||||
|
return data?.compact ? label : label.padEnd(12, '\xa0');
|
||||||
|
};
|
||||||
|
|
||||||
|
const paddedIDLabel = (id: number) => {
|
||||||
|
const label = id + ':';
|
||||||
|
return data?.compact ? label : label.padEnd(7, '\xa0');
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDownload = () => {
|
||||||
|
let result = '';
|
||||||
|
for (const i of logEntries) {
|
||||||
|
result +=
|
||||||
|
i.t + ' ' + levelLabel(i.l) + ' ' + i.i + ': [' + i.n + '] ' + i.m + '\n';
|
||||||
|
}
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.setAttribute(
|
a.setAttribute(
|
||||||
'href',
|
'href',
|
||||||
@@ -231,28 +159,24 @@ const SystemLog = () => {
|
|||||||
document.body.appendChild(a);
|
document.body.appendChild(a);
|
||||||
a.click();
|
a.click();
|
||||||
document.body.removeChild(a);
|
document.body.removeChild(a);
|
||||||
}, [logEntries]);
|
};
|
||||||
|
|
||||||
const saveSettings = useCallback(async () => {
|
const saveSettings = async () => {
|
||||||
await saveData();
|
await saveData();
|
||||||
}, [saveData]);
|
};
|
||||||
|
|
||||||
// handle scrolling - optimized to only scroll when needed
|
// handle scrolling
|
||||||
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) {
|
||||||
const container = logWindowRef.current;
|
ref.current?.scrollIntoView({
|
||||||
if (container) {
|
behavior: 'smooth',
|
||||||
requestAnimationFrame(() => {
|
block: 'end'
|
||||||
container.scrollTop = container.scrollHeight;
|
});
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [logEntries.length, autoscroll]);
|
}, [logEntries.length]);
|
||||||
|
|
||||||
const sendReadCommand = useCallback(() => {
|
const sendReadCommand = () => {
|
||||||
if (readValue === '') {
|
if (readValue === '') {
|
||||||
setReadOpen(!readOpen);
|
setReadOpen(!readOpen);
|
||||||
return;
|
return;
|
||||||
@@ -263,7 +187,7 @@ const SystemLog = () => {
|
|||||||
setReadOpen(false);
|
setReadOpen(false);
|
||||||
setReadValue('');
|
setReadValue('');
|
||||||
}
|
}
|
||||||
}, [readValue, readOpen, send]);
|
};
|
||||||
|
|
||||||
const content = () => {
|
const content = () => {
|
||||||
if (!data) {
|
if (!data) {
|
||||||
@@ -355,7 +279,6 @@ const SystemLog = () => {
|
|||||||
>
|
>
|
||||||
<IconButton
|
<IconButton
|
||||||
disableRipple
|
disableRipple
|
||||||
aria-label={LL.CANCEL()}
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setReadOpen(false);
|
setReadOpen(false);
|
||||||
setReadValue('');
|
setReadValue('');
|
||||||
@@ -381,7 +304,7 @@ const SystemLog = () => {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{data.developer_mode && (
|
{data.developer_mode && (
|
||||||
<IconButton onClick={sendReadCommand} aria-label={LL.EXECUTE()}>
|
<IconButton onClick={sendReadCommand}>
|
||||||
<PlayArrowIcon color="primary" />
|
<PlayArrowIcon color="primary" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
@@ -403,20 +326,27 @@ 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: boxPosition.left,
|
left: () => leftOffset(),
|
||||||
top: boxPosition.top,
|
top: () => topOffset(),
|
||||||
p: 1
|
p: 1
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{logEntries.map((e) => (
|
{logEntries.map((e) => (
|
||||||
<LogEntryItem key={e.i} entry={e} compact={data.compact} />
|
<div key={e.i} style={{ font: '14px monospace', whiteSpace: 'nowrap' }}>
|
||||||
|
<span>{e.t}</span>
|
||||||
|
<span>{paddedLevelLabel(e.l)} </span>
|
||||||
|
<span>{paddedIDLabel(e.i)} </span>
|
||||||
|
<span>{paddedNameLabel(e.n)} </span>
|
||||||
|
<LogEntryLine details={{ level: e.l }} key={e.i}>
|
||||||
|
{e.m}
|
||||||
|
</LogEntryLine>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<div ref={ref} />
|
<div ref={ref} />
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import CancelIcon from '@mui/icons-material/Cancel';
|
import CancelIcon from '@mui/icons-material/Cancel';
|
||||||
import { Box, Button, Typography } from '@mui/material';
|
import { Box, Button, Dialog, DialogContent, 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';
|
||||||
@@ -16,9 +17,11 @@ 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 }),
|
||||||
{
|
{
|
||||||
@@ -29,12 +32,10 @@ const SystemMonitor = () => {
|
|||||||
const { data, send } = useRequest(readSystemStatus, {
|
const { data, send } = useRequest(readSystemStatus, {
|
||||||
force: true,
|
force: true,
|
||||||
async middleware(_, next) {
|
async middleware(_, next) {
|
||||||
// Skip first request to allow AsyncWS to send its response
|
if (count++ >= 1) {
|
||||||
if (!hasInitialized.current) {
|
// skip first request (1 second) to allow AsyncWS to send its response
|
||||||
hasInitialized.current = true;
|
await next();
|
||||||
return; // Don't await next() on first call
|
|
||||||
}
|
}
|
||||||
await next();
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.onSuccess((event) => {
|
.onSuccess((event) => {
|
||||||
@@ -57,89 +58,40 @@ const SystemMonitor = () => {
|
|||||||
void send();
|
void send();
|
||||||
}, 1000); // check every 1 second
|
}, 1000); // check every 1 second
|
||||||
|
|
||||||
const { statusMessage, isUploading, progressValue } = useMemo(() => {
|
const onCancel = async () => {
|
||||||
const status = data?.status;
|
|
||||||
|
|
||||||
let message = '';
|
|
||||||
if (status && status >= SystemStatusCodes.SYSTEM_STATUS_UPLOADING) {
|
|
||||||
message = LL.WAIT_FIRMWARE();
|
|
||||||
} else if (status === SystemStatusCodes.SYSTEM_STATUS_PENDING_RESTART) {
|
|
||||||
message = LL.APPLICATION_RESTARTING();
|
|
||||||
} else if (status === SystemStatusCodes.SYSTEM_STATUS_NORMAL) {
|
|
||||||
message = LL.RESTARTING_PRE();
|
|
||||||
} else if (status === SystemStatusCodes.SYSTEM_STATUS_ERROR_UPLOAD) {
|
|
||||||
message = 'Upload Failed';
|
|
||||||
} else {
|
|
||||||
message = LL.RESTARTING_POST();
|
|
||||||
}
|
|
||||||
|
|
||||||
const uploading =
|
|
||||||
status !== undefined && status >= SystemStatusCodes.SYSTEM_STATUS_UPLOADING;
|
|
||||||
const progress =
|
|
||||||
uploading && status
|
|
||||||
? Math.round(status - SystemStatusCodes.SYSTEM_STATUS_UPLOADING)
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
return {
|
|
||||||
statusMessage: message,
|
|
||||||
isUploading: uploading,
|
|
||||||
progressValue: progress
|
|
||||||
};
|
|
||||||
}, [data?.status, LL]);
|
|
||||||
|
|
||||||
const onCancel = useCallback(async () => {
|
|
||||||
setErrorMessage(undefined);
|
setErrorMessage(undefined);
|
||||||
await setSystemStatus(String(SystemStatusCodes.SYSTEM_STATUS_NORMAL));
|
await setSystemStatus(
|
||||||
|
SystemStatusCodes.SYSTEM_STATUS_NORMAL as unknown as string
|
||||||
|
);
|
||||||
document.location.href = '/';
|
document.location.href = '/';
|
||||||
}, [setSystemStatus]);
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Dialog fullWidth={true} sx={dialogStyle} open={true}>
|
||||||
sx={{
|
<DialogContent dividers>
|
||||||
position: 'fixed',
|
<Box m={0} py={0} display="flex" alignItems="center" flexDirection="column">
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
width: '100vw',
|
|
||||||
height: '100vh',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
backdropFilter: 'blur(8px)'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
width: '30%',
|
|
||||||
minWidth: '300px',
|
|
||||||
maxWidth: '500px',
|
|
||||||
backgroundColor: '#393939',
|
|
||||||
border: 2,
|
|
||||||
borderColor: '#565656',
|
|
||||||
borderRadius: '8px',
|
|
||||||
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.3)',
|
|
||||||
p: 3
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box display="flex" alignItems="center" flexDirection="column">
|
|
||||||
<img
|
|
||||||
src="/app/icon.png"
|
|
||||||
alt="EMS-ESP"
|
|
||||||
style={{ width: '40px', height: '40px', marginBottom: '16px' }}
|
|
||||||
/>
|
|
||||||
<Typography
|
<Typography
|
||||||
color="secondary"
|
color="secondary"
|
||||||
variant="h6"
|
variant="h6"
|
||||||
fontWeight={400}
|
fontWeight={400}
|
||||||
textAlign="center"
|
textAlign="center"
|
||||||
>
|
>
|
||||||
{statusMessage}
|
{data?.status >= SystemStatusCodes.SYSTEM_STATUS_UPLOADING
|
||||||
|
? LL.WAIT_FIRMWARE()
|
||||||
|
: data?.status === SystemStatusCodes.SYSTEM_STATUS_PENDING_RESTART
|
||||||
|
? LL.APPLICATION_RESTARTING()
|
||||||
|
: data?.status === SystemStatusCodes.SYSTEM_STATUS_NORMAL
|
||||||
|
? LL.RESTARTING_PRE()
|
||||||
|
: data?.status === SystemStatusCodes.SYSTEM_STATUS_ERROR_UPLOAD
|
||||||
|
? 'Upload Failed'
|
||||||
|
: LL.RESTARTING_POST()}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
{errorMessage ? (
|
{errorMessage ? (
|
||||||
<MessageBox level="error" message={errorMessage}>
|
<MessageBox my={2} level="error" message={errorMessage}>
|
||||||
<Button
|
<Button
|
||||||
sx={{ ml: 2 }}
|
|
||||||
size="small"
|
size="small"
|
||||||
|
sx={{ ml: 2 }}
|
||||||
startIcon={<CancelIcon />}
|
startIcon={<CancelIcon />}
|
||||||
variant="contained"
|
variant="contained"
|
||||||
color="error"
|
color="error"
|
||||||
@@ -153,16 +105,20 @@ const SystemMonitor = () => {
|
|||||||
<Typography mt={2} variant="h6" fontWeight={400} textAlign="center">
|
<Typography mt={2} variant="h6" fontWeight={400} textAlign="center">
|
||||||
{LL.PLEASE_WAIT()}…
|
{LL.PLEASE_WAIT()}…
|
||||||
</Typography>
|
</Typography>
|
||||||
{isUploading && (
|
{data && data.status >= SystemStatusCodes.SYSTEM_STATUS_UPLOADING && (
|
||||||
<Box width="100%" pl={2} pr={2} py={2}>
|
<Box width="100%" pl={2} pr={2} py={2}>
|
||||||
<LinearProgressWithLabel value={progressValue} />
|
<LinearProgressWithLabel
|
||||||
|
value={Math.round(
|
||||||
|
data?.status - SystemStatusCodes.SYSTEM_STATUS_UPLOADING
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</DialogContent>
|
||||||
</Box>
|
</Dialog>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,4 @@
|
|||||||
import {
|
import { memo, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||||
memo,
|
|
||||||
useCallback,
|
|
||||||
useContext,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
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';
|
||||||
@@ -18,10 +10,12 @@ import WarningIcon from '@mui/icons-material/Warning';
|
|||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
|
Checkbox,
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogActions,
|
DialogActions,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
|
FormControlLabel,
|
||||||
Grid,
|
Grid,
|
||||||
IconButton,
|
IconButton,
|
||||||
Link,
|
Link,
|
||||||
@@ -60,13 +54,6 @@ const DEV_RELNOTES_URL =
|
|||||||
'https://github.com/emsesp/EMS-ESP32/blob/dev/CHANGELOG_LATEST.md';
|
'https://github.com/emsesp/EMS-ESP32/blob/dev/CHANGELOG_LATEST.md';
|
||||||
|
|
||||||
// Types for better type safety
|
// Types for better type safety
|
||||||
interface PartitionData {
|
|
||||||
partition: string;
|
|
||||||
version: string;
|
|
||||||
install_date?: string;
|
|
||||||
size: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface VersionData {
|
interface VersionData {
|
||||||
emsesp_version: string;
|
emsesp_version: string;
|
||||||
arduino_version: string;
|
arduino_version: string;
|
||||||
@@ -74,9 +61,6 @@ interface VersionData {
|
|||||||
flash_chip_size: number;
|
flash_chip_size: number;
|
||||||
psram: boolean;
|
psram: boolean;
|
||||||
build_flags?: string;
|
build_flags?: string;
|
||||||
partition: string;
|
|
||||||
partitions: PartitionData[];
|
|
||||||
developer_mode: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UpgradeCheckData {
|
interface UpgradeCheckData {
|
||||||
@@ -96,10 +80,6 @@ const VersionInfoDialog = memo(
|
|||||||
showVersionInfo,
|
showVersionInfo,
|
||||||
latestVersion,
|
latestVersion,
|
||||||
latestDevVersion,
|
latestDevVersion,
|
||||||
partitionVersion,
|
|
||||||
partition,
|
|
||||||
currentPartition,
|
|
||||||
size,
|
|
||||||
locale,
|
locale,
|
||||||
LL,
|
LL,
|
||||||
onClose
|
onClose
|
||||||
@@ -107,10 +87,6 @@ const VersionInfoDialog = memo(
|
|||||||
showVersionInfo: number;
|
showVersionInfo: number;
|
||||||
latestVersion?: VersionInfo;
|
latestVersion?: VersionInfo;
|
||||||
latestDevVersion?: VersionInfo;
|
latestDevVersion?: VersionInfo;
|
||||||
partitionVersion?: VersionInfo | undefined;
|
|
||||||
partition: string;
|
|
||||||
currentPartition: string;
|
|
||||||
size: number;
|
|
||||||
locale: string;
|
locale: string;
|
||||||
LL: TranslationFunctions;
|
LL: TranslationFunctions;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -118,19 +94,8 @@ const VersionInfoDialog = memo(
|
|||||||
if (showVersionInfo === 0) return null;
|
if (showVersionInfo === 0) return null;
|
||||||
|
|
||||||
const isStable = showVersionInfo === 1;
|
const isStable = showVersionInfo === 1;
|
||||||
const isDev = showVersionInfo === 2;
|
const version = isStable ? latestVersion : latestDevVersion;
|
||||||
const isPartition = showVersionInfo === 3;
|
const relNotesUrl = isStable ? STABLE_RELNOTES_URL : DEV_RELNOTES_URL;
|
||||||
|
|
||||||
const version = isStable
|
|
||||||
? latestVersion
|
|
||||||
: isDev
|
|
||||||
? latestDevVersion
|
|
||||||
: partitionVersion;
|
|
||||||
const relNotesUrl = isStable
|
|
||||||
? STABLE_RELNOTES_URL
|
|
||||||
: isDev
|
|
||||||
? DEV_RELNOTES_URL
|
|
||||||
: '';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog sx={dialogStyle} open={showVersionInfo !== 0} onClose={onClose}>
|
<Dialog sx={dialogStyle} open={showVersionInfo !== 0} onClose={onClose}>
|
||||||
@@ -147,17 +112,14 @@ const VersionInfoDialog = memo(
|
|||||||
borderBottom: 'none',
|
borderBottom: 'none',
|
||||||
pr: 1,
|
pr: 1,
|
||||||
py: 0.5,
|
py: 0.5,
|
||||||
fontSize: 13
|
fontSize: 13,
|
||||||
|
width: 90
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{LL.VERSION()}
|
{LL.TYPE(0)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
|
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
|
||||||
{isPartition
|
{isStable ? LL.STABLE() : LL.DEVELOPMENT()}
|
||||||
? typeof version === 'string'
|
|
||||||
? version
|
|
||||||
: version?.name
|
|
||||||
: version?.name}
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
<TableRow sx={{ height: 24, borderBottom: 'none' }}>
|
<TableRow sx={{ height: 24, borderBottom: 'none' }}>
|
||||||
@@ -169,61 +131,15 @@ const VersionInfoDialog = memo(
|
|||||||
borderBottom: 'none',
|
borderBottom: 'none',
|
||||||
pr: 1,
|
pr: 1,
|
||||||
py: 0.5,
|
py: 0.5,
|
||||||
fontSize: 13,
|
fontSize: 13
|
||||||
width: 140
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isPartition ? LL.TYPE(0) : LL.RELEASE_TYPE()}
|
{LL.VERSION()}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
|
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
|
||||||
{partition === currentPartition && LL.ACTIVE() + ' '}
|
{version?.name}
|
||||||
{isStable
|
|
||||||
? LL.STABLE()
|
|
||||||
: isDev
|
|
||||||
? LL.DEVELOPMENT()
|
|
||||||
: 'Partition ' + LL.VERSION()}
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
{isPartition && (
|
|
||||||
<TableRow sx={{ height: 24, borderBottom: 'none' }}>
|
|
||||||
<TableCell
|
|
||||||
component="th"
|
|
||||||
scope="row"
|
|
||||||
sx={{
|
|
||||||
color: 'lightblue',
|
|
||||||
borderBottom: 'none',
|
|
||||||
pr: 1,
|
|
||||||
py: 0.5,
|
|
||||||
fontSize: 13
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Partition
|
|
||||||
</TableCell>
|
|
||||||
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
|
|
||||||
{partition}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
{isPartition && (
|
|
||||||
<TableRow sx={{ height: 24, borderBottom: 'none' }}>
|
|
||||||
<TableCell
|
|
||||||
component="th"
|
|
||||||
scope="row"
|
|
||||||
sx={{
|
|
||||||
color: 'lightblue',
|
|
||||||
borderBottom: 'none',
|
|
||||||
pr: 1,
|
|
||||||
py: 0.5,
|
|
||||||
fontSize: 13
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Size
|
|
||||||
</TableCell>
|
|
||||||
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
|
|
||||||
{size} KB
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
{version?.published_at && (
|
{version?.published_at && (
|
||||||
<TableRow sx={{ height: 24, borderBottom: 'none' }}>
|
<TableRow sx={{ height: 24, borderBottom: 'none' }}>
|
||||||
<TableCell
|
<TableCell
|
||||||
@@ -237,7 +153,7 @@ const VersionInfoDialog = memo(
|
|||||||
fontSize: 13
|
fontSize: 13
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isPartition ? 'Install Date' : 'Build Date'}
|
Build Date
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
|
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
|
||||||
{prettyDateTime(locale, new Date(version.published_at))}
|
{prettyDateTime(locale, new Date(version.published_at))}
|
||||||
@@ -248,17 +164,15 @@ const VersionInfoDialog = memo(
|
|||||||
</Table>
|
</Table>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
{!isPartition && (
|
<Button
|
||||||
<Button
|
variant="outlined"
|
||||||
variant="outlined"
|
component="a"
|
||||||
component="a"
|
href={relNotesUrl}
|
||||||
href={relNotesUrl}
|
target="_blank"
|
||||||
target="_blank"
|
color="primary"
|
||||||
color="primary"
|
>
|
||||||
>
|
Changelog
|
||||||
Changelog
|
</Button>
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button variant="outlined" onClick={onClose} color="secondary">
|
<Button variant="outlined" onClick={onClose} color="secondary">
|
||||||
{LL.CLOSE()}
|
{LL.CLOSE()}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -304,7 +218,7 @@ const InstallDialog = memo(
|
|||||||
return (
|
return (
|
||||||
<Dialog sx={dialogStyle} open={openInstallDialog} onClose={onClose}>
|
<Dialog sx={dialogStyle} open={openInstallDialog} onClose={onClose}>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
{`${LL.INSTALL()} ${fetchDevVersion ? LL.DEVELOPMENT() : LL.STABLE()} Firmware`}
|
{`${LL.UPDATE()} ${fetchDevVersion ? LL.DEVELOPMENT() : LL.STABLE()} Firmware`}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogContent dividers>
|
<DialogContent dividers>
|
||||||
<Typography mb={2}>
|
<Typography mb={2}>
|
||||||
@@ -349,54 +263,6 @@ const InstallDialog = memo(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const InstallPartitionDialog = memo(
|
|
||||||
({
|
|
||||||
openInstallPartitionDialog,
|
|
||||||
version,
|
|
||||||
partition,
|
|
||||||
LL,
|
|
||||||
onClose,
|
|
||||||
onInstall
|
|
||||||
}: {
|
|
||||||
openInstallPartitionDialog: boolean;
|
|
||||||
version: string;
|
|
||||||
partition: string;
|
|
||||||
LL: TranslationFunctions;
|
|
||||||
onClose: () => void;
|
|
||||||
onInstall: (partition: string) => void;
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<Dialog sx={dialogStyle} open={openInstallPartitionDialog} onClose={onClose}>
|
|
||||||
<DialogTitle>
|
|
||||||
{LL.INSTALL()} {LL.STORED_VERSIONS()}
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogContent dividers>
|
|
||||||
<Typography mb={2}>{LL.INSTALL_VERSION(LL.INSTALL(), version)}</Typography>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
<Button
|
|
||||||
startIcon={<CancelIcon />}
|
|
||||||
variant="outlined"
|
|
||||||
onClick={onClose}
|
|
||||||
color="secondary"
|
|
||||||
>
|
|
||||||
{LL.CANCEL()}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
startIcon={<WarningIcon color="warning" />}
|
|
||||||
variant="outlined"
|
|
||||||
onClick={() => onInstall(partition)}
|
|
||||||
color="primary"
|
|
||||||
>
|
|
||||||
{LL.INSTALL()}
|
|
||||||
</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Helper function moved outside component
|
// Helper function moved outside component
|
||||||
const getPlatform = (data: VersionData): string => {
|
const getPlatform = (data: VersionData): string => {
|
||||||
return `${data.esp_platform}-${data.flash_chip_size >= 16384 ? '16MB' : '4MB'}${data.psram ? '+' : ''}`;
|
return `${data.esp_platform}-${data.flash_chip_size >= 16384 ? '16MB' : '4MB'}${data.psram ? '+' : ''}`;
|
||||||
@@ -409,14 +275,6 @@ const Version = () => {
|
|||||||
// State management
|
// State management
|
||||||
const [restarting, setRestarting] = useState<boolean>(false);
|
const [restarting, setRestarting] = useState<boolean>(false);
|
||||||
const [openInstallDialog, setOpenInstallDialog] = useState<boolean>(false);
|
const [openInstallDialog, setOpenInstallDialog] = useState<boolean>(false);
|
||||||
|
|
||||||
const [partitionVersion, setPartitionVersion] = useState<VersionInfo | undefined>(
|
|
||||||
undefined
|
|
||||||
);
|
|
||||||
const [partition, setPartition] = useState<string>('');
|
|
||||||
const [openInstallPartitionDialog, setOpenInstallPartitionDialog] =
|
|
||||||
useState<boolean>(false);
|
|
||||||
|
|
||||||
const [usingDevVersion, setUsingDevVersion] = useState<boolean>(false);
|
const [usingDevVersion, setUsingDevVersion] = useState<boolean>(false);
|
||||||
const [fetchDevVersion, setFetchDevVersion] = useState<boolean>(false);
|
const [fetchDevVersion, setFetchDevVersion] = useState<boolean>(false);
|
||||||
const [devUpgradeAvailable, setDevUpgradeAvailable] = useState<boolean>(false);
|
const [devUpgradeAvailable, setDevUpgradeAvailable] = useState<boolean>(false);
|
||||||
@@ -424,9 +282,9 @@ const Version = () => {
|
|||||||
useState<boolean>(false);
|
useState<boolean>(false);
|
||||||
const [internetLive, setInternetLive] = useState<boolean>(false);
|
const [internetLive, setInternetLive] = useState<boolean>(false);
|
||||||
const [downloadOnly, setDownloadOnly] = useState<boolean>(false);
|
const [downloadOnly, setDownloadOnly] = useState<boolean>(false);
|
||||||
const [showVersionInfo, setShowVersionInfo] = useState<number>(0); // 1 = stable, 2 = dev, 3 = partition
|
const [showVersionInfo, setShowVersionInfo] = useState<number>(0);
|
||||||
const [firmwareSize, setFirmwareSize] = useState<number>(0);
|
|
||||||
|
|
||||||
|
// API calls with optimized error handling
|
||||||
const { send: sendCheckUpgrade } = useRequest(
|
const { send: sendCheckUpgrade } = useRequest(
|
||||||
(versions: string) => callAction({ action: 'checkUpgrade', param: versions }),
|
(versions: string) => callAction({ action: 'checkUpgrade', param: versions }),
|
||||||
{ immediate: false }
|
{ immediate: false }
|
||||||
@@ -436,13 +294,6 @@ const Version = () => {
|
|||||||
setStableUpgradeAvailable(data.stable_upgradeable);
|
setStableUpgradeAvailable(data.stable_upgradeable);
|
||||||
});
|
});
|
||||||
|
|
||||||
const { send: sendSetPartition } = useRequest(
|
|
||||||
(partition: string) => callAction({ action: 'setPartition', param: partition }),
|
|
||||||
{ immediate: false }
|
|
||||||
).onError((error) => {
|
|
||||||
toast.error(String(error.error?.message || 'An error occurred'));
|
|
||||||
});
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data,
|
data,
|
||||||
send: loadData,
|
send: loadData,
|
||||||
@@ -469,38 +320,18 @@ const Version = () => {
|
|||||||
|
|
||||||
// Memoized values
|
// Memoized values
|
||||||
const platform = useMemo(() => (data ? getPlatform(data) : ''), [data]);
|
const platform = useMemo(() => (data ? getPlatform(data) : ''), [data]);
|
||||||
|
const isDev = useMemo(
|
||||||
// Memoize filtered partitions to avoid recomputing on every render
|
() => data?.emsesp_version.includes('dev') ?? false,
|
||||||
const otherPartitions = useMemo(
|
[data?.emsesp_version]
|
||||||
() => data?.partitions.filter((p) => p.partition !== data.partition) ?? [],
|
|
||||||
[data]
|
|
||||||
);
|
|
||||||
|
|
||||||
const setPartitionVersionInfo = useCallback(
|
|
||||||
(partition: string) => {
|
|
||||||
setShowVersionInfo(3);
|
|
||||||
|
|
||||||
// search for the partition in the data.partitions array
|
|
||||||
const partitionData = data?.partitions.find((p) => p.partition === partition);
|
|
||||||
if (partitionData) {
|
|
||||||
setPartitionVersion({
|
|
||||||
name: partitionData.version,
|
|
||||||
published_at: partitionData.install_date ?? ''
|
|
||||||
});
|
|
||||||
setPartition(partitionData.partition);
|
|
||||||
setFirmwareSize(partitionData.size);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[data]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const doRestart = useCallback(async () => {
|
const doRestart = useCallback(async () => {
|
||||||
|
setRestarting(true);
|
||||||
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
|
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
|
||||||
(error: Error) => {
|
(error: Error) => {
|
||||||
toast.error(error.message);
|
toast.error(error.message);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
setRestarting(true);
|
|
||||||
}, [sendAPI]);
|
}, [sendAPI]);
|
||||||
|
|
||||||
const installFirmwareURL = useCallback(
|
const installFirmwareURL = useCallback(
|
||||||
@@ -508,28 +339,9 @@ const Version = () => {
|
|||||||
await sendUploadURL(url).catch((error: Error) => {
|
await sendUploadURL(url).catch((error: Error) => {
|
||||||
toast.error(error.message);
|
toast.error(error.message);
|
||||||
});
|
});
|
||||||
await doRestart();
|
|
||||||
},
|
|
||||||
[sendUploadURL, doRestart]
|
|
||||||
);
|
|
||||||
|
|
||||||
const installPartitionFirmware = useCallback(
|
|
||||||
async (partition: string) => {
|
|
||||||
await sendSetPartition(partition).catch((error: Error) => {
|
|
||||||
toast.error(error.message);
|
|
||||||
});
|
|
||||||
setRestarting(true);
|
setRestarting(true);
|
||||||
},
|
},
|
||||||
[sendSetPartition]
|
[sendUploadURL]
|
||||||
);
|
|
||||||
|
|
||||||
const showPartitionDialog = useCallback(
|
|
||||||
(version: string, partition: string, install_date: string) => {
|
|
||||||
setOpenInstallPartitionDialog(true);
|
|
||||||
setPartitionVersion({ name: version, published_at: install_date });
|
|
||||||
setPartition(partition);
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const showFirmwareDialog = useCallback((useDevVersion: boolean) => {
|
const showFirmwareDialog = useCallback((useDevVersion: boolean) => {
|
||||||
@@ -541,21 +353,13 @@ const Version = () => {
|
|||||||
setOpenInstallDialog(false);
|
setOpenInstallDialog(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const closeInstallPartitionDialog = useCallback(() => {
|
|
||||||
setOpenInstallPartitionDialog(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleVersionInfoClose = useCallback(() => {
|
const handleVersionInfoClose = useCallback(() => {
|
||||||
setShowVersionInfo(0);
|
setShowVersionInfo(0);
|
||||||
setPartitionVersion(undefined);
|
|
||||||
setPartition('');
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// check upgrades - only once when both versions are available
|
// Effect for checking upgrades
|
||||||
const upgradeCheckedRef = useRef(false);
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (latestVersion && latestDevVersion && !upgradeCheckedRef.current) {
|
if (latestVersion && latestDevVersion) {
|
||||||
upgradeCheckedRef.current = true;
|
|
||||||
const versions = `${latestDevVersion.name},${latestVersion.name}`;
|
const versions = `${latestDevVersion.name},${latestVersion.name}`;
|
||||||
sendCheckUpgrade(versions)
|
sendCheckUpgrade(versions)
|
||||||
.catch((error: Error) => {
|
.catch((error: Error) => {
|
||||||
@@ -595,7 +399,7 @@ const Version = () => {
|
|||||||
{LL.LATEST_VERSION(usingDevVersion ? LL.DEVELOPMENT() : LL.STABLE())}
|
{LL.LATEST_VERSION(usingDevVersion ? LL.DEVELOPMENT() : LL.STABLE())}
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
sx={{ ml: 1 }}
|
sx={{ ml: 2 }}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
size="small"
|
size="small"
|
||||||
onClick={() => showFirmwareDialog(showingDev)}
|
onClick={() => showFirmwareDialog(showingDev)}
|
||||||
@@ -610,7 +414,7 @@ const Version = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
sx={{ ml: 1 }}
|
sx={{ ml: 2 }}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
color={choice === LL.UPDATE_AVAILABLE() ? 'success' : 'warning'}
|
color={choice === LL.UPDATE_AVAILABLE() ? 'success' : 'warning'}
|
||||||
size="small"
|
size="small"
|
||||||
@@ -638,13 +442,14 @@ const Version = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Box p={2} border="1px solid grey" borderRadius={2}>
|
<Box p={2} border="1px solid grey" borderRadius={2}>
|
||||||
<Typography mb={1} variant="h6" color="primary">
|
<Typography mb={2} variant="h6" color="primary">
|
||||||
{LL.THIS_VERSION()}
|
{LL.THIS_VERSION()}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Grid
|
<Grid
|
||||||
container
|
container
|
||||||
direction="row"
|
direction="row"
|
||||||
|
rowSpacing={1}
|
||||||
sx={{
|
sx={{
|
||||||
justifyContent: 'flex-start',
|
justifyContent: 'flex-start',
|
||||||
alignItems: 'baseline'
|
alignItems: 'baseline'
|
||||||
@@ -661,12 +466,6 @@ const Version = () => {
|
|||||||
({data.build_flags})
|
({data.build_flags})
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
<IconButton
|
|
||||||
onClick={() => setPartitionVersionInfo(data.partition)}
|
|
||||||
aria-label={LL.FIRMWARE_VERSION_INFO()}
|
|
||||||
>
|
|
||||||
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
|
|
||||||
</IconButton>
|
|
||||||
</Typography>
|
</Typography>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
@@ -699,11 +498,57 @@ const Version = () => {
|
|||||||
</Typography>
|
</Typography>
|
||||||
</Typography>
|
</Typography>
|
||||||
</Grid>
|
</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>
|
</Grid>
|
||||||
|
|
||||||
{internetLive ? (
|
{internetLive ? (
|
||||||
<>
|
<>
|
||||||
<Typography mt={4} mb={1} variant="h6" color="primary">
|
<Typography mt={2} mb={2} variant="h6" color="primary">
|
||||||
{LL.AVAILABLE_VERSION()}
|
{LL.AVAILABLE_VERSION()}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
@@ -716,57 +561,13 @@ const Version = () => {
|
|||||||
alignItems: 'baseline'
|
alignItems: 'baseline'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{otherPartitions.length > 0 && data.developer_mode && (
|
|
||||||
<>
|
|
||||||
<Grid size={{ xs: 4, md: 2 }}>
|
|
||||||
<Typography color="secondary">
|
|
||||||
{LL.STORED_VERSIONS()}
|
|
||||||
</Typography>
|
|
||||||
</Grid>
|
|
||||||
<Grid size={{ xs: 8, md: 10 }}>
|
|
||||||
{otherPartitions.map((partition) => (
|
|
||||||
<Typography key={partition.partition} mb={1}>
|
|
||||||
{partition.version}
|
|
||||||
<IconButton
|
|
||||||
onClick={() =>
|
|
||||||
setPartitionVersionInfo(partition.partition)
|
|
||||||
}
|
|
||||||
aria-label={LL.FIRMWARE_VERSION_INFO()}
|
|
||||||
>
|
|
||||||
<InfoOutlinedIcon
|
|
||||||
color="primary"
|
|
||||||
sx={{ fontSize: 18 }}
|
|
||||||
/>
|
|
||||||
</IconButton>
|
|
||||||
<Button
|
|
||||||
sx={{ ml: 0 }}
|
|
||||||
variant="outlined"
|
|
||||||
size="small"
|
|
||||||
onClick={() =>
|
|
||||||
showPartitionDialog(
|
|
||||||
partition.version,
|
|
||||||
partition.partition,
|
|
||||||
partition.install_date ?? ''
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{LL.INSTALL()}
|
|
||||||
</Button>
|
|
||||||
</Typography>
|
|
||||||
))}
|
|
||||||
</Grid>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<Grid size={{ xs: 4, md: 2 }}>
|
<Grid size={{ xs: 4, md: 2 }}>
|
||||||
<Typography color="secondary">{LL.STABLE()}</Typography>
|
<Typography color="secondary">{LL.STABLE()}</Typography>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid size={{ xs: 8, md: 10 }}>
|
<Grid size={{ xs: 8, md: 10 }}>
|
||||||
<Typography>
|
<Typography>
|
||||||
{latestVersion?.name}
|
{latestVersion?.name}
|
||||||
<IconButton
|
<IconButton onClick={() => setShowVersionInfo(1)}>
|
||||||
onClick={() => setShowVersionInfo(1)}
|
|
||||||
aria-label={LL.FIRMWARE_VERSION_INFO()}
|
|
||||||
>
|
|
||||||
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
|
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
{showButtons(false)}
|
{showButtons(false)}
|
||||||
@@ -779,10 +580,7 @@ const Version = () => {
|
|||||||
<Grid size={{ xs: 8, md: 10 }}>
|
<Grid size={{ xs: 8, md: 10 }}>
|
||||||
<Typography>
|
<Typography>
|
||||||
{latestDevVersion?.name}
|
{latestDevVersion?.name}
|
||||||
<IconButton
|
<IconButton onClick={() => setShowVersionInfo(2)}>
|
||||||
onClick={() => setShowVersionInfo(2)}
|
|
||||||
aria-label={LL.FIRMWARE_VERSION_INFO()}
|
|
||||||
>
|
|
||||||
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
|
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
{showButtons(true)}
|
{showButtons(true)}
|
||||||
@@ -802,11 +600,7 @@ const Version = () => {
|
|||||||
showVersionInfo={showVersionInfo}
|
showVersionInfo={showVersionInfo}
|
||||||
latestVersion={latestVersion}
|
latestVersion={latestVersion}
|
||||||
latestDevVersion={latestDevVersion}
|
latestDevVersion={latestDevVersion}
|
||||||
partitionVersion={partitionVersion}
|
|
||||||
locale={locale}
|
locale={locale}
|
||||||
partition={partition}
|
|
||||||
currentPartition={data?.partition ?? ''}
|
|
||||||
size={firmwareSize}
|
|
||||||
LL={LL}
|
LL={LL}
|
||||||
onClose={handleVersionInfoClose}
|
onClose={handleVersionInfoClose}
|
||||||
/>
|
/>
|
||||||
@@ -821,14 +615,6 @@ const Version = () => {
|
|||||||
onClose={closeInstallDialog}
|
onClose={closeInstallDialog}
|
||||||
onInstall={installFirmwareURL}
|
onInstall={installFirmwareURL}
|
||||||
/>
|
/>
|
||||||
<InstallPartitionDialog
|
|
||||||
openInstallPartitionDialog={openInstallPartitionDialog}
|
|
||||||
version={partitionVersion?.name || ''}
|
|
||||||
partition={partition}
|
|
||||||
LL={LL}
|
|
||||||
onClose={closeInstallPartitionDialog}
|
|
||||||
onInstall={installPartitionFirmware}
|
|
||||||
/>
|
|
||||||
<Typography sx={{ pt: 2, pb: 2 }} variant="h6" color="primary">
|
<Typography sx={{ pt: 2, pb: 2 }} variant="h6" color="primary">
|
||||||
{LL.UPLOAD()}
|
{LL.UPLOAD()}
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -844,6 +630,7 @@ const Version = () => {
|
|||||||
loadData,
|
loadData,
|
||||||
LL,
|
LL,
|
||||||
platform,
|
platform,
|
||||||
|
isDev,
|
||||||
internetLive,
|
internetLive,
|
||||||
latestVersion,
|
latestVersion,
|
||||||
latestDevVersion,
|
latestDevVersion,
|
||||||
@@ -857,15 +644,7 @@ const Version = () => {
|
|||||||
handleVersionInfoClose,
|
handleVersionInfoClose,
|
||||||
closeInstallDialog,
|
closeInstallDialog,
|
||||||
installFirmwareURL,
|
installFirmwareURL,
|
||||||
doRestart,
|
doRestart
|
||||||
otherPartitions,
|
|
||||||
setPartitionVersionInfo,
|
|
||||||
showPartitionDialog,
|
|
||||||
partitionVersion,
|
|
||||||
partition,
|
|
||||||
firmwareSize,
|
|
||||||
closeInstallPartitionDialog,
|
|
||||||
installPartitionFirmware
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return <SectionContent>{restarting ? <SystemMonitor /> : content}</SectionContent>;
|
return <SectionContent>{restarting ? <SystemMonitor /> : content}</SectionContent>;
|
||||||
|
|||||||
@@ -19,4 +19,6 @@ const ButtonRow = memo<BoxProps>(({ children, ...rest }) => (
|
|||||||
</Box>
|
</Box>
|
||||||
));
|
));
|
||||||
|
|
||||||
|
ButtonRow.displayName = 'ButtonRow';
|
||||||
|
|
||||||
export default ButtonRow;
|
export default ButtonRow;
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { type FC, memo, useMemo } from 'react';
|
import type { FC } 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 } from '@mui/material';
|
import type { BoxProps, SvgIconProps, Theme } from '@mui/material';
|
||||||
|
|
||||||
type MessageBoxLevel = 'warning' | 'success' | 'info' | 'error';
|
type MessageBoxLevel = 'warning' | 'success' | 'info' | 'error';
|
||||||
|
|
||||||
@@ -14,18 +14,22 @@ export interface MessageBoxProps extends BoxProps {
|
|||||||
message?: string;
|
message?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LEVEL_ICONS: Record<MessageBoxLevel, React.ComponentType<SvgIconProps>> = {
|
const LEVEL_ICONS: {
|
||||||
|
[type in MessageBoxLevel]: React.ComponentType<SvgIconProps>;
|
||||||
|
} = {
|
||||||
success: CheckCircleOutlineOutlinedIcon,
|
success: CheckCircleOutlineOutlinedIcon,
|
||||||
info: InfoOutlinedIcon,
|
info: InfoOutlinedIcon,
|
||||||
warning: ReportProblemOutlinedIcon,
|
warning: ReportProblemOutlinedIcon,
|
||||||
error: ErrorIcon
|
error: ErrorIcon
|
||||||
};
|
};
|
||||||
|
|
||||||
const LEVEL_PALETTE_PATHS: Record<MessageBoxLevel, string> = {
|
const LEVEL_BACKGROUNDS: {
|
||||||
success: 'success.dark',
|
[type in MessageBoxLevel]: (theme: Theme) => string;
|
||||||
info: 'info.main',
|
} = {
|
||||||
warning: 'warning.dark',
|
success: (theme: Theme) => theme.palette.success.dark,
|
||||||
error: 'error.dark'
|
info: (theme: Theme) => theme.palette.info.main,
|
||||||
|
warning: (theme: Theme) => theme.palette.warning.dark,
|
||||||
|
error: (theme: Theme) => theme.palette.error.dark
|
||||||
};
|
};
|
||||||
|
|
||||||
const MessageBox: FC<MessageBoxProps> = ({
|
const MessageBox: FC<MessageBoxProps> = ({
|
||||||
@@ -36,38 +40,25 @@ const MessageBox: FC<MessageBoxProps> = ({
|
|||||||
...rest
|
...rest
|
||||||
}) => {
|
}) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
const Icon = LEVEL_ICONS[level];
|
||||||
const { Icon, backgroundColor } = useMemo(() => {
|
const backgroundColor = LEVEL_BACKGROUNDS[level](theme);
|
||||||
const Icon = LEVEL_ICONS[level];
|
const color = 'white';
|
||||||
const palettePath = LEVEL_PALETTE_PATHS[level];
|
|
||||||
const [key, shade] = palettePath.split('.') as [
|
|
||||||
keyof typeof theme.palette,
|
|
||||||
string
|
|
||||||
];
|
|
||||||
const paletteKey = theme.palette[key] as unknown as Record<string, string>;
|
|
||||||
const backgroundColor = paletteKey[shade];
|
|
||||||
|
|
||||||
return { Icon, backgroundColor };
|
|
||||||
}, [level, theme]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
p={2}
|
p={2}
|
||||||
display="flex"
|
display="flex"
|
||||||
alignItems="center"
|
alignItems="center"
|
||||||
borderRadius={1}
|
borderRadius={1}
|
||||||
sx={{ backgroundColor, color: 'white', ...sx }}
|
sx={{ backgroundColor, color, ...sx }}
|
||||||
{...rest}
|
{...rest}
|
||||||
>
|
>
|
||||||
<Icon />
|
<Icon />
|
||||||
{(message || children) && (
|
<Typography sx={{ ml: 2 }} variant="body1">
|
||||||
<Typography sx={{ ml: 2 }} variant="body1">
|
{message ?? ''}
|
||||||
{message}
|
</Typography>
|
||||||
{children}
|
{children}
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default memo(MessageBox);
|
export default MessageBox;
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
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';
|
||||||
|
|
||||||
@@ -10,19 +8,16 @@ interface SectionContentProps extends RequiredChildrenProps {
|
|||||||
id?: string;
|
id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract styles to avoid recreation on every render
|
const SectionContent: FC<SectionContentProps> = (props) => {
|
||||||
const paperStyles: SxProps<Theme> = {
|
const { children, id } = props;
|
||||||
p: 1.5,
|
return (
|
||||||
m: 1.5,
|
<Paper
|
||||||
borderRadius: 3,
|
id={id}
|
||||||
border: '1px solid rgb(65, 65, 65)'
|
sx={{ p: 1.5, m: 1.5, borderRadius: 3, border: '1px solid rgb(65, 65, 65)' }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const SectionContent: FC<SectionContentProps> = ({ children, id }) => (
|
export default SectionContent;
|
||||||
<Paper id={id} sx={paperStyles}>
|
|
||||||
{children}
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Memoize to prevent unnecessary re-renders
|
|
||||||
export default memo(SectionContent);
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// use direct exports to reduce bundle size
|
// Optimized exports - 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,4 +1,3 @@
|
|||||||
import { memo } from 'react';
|
|
||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
|
|
||||||
import { FormControlLabel } from '@mui/material';
|
import { FormControlLabel } from '@mui/material';
|
||||||
@@ -10,4 +9,4 @@ const BlockFormControlLabel: FC<FormControlLabelProps> = (props) => (
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
export default memo(BlockFormControlLabel);
|
export default BlockFormControlLabel;
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { memo, useCallback, useContext, useMemo } from 'react';
|
import { type ChangeEventHandler, 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';
|
||||||
|
|
||||||
@@ -19,66 +17,73 @@ 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, LL } = useContext(I18nContext);
|
const { setLocale, locale } = useContext(I18nContext);
|
||||||
|
|
||||||
const onLocaleSelected: ChangeEventHandler<HTMLInputElement> = useCallback(
|
const onLocaleSelected: ChangeEventHandler<HTMLInputElement> = async ({
|
||||||
async ({ target }) => {
|
target
|
||||||
const loc = target.value as Locales;
|
}) => {
|
||||||
localStorage.setItem('lang', loc);
|
const loc = target.value as Locales;
|
||||||
await loadLocaleAsync(loc);
|
localStorage.setItem('lang', loc);
|
||||||
setLocale(loc);
|
await loadLocaleAsync(loc);
|
||||||
},
|
setLocale(loc);
|
||||||
[setLocale]
|
};
|
||||||
);
|
|
||||||
|
|
||||||
// Memoize menu items to prevent recreation on every render
|
|
||||||
const menuItems = useMemo(
|
|
||||||
() =>
|
|
||||||
LANGUAGE_OPTIONS.map(({ key, flag, label }) => (
|
|
||||||
<MenuItem key={key} value={key}>
|
|
||||||
<img src={flag} style={flagStyle} alt={label} />
|
|
||||||
{label}
|
|
||||||
</MenuItem>
|
|
||||||
)),
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<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
|
||||||
>
|
>
|
||||||
{menuItems}
|
<MenuItem key="cz" value="cz">
|
||||||
|
<img src={CZflag} style={{ width: 16, verticalAlign: 'middle' }} />
|
||||||
|
CZ
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem key="de" value="de">
|
||||||
|
<img src={DEflag} style={{ width: 16, verticalAlign: 'middle' }} />
|
||||||
|
DE
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem key="en" value="en">
|
||||||
|
<img src={GBflag} style={{ width: 16, verticalAlign: 'middle' }} />
|
||||||
|
EN
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem key="fr" value="fr">
|
||||||
|
<img src={FRflag} style={{ width: 16, verticalAlign: 'middle' }} />
|
||||||
|
FR
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem key="it" value="it">
|
||||||
|
<img src={ITflag} style={{ width: 16, verticalAlign: 'middle' }} />
|
||||||
|
IT
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem key="nl" value="nl">
|
||||||
|
<img src={NLflag} style={{ width: 16, verticalAlign: 'middle' }} />
|
||||||
|
NL
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem key="no" value="no">
|
||||||
|
<img src={NOflag} style={{ width: 16, verticalAlign: 'middle' }} />
|
||||||
|
NO
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem key="pl" value="pl">
|
||||||
|
<img src={PLflag} style={{ width: 16, verticalAlign: 'middle' }} />
|
||||||
|
PL
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem key="sk" value="sk">
|
||||||
|
<img src={SKflag} style={{ width: 16, verticalAlign: 'middle' }} />
|
||||||
|
SK
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem key="sv" value="sv">
|
||||||
|
<img src={SVflag} style={{ width: 16, verticalAlign: 'middle' }} />
|
||||||
|
SV
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem key="tr" value="tr">
|
||||||
|
<img src={TRflag} style={{ width: 16, verticalAlign: 'middle' }} />
|
||||||
|
TR
|
||||||
|
</MenuItem>
|
||||||
</TextField>
|
</TextField>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default memo(LanguageSelector);
|
export default LanguageSelector;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { memo, useCallback, useState } from 'react';
|
import { useState } from 'react';
|
||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
|
|
||||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||||
@@ -13,10 +13,6 @@ type ValidatedPasswordFieldProps = Omit<ValidatedTextFieldProps, 'type'>;
|
|||||||
const ValidatedPasswordField: FC<ValidatedPasswordFieldProps> = ({ ...props }) => {
|
const ValidatedPasswordField: FC<ValidatedPasswordFieldProps> = ({ ...props }) => {
|
||||||
const [showPassword, setShowPassword] = useState<boolean>(false);
|
const [showPassword, setShowPassword] = useState<boolean>(false);
|
||||||
|
|
||||||
const togglePasswordVisibility = useCallback(() => {
|
|
||||||
setShowPassword((prev) => !prev);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ValidatedTextField
|
<ValidatedTextField
|
||||||
{...props}
|
{...props}
|
||||||
@@ -25,11 +21,7 @@ const ValidatedPasswordField: FC<ValidatedPasswordFieldProps> = ({ ...props }) =
|
|||||||
input: {
|
input: {
|
||||||
endAdornment: (
|
endAdornment: (
|
||||||
<InputAdornment position="end">
|
<InputAdornment position="end">
|
||||||
<IconButton
|
<IconButton onClick={() => setShowPassword(!showPassword)} edge="end">
|
||||||
onClick={togglePasswordVisibility}
|
|
||||||
edge="end"
|
|
||||||
aria-label="Password visibility"
|
|
||||||
>
|
|
||||||
{showPassword ? <VisibilityIcon /> : <VisibilityOffIcon />}
|
{showPassword ? <VisibilityIcon /> : <VisibilityOffIcon />}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</InputAdornment>
|
</InputAdornment>
|
||||||
@@ -40,4 +32,4 @@ const ValidatedPasswordField: FC<ValidatedPasswordFieldProps> = ({ ...props }) =
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default memo(ValidatedPasswordField);
|
export default ValidatedPasswordField;
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { memo } from 'react';
|
|
||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
|
|
||||||
import { FormHelperText, TextField } from '@mui/material';
|
import { FormHelperText, TextField } from '@mui/material';
|
||||||
@@ -15,42 +14,18 @@ export type ValidatedTextFieldProps = ValidatedFieldProps & TextFieldProps;
|
|||||||
|
|
||||||
const ValidatedTextField: FC<ValidatedTextFieldProps> = ({
|
const ValidatedTextField: FC<ValidatedTextFieldProps> = ({
|
||||||
fieldErrors,
|
fieldErrors,
|
||||||
sx,
|
|
||||||
...rest
|
...rest
|
||||||
}) => {
|
}) => {
|
||||||
const errors = fieldErrors?.[rest.name];
|
const errors = fieldErrors?.[rest.name];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TextField
|
<TextField error={!!errors} {...rest} />
|
||||||
error={!!errors}
|
|
||||||
{...rest}
|
|
||||||
aria-label="Error"
|
|
||||||
sx={{
|
|
||||||
'& .MuiInputBase-input.Mui-disabled': {
|
|
||||||
WebkitTextFillColor: 'grey'
|
|
||||||
},
|
|
||||||
...(sx || {})
|
|
||||||
}}
|
|
||||||
{...(rest.disabled && {
|
|
||||||
slotProps: {
|
|
||||||
select: {
|
|
||||||
IconComponent: () => null
|
|
||||||
},
|
|
||||||
inputLabel: {
|
|
||||||
style: { color: 'grey' }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})}
|
|
||||||
color={rest.disabled ? 'secondary' : 'primary'}
|
|
||||||
/>
|
|
||||||
{errors?.map((e) => (
|
{errors?.map((e) => (
|
||||||
<FormHelperText key={e.message} sx={{ color: 'rgb(250, 95, 84)' }}>
|
<FormHelperText key={e.message}>{e.message}</FormHelperText>
|
||||||
{e.message}
|
|
||||||
</FormHelperText>
|
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default memo(ValidatedTextField);
|
export default ValidatedTextField;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { LayoutContext } from './context';
|
|||||||
|
|
||||||
export const DRAWER_WIDTH = 210;
|
export const DRAWER_WIDTH = 210;
|
||||||
|
|
||||||
const LayoutComponent: FC<RequiredChildrenProps> = ({ children }) => {
|
const Layout: FC<RequiredChildrenProps> = memo(({ children }) => {
|
||||||
const [mobileOpen, setMobileOpen] = useState(false);
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
const [title, setTitle] = useState(PROJECT_NAME);
|
const [title, setTitle] = useState(PROJECT_NAME);
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
@@ -41,8 +41,6 @@ const LayoutComponent: FC<RequiredChildrenProps> = ({ children }) => {
|
|||||||
</Box>
|
</Box>
|
||||||
</LayoutContext.Provider>
|
</LayoutContext.Provider>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
const Layout = memo(LayoutComponent);
|
|
||||||
|
|
||||||
export default Layout;
|
export default Layout;
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import { memo, useCallback, useMemo } from 'react';
|
|
||||||
import { Link, useLocation, useNavigate } from 'react-router';
|
import { Link, useLocation, useNavigate } from 'react-router';
|
||||||
|
|
||||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||||
import MenuIcon from '@mui/icons-material/Menu';
|
import MenuIcon from '@mui/icons-material/Menu';
|
||||||
import { AppBar, IconButton, Toolbar, Typography } from '@mui/material';
|
import { AppBar, IconButton, Toolbar, Typography } from '@mui/material';
|
||||||
import type { SxProps, Theme } from '@mui/material/styles';
|
|
||||||
|
|
||||||
import { useI18nContext } from 'i18n/i18n-react';
|
import { useI18nContext } from 'i18n/i18n-react';
|
||||||
|
|
||||||
@@ -15,47 +13,30 @@ interface LayoutAppBarProps {
|
|||||||
onToggleDrawer: () => void;
|
onToggleDrawer: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract static styles
|
const LayoutAppBar = ({ title, onToggleDrawer }: LayoutAppBarProps) => {
|
||||||
const appBarStyles: SxProps<Theme> = {
|
|
||||||
width: { md: `calc(100% - ${DRAWER_WIDTH}px)` },
|
|
||||||
ml: { md: `${DRAWER_WIDTH}px` },
|
|
||||||
boxShadow: 'none',
|
|
||||||
backgroundColor: '#2e586a'
|
|
||||||
};
|
|
||||||
|
|
||||||
const menuButtonStyles: SxProps<Theme> = {
|
|
||||||
mr: 2,
|
|
||||||
display: { md: 'none' }
|
|
||||||
};
|
|
||||||
|
|
||||||
const backButtonStyles: SxProps<Theme> = {
|
|
||||||
mr: 1,
|
|
||||||
fontSize: 20,
|
|
||||||
verticalAlign: 'middle'
|
|
||||||
};
|
|
||||||
|
|
||||||
const LayoutAppBarComponent = ({ title, onToggleDrawer }: LayoutAppBarProps) => {
|
|
||||||
const { LL } = useI18nContext();
|
const { LL } = useI18nContext();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
|
||||||
|
|
||||||
const pathnames = useMemo(
|
const pathnames = useLocation()
|
||||||
() => location.pathname.split('/').filter((x) => x),
|
.pathname.split('/')
|
||||||
[location.pathname]
|
.filter((x) => x);
|
||||||
);
|
|
||||||
|
|
||||||
const handleBackClick = useCallback(() => {
|
|
||||||
void navigate('/' + pathnames[0]);
|
|
||||||
}, [navigate, pathnames]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppBar position="fixed" sx={appBarStyles}>
|
<AppBar
|
||||||
|
position="fixed"
|
||||||
|
sx={{
|
||||||
|
width: { md: `calc(100% - ${DRAWER_WIDTH}px)` },
|
||||||
|
ml: { md: `${DRAWER_WIDTH}px` },
|
||||||
|
boxShadow: 'none',
|
||||||
|
backgroundColor: '#2e586a'
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Toolbar>
|
<Toolbar>
|
||||||
<IconButton
|
<IconButton
|
||||||
color="inherit"
|
color="inherit"
|
||||||
edge="start"
|
edge="start"
|
||||||
onClick={onToggleDrawer}
|
onClick={onToggleDrawer}
|
||||||
sx={menuButtonStyles}
|
sx={{ mr: 2, display: { md: 'none' } }}
|
||||||
>
|
>
|
||||||
<MenuIcon />
|
<MenuIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
@@ -63,10 +44,10 @@ const LayoutAppBarComponent = ({ title, onToggleDrawer }: LayoutAppBarProps) =>
|
|||||||
{pathnames.length > 1 && (
|
{pathnames.length > 1 && (
|
||||||
<>
|
<>
|
||||||
<IconButton
|
<IconButton
|
||||||
sx={backButtonStyles}
|
sx={{ mr: 1, fontSize: 20, verticalAlign: 'middle' }}
|
||||||
color="primary"
|
color="primary"
|
||||||
edge="start"
|
edge="start"
|
||||||
onClick={handleBackClick}
|
onClick={() => navigate('/' + pathnames[0])}
|
||||||
>
|
>
|
||||||
<ArrowBackIcon />
|
<ArrowBackIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
@@ -89,6 +70,4 @@ const LayoutAppBarComponent = ({ title, onToggleDrawer }: LayoutAppBarProps) =>
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const LayoutAppBar = memo(LayoutAppBarComponent);
|
|
||||||
|
|
||||||
export default LayoutAppBar;
|
export default LayoutAppBar;
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { memo, useMemo } from 'react';
|
|
||||||
|
|
||||||
import { Box, Divider, Drawer, Toolbar, Typography, styled } from '@mui/material';
|
import { Box, Divider, Drawer, Toolbar, Typography, styled } from '@mui/material';
|
||||||
|
|
||||||
import { PROJECT_NAME } from 'env';
|
import { PROJECT_NAME } from 'env';
|
||||||
@@ -23,23 +21,19 @@ interface LayoutDrawerProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LayoutDrawerComponent = ({ mobileOpen, onClose }: LayoutDrawerProps) => {
|
const LayoutDrawerProps = ({ mobileOpen, onClose }: LayoutDrawerProps) => {
|
||||||
// Memoize drawer content to prevent unnecessary re-renders
|
const drawer = (
|
||||||
const drawer = useMemo(
|
<>
|
||||||
() => (
|
<Toolbar disableGutters>
|
||||||
<>
|
<Box display="flex" alignItems="center" px={2}>
|
||||||
<Toolbar disableGutters>
|
<LayoutDrawerLogo src="/app/icon.png" alt={PROJECT_NAME} />
|
||||||
<Box display="flex" alignItems="center" px={2}>
|
<Typography variant="h6">{PROJECT_NAME}</Typography>
|
||||||
<LayoutDrawerLogo src="/app/icon.png" alt={PROJECT_NAME} />
|
</Box>
|
||||||
<Typography variant="h6">{PROJECT_NAME}</Typography>
|
<Divider absolute />
|
||||||
</Box>
|
</Toolbar>
|
||||||
<Divider absolute />
|
<Divider />
|
||||||
</Toolbar>
|
<LayoutMenu />
|
||||||
<Divider />
|
</>
|
||||||
<LayoutMenu />
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
[]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -72,6 +66,4 @@ const LayoutDrawerComponent = ({ mobileOpen, onClose }: LayoutDrawerProps) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const LayoutDrawer = memo(LayoutDrawerComponent);
|
export default LayoutDrawerProps;
|
||||||
|
|
||||||
export default LayoutDrawer;
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { memo, useCallback, useContext, useState } from 'react';
|
import { useContext, useState } from 'react';
|
||||||
|
|
||||||
import AccountCircleIcon from '@mui/icons-material/AccountCircle';
|
import AccountCircleIcon from '@mui/icons-material/AccountCircle';
|
||||||
import AssessmentIcon from '@mui/icons-material/Assessment';
|
import AssessmentIcon from '@mui/icons-material/Assessment';
|
||||||
@@ -7,24 +7,47 @@ import ConstructionIcon from '@mui/icons-material/Construction';
|
|||||||
import KeyboardArrowDown from '@mui/icons-material/KeyboardArrowDown';
|
import KeyboardArrowDown from '@mui/icons-material/KeyboardArrowDown';
|
||||||
import LiveHelpIcon from '@mui/icons-material/LiveHelp';
|
import LiveHelpIcon from '@mui/icons-material/LiveHelp';
|
||||||
import MoreTimeIcon from '@mui/icons-material/MoreTime';
|
import MoreTimeIcon from '@mui/icons-material/MoreTime';
|
||||||
|
import PersonIcon from '@mui/icons-material/Person';
|
||||||
import PlaylistAddIcon from '@mui/icons-material/PlaylistAdd';
|
import PlaylistAddIcon from '@mui/icons-material/PlaylistAdd';
|
||||||
import SensorsIcon from '@mui/icons-material/Sensors';
|
import SensorsIcon from '@mui/icons-material/Sensors';
|
||||||
import SettingsIcon from '@mui/icons-material/Settings';
|
import SettingsIcon from '@mui/icons-material/Settings';
|
||||||
import StarIcon from '@mui/icons-material/Star';
|
import StarIcon from '@mui/icons-material/Star';
|
||||||
import { Box, Divider, List, ListItemButton, ListItemText } from '@mui/material';
|
import {
|
||||||
|
Avatar,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Divider,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemButton,
|
||||||
|
ListItemIcon,
|
||||||
|
ListItemText,
|
||||||
|
Popover
|
||||||
|
} from '@mui/material';
|
||||||
|
|
||||||
|
import { LanguageSelector } from 'components/inputs';
|
||||||
import LayoutMenuItem from 'components/layout/LayoutMenuItem';
|
import LayoutMenuItem from 'components/layout/LayoutMenuItem';
|
||||||
import { AuthenticatedContext } from 'contexts/authentication';
|
import { AuthenticatedContext } from 'contexts/authentication';
|
||||||
import { useI18nContext } from 'i18n/i18n-react';
|
import { useI18nContext } from 'i18n/i18n-react';
|
||||||
|
|
||||||
const LayoutMenuComponent = () => {
|
const LayoutMenu = () => {
|
||||||
const { me } = useContext(AuthenticatedContext);
|
const { me, signOut } = useContext(AuthenticatedContext);
|
||||||
const { LL } = useI18nContext();
|
const { LL } = useI18nContext();
|
||||||
|
|
||||||
|
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null);
|
||||||
|
|
||||||
|
const open = Boolean(anchorEl);
|
||||||
|
const id = anchorEl ? 'app-menu-popover' : undefined;
|
||||||
|
|
||||||
const [menuOpen, setMenuOpen] = useState(true);
|
const [menuOpen, setMenuOpen] = useState(true);
|
||||||
|
|
||||||
const handleMenuToggle = useCallback(() => {
|
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
setMenuOpen((prev) => !prev);
|
setAnchorEl(event.currentTarget);
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setAnchorEl(null);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -41,8 +64,10 @@ const LayoutMenuComponent = () => {
|
|||||||
>
|
>
|
||||||
<ListItemButton
|
<ListItemButton
|
||||||
alignItems="flex-start"
|
alignItems="flex-start"
|
||||||
onClick={handleMenuToggle}
|
onClick={() => setMenuOpen(!menuOpen)}
|
||||||
sx={{
|
sx={{
|
||||||
|
pt: 2.5,
|
||||||
|
pb: menuOpen ? 0 : 2.5,
|
||||||
'&:hover, &:focus': { '& svg': { opacity: 1 } }
|
'&:hover, &:focus': { '& svg': { opacity: 1 } }
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -109,13 +134,66 @@ const LayoutMenuComponent = () => {
|
|||||||
to="/settings"
|
to="/settings"
|
||||||
/>
|
/>
|
||||||
<LayoutMenuItem icon={LiveHelpIcon} label={LL.HELP()} to={`/help`} />
|
<LayoutMenuItem icon={LiveHelpIcon} label={LL.HELP()} to={`/help`} />
|
||||||
<Divider />
|
|
||||||
<LayoutMenuItem icon={AccountCircleIcon} label={me.username} to={`/user`} />
|
|
||||||
</List>
|
</List>
|
||||||
|
<Divider />
|
||||||
|
<List>
|
||||||
|
<ListItem disablePadding>
|
||||||
|
<ListItemButton component="button" onClick={handleClick}>
|
||||||
|
<ListItemIcon sx={{ color: '#9e9e9e' }}>
|
||||||
|
<AccountCircleIcon />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText sx={{ color: '#2196f3' }}>{me.username}</ListItemText>
|
||||||
|
</ListItemButton>
|
||||||
|
</ListItem>
|
||||||
|
</List>
|
||||||
|
<Popover
|
||||||
|
id={id}
|
||||||
|
open={open}
|
||||||
|
anchorEl={anchorEl}
|
||||||
|
onClose={handleClose}
|
||||||
|
anchorOrigin={{
|
||||||
|
vertical: 'bottom',
|
||||||
|
horizontal: 'center'
|
||||||
|
}}
|
||||||
|
transformOrigin={{
|
||||||
|
vertical: 'top',
|
||||||
|
horizontal: 'center'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
padding={2}
|
||||||
|
justifyContent="center"
|
||||||
|
flexDirection="column"
|
||||||
|
sx={{
|
||||||
|
borderRadius: 3,
|
||||||
|
border: '3px solid grey'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
fullWidth
|
||||||
|
color="primary"
|
||||||
|
onClick={() => signOut(true)}
|
||||||
|
>
|
||||||
|
{LL.SIGN_OUT()}
|
||||||
|
</Button>
|
||||||
|
<List>
|
||||||
|
<ListItem disablePadding>
|
||||||
|
<Avatar sx={{ bgcolor: '#9e9e9e', color: 'white' }}>
|
||||||
|
<PersonIcon />
|
||||||
|
</Avatar>
|
||||||
|
<ListItemText
|
||||||
|
sx={{ pl: 2, color: '#2196f3' }}
|
||||||
|
primary={me.username}
|
||||||
|
secondary={'(' + (me.admin ? LL.ADMINISTRATOR() : LL.GUEST()) + ')'}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
</List>
|
||||||
|
<LanguageSelector />
|
||||||
|
</Box>
|
||||||
|
</Popover>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const LayoutMenu = memo(LayoutMenuComponent);
|
|
||||||
|
|
||||||
export default LayoutMenu;
|
export default LayoutMenu;
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { memo, useMemo } from 'react';
|
|
||||||
import { Link, useLocation } from 'react-router';
|
import { Link, useLocation } from 'react-router';
|
||||||
|
|
||||||
import { ListItemButton, ListItemIcon, ListItemText } from '@mui/material';
|
import { ListItemButton, ListItemIcon, ListItemText } from '@mui/material';
|
||||||
import type { SvgIconProps, SxProps, Theme } from '@mui/material';
|
import type { SvgIconProps } from '@mui/material';
|
||||||
|
|
||||||
import { routeMatches } from 'utils';
|
import { routeMatches } from 'utils';
|
||||||
|
|
||||||
@@ -13,7 +12,7 @@ interface LayoutMenuItemProps {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LayoutMenuItemComponent = ({
|
const LayoutMenuItem = ({
|
||||||
icon: Icon,
|
icon: Icon,
|
||||||
label,
|
label,
|
||||||
to,
|
to,
|
||||||
@@ -21,51 +20,7 @@ const LayoutMenuItemComponent = ({
|
|||||||
}: LayoutMenuItemProps) => {
|
}: LayoutMenuItemProps) => {
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
|
|
||||||
const selected = useMemo(() => routeMatches(to, pathname), [to, pathname]);
|
const selected = routeMatches(to, pathname);
|
||||||
|
|
||||||
// Memoize dynamic styles based on selected state
|
|
||||||
const buttonStyles: SxProps<Theme> = useMemo(
|
|
||||||
() => ({
|
|
||||||
transition: 'all 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)',
|
|
||||||
backgroundColor: selected ? 'rgba(144, 202, 249, 0.1)' : 'transparent',
|
|
||||||
borderRadius: '8px',
|
|
||||||
margin: '2px 8px',
|
|
||||||
'&:hover': {
|
|
||||||
backgroundColor: 'rgba(68, 82, 211, 0.39)'
|
|
||||||
},
|
|
||||||
'&::before': {
|
|
||||||
content: '""',
|
|
||||||
position: 'absolute',
|
|
||||||
left: 0,
|
|
||||||
top: 0,
|
|
||||||
bottom: 0,
|
|
||||||
width: selected ? '4px' : '0px',
|
|
||||||
backgroundColor: '#90caf9',
|
|
||||||
borderRadius: '0 2px 2px 0',
|
|
||||||
transition: 'width 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)'
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
[selected]
|
|
||||||
);
|
|
||||||
|
|
||||||
const iconStyles: SxProps<Theme> = useMemo(
|
|
||||||
() => ({
|
|
||||||
color: selected ? '#90caf9' : '#9e9e9e',
|
|
||||||
transition: 'color 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)',
|
|
||||||
transform: selected ? 'scale(1.1)' : 'scale(1)',
|
|
||||||
transitionProperty: 'color, transform'
|
|
||||||
}),
|
|
||||||
[selected]
|
|
||||||
);
|
|
||||||
|
|
||||||
const textStyles: SxProps<Theme> = useMemo(
|
|
||||||
() => ({
|
|
||||||
color: selected ? '#90caf9' : '#f5f5f5',
|
|
||||||
transition: 'color 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)',
|
|
||||||
transitionProperty: 'color, font-weight'
|
|
||||||
}),
|
|
||||||
[selected]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListItemButton
|
<ListItemButton
|
||||||
@@ -73,16 +28,51 @@ const LayoutMenuItemComponent = ({
|
|||||||
to={to}
|
to={to}
|
||||||
disabled={disabled || false}
|
disabled={disabled || false}
|
||||||
selected={selected}
|
selected={selected}
|
||||||
sx={buttonStyles}
|
sx={{
|
||||||
|
transition: 'all 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)',
|
||||||
|
transform: selected ? 'scale(1.02)' : 'scale(1)',
|
||||||
|
backgroundColor: selected ? 'rgba(144, 202, 249, 0.1)' : 'transparent',
|
||||||
|
borderRadius: '8px',
|
||||||
|
margin: '2px 8px',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: 'rgba(68, 82, 211, 0.39)',
|
||||||
|
transform: selected ? 'scale(1.02)' : 'scale(1.01)'
|
||||||
|
},
|
||||||
|
'&::before': {
|
||||||
|
content: '""',
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: selected ? '4px' : '0px',
|
||||||
|
backgroundColor: '#90caf9',
|
||||||
|
borderRadius: '0 2px 2px 0',
|
||||||
|
transition: 'width 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)'
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<ListItemIcon sx={iconStyles}>
|
<ListItemIcon
|
||||||
|
sx={{
|
||||||
|
color: selected ? '#90caf9' : '#9e9e9e',
|
||||||
|
transition: 'color 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)',
|
||||||
|
transform: selected ? 'scale(1.1)' : 'scale(1)',
|
||||||
|
transitionProperty: 'color, transform'
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Icon />
|
<Icon />
|
||||||
</ListItemIcon>
|
</ListItemIcon>
|
||||||
<ListItemText sx={textStyles}>{label}</ListItemText>
|
<ListItemText
|
||||||
|
sx={{
|
||||||
|
color: selected ? '#90caf9' : '#f5f5f5',
|
||||||
|
transition: 'color 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)',
|
||||||
|
// fontWeight: selected ? '600' : '400',
|
||||||
|
transitionProperty: 'color, font-weight'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</ListItemText>
|
||||||
</ListItemButton>
|
</ListItemButton>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const LayoutMenuItem = memo(LayoutMenuItemComponent);
|
|
||||||
|
|
||||||
export default LayoutMenuItem;
|
export default LayoutMenuItem;
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { memo } from 'react';
|
|
||||||
import type { CSSProperties } from 'react';
|
|
||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
|
|
||||||
import NavigateNextIcon from '@mui/icons-material/NavigateNext';
|
import NavigateNextIcon from '@mui/icons-material/NavigateNext';
|
||||||
@@ -22,14 +20,8 @@ interface ListMenuItemProps {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const iconStyles: CSSProperties = {
|
function RenderIcon({ icon: Icon, bgcolor, label, text }: ListMenuItemProps) {
|
||||||
justifyContent: 'right',
|
return (
|
||||||
color: 'lightblue',
|
|
||||||
verticalAlign: 'middle'
|
|
||||||
};
|
|
||||||
|
|
||||||
const RenderIcon = memo(
|
|
||||||
({ icon: Icon, bgcolor, label, text }: ListMenuItemProps) => (
|
|
||||||
<>
|
<>
|
||||||
<ListItemAvatar>
|
<ListItemAvatar>
|
||||||
<Avatar sx={{ bgcolor, color: 'white' }}>
|
<Avatar sx={{ bgcolor, color: 'white' }}>
|
||||||
@@ -38,8 +30,8 @@ const RenderIcon = memo(
|
|||||||
</ListItemAvatar>
|
</ListItemAvatar>
|
||||||
<ListItemText primary={label} secondary={text} />
|
<ListItemText primary={label} secondary={text} />
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
);
|
}
|
||||||
|
|
||||||
const LayoutMenuItem = ({
|
const LayoutMenuItem = ({
|
||||||
icon,
|
icon,
|
||||||
@@ -54,7 +46,13 @@ const LayoutMenuItem = ({
|
|||||||
<ListItem
|
<ListItem
|
||||||
disablePadding
|
disablePadding
|
||||||
secondaryAction={
|
secondaryAction={
|
||||||
<ListItemIcon style={iconStyles}>
|
<ListItemIcon
|
||||||
|
style={{
|
||||||
|
justifyContent: 'right',
|
||||||
|
color: 'lightblue',
|
||||||
|
verticalAlign: 'middle'
|
||||||
|
}}
|
||||||
|
>
|
||||||
<NavigateNextIcon />
|
<NavigateNextIcon />
|
||||||
</ListItemIcon>
|
</ListItemIcon>
|
||||||
}
|
}
|
||||||
@@ -81,4 +79,4 @@ const LayoutMenuItem = ({
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
export default memo(LayoutMenuItem);
|
export default LayoutMenuItem;
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { memo } from 'react';
|
|
||||||
|
|
||||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||||
import { Box, Button, CircularProgress } from '@mui/material';
|
import { Box, Button, CircularProgress } from '@mui/material';
|
||||||
|
|
||||||
@@ -11,12 +9,12 @@ interface FormLoaderProps {
|
|||||||
onRetry?: () => void;
|
onRetry?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FormLoaderComponent = ({ errorMessage, onRetry }: FormLoaderProps) => {
|
const FormLoader = ({ errorMessage, onRetry }: FormLoaderProps) => {
|
||||||
const { LL } = useI18nContext();
|
const { LL } = useI18nContext();
|
||||||
|
|
||||||
if (errorMessage) {
|
if (errorMessage) {
|
||||||
return (
|
return (
|
||||||
<MessageBox level="error" message={errorMessage}>
|
<MessageBox my={2} level="error" message={errorMessage}>
|
||||||
{onRetry && (
|
{onRetry && (
|
||||||
<Button
|
<Button
|
||||||
sx={{ ml: 2 }}
|
sx={{ ml: 2 }}
|
||||||
@@ -40,6 +38,4 @@ const FormLoaderComponent = ({ errorMessage, onRetry }: FormLoaderProps) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const FormLoader = memo(FormLoaderComponent);
|
|
||||||
|
|
||||||
export default FormLoader;
|
export default FormLoader;
|
||||||
|
|||||||
@@ -1,21 +1,20 @@
|
|||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
|
|
||||||
import { Box, CircularProgress } from '@mui/material';
|
import { Box, CircularProgress } from '@mui/material';
|
||||||
import type { SxProps, Theme } from '@mui/material';
|
|
||||||
|
|
||||||
const containerStyles: SxProps<Theme> = {
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
minHeight: '200px',
|
|
||||||
backgroundColor: 'background.default',
|
|
||||||
borderRadius: 1,
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: 'divider'
|
|
||||||
};
|
|
||||||
|
|
||||||
const LazyLoader = memo(() => (
|
const LazyLoader = memo(() => (
|
||||||
<Box sx={containerStyles}>
|
<Box
|
||||||
|
display="flex"
|
||||||
|
justifyContent="center"
|
||||||
|
alignItems="center"
|
||||||
|
minHeight="200px"
|
||||||
|
sx={{
|
||||||
|
backgroundColor: 'background.default',
|
||||||
|
borderRadius: 1,
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'divider'
|
||||||
|
}}
|
||||||
|
>
|
||||||
<CircularProgress size={40} />
|
<CircularProgress size={40} />
|
||||||
</Box>
|
</Box>
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -1,17 +1,10 @@
|
|||||||
import { memo } from 'react';
|
|
||||||
|
|
||||||
import { Box, CircularProgress } from '@mui/material';
|
import { Box, CircularProgress } from '@mui/material';
|
||||||
import type { SxProps, Theme } from '@mui/material';
|
import type { Theme } from '@mui/material';
|
||||||
|
|
||||||
interface LoadingSpinnerProps {
|
interface LoadingSpinnerProps {
|
||||||
height?: number | string;
|
height?: number | string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const circularProgressStyles: SxProps<Theme> = (theme: Theme) => ({
|
|
||||||
margin: theme.spacing(4),
|
|
||||||
color: theme.palette.text.secondary
|
|
||||||
});
|
|
||||||
|
|
||||||
const LoadingSpinner = ({ height = '100%' }: LoadingSpinnerProps) => {
|
const LoadingSpinner = ({ height = '100%' }: LoadingSpinnerProps) => {
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
@@ -22,9 +15,15 @@ const LoadingSpinner = ({ height = '100%' }: LoadingSpinnerProps) => {
|
|||||||
padding={2}
|
padding={2}
|
||||||
height={height}
|
height={height}
|
||||||
>
|
>
|
||||||
<CircularProgress sx={circularProgressStyles} size={100} />
|
<CircularProgress
|
||||||
|
sx={(theme: Theme) => ({
|
||||||
|
margin: theme.spacing(4),
|
||||||
|
color: theme.palette.text.secondary
|
||||||
|
})}
|
||||||
|
size={100}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default memo(LoadingSpinner);
|
export default LoadingSpinner;
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { memo, useCallback } from 'react';
|
|
||||||
import type { Blocker } from 'react-router';
|
import type { Blocker } from 'react-router';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -15,23 +14,23 @@ import { useI18nContext } from 'i18n/i18n-react';
|
|||||||
const BlockNavigation = ({ blocker }: { blocker: Blocker }) => {
|
const BlockNavigation = ({ blocker }: { blocker: Blocker }) => {
|
||||||
const { LL } = useI18nContext();
|
const { LL } = useI18nContext();
|
||||||
|
|
||||||
const handleReset = useCallback(() => {
|
|
||||||
blocker.reset?.();
|
|
||||||
}, [blocker]);
|
|
||||||
|
|
||||||
const handleProceed = useCallback(() => {
|
|
||||||
blocker.proceed?.();
|
|
||||||
}, [blocker]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog sx={dialogStyle} open={blocker.state === 'blocked'}>
|
<Dialog sx={dialogStyle} open={blocker.state === 'blocked'}>
|
||||||
<DialogTitle>{LL.BLOCK_NAVIGATE_1()}</DialogTitle>
|
<DialogTitle>{LL.BLOCK_NAVIGATE_1()}</DialogTitle>
|
||||||
<DialogContent dividers>{LL.BLOCK_NAVIGATE_2()}</DialogContent>
|
<DialogContent dividers>{LL.BLOCK_NAVIGATE_2()}</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<Button variant="outlined" onClick={handleReset} color="secondary">
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => blocker.reset?.()}
|
||||||
|
color="secondary"
|
||||||
|
>
|
||||||
{LL.STAY()}
|
{LL.STAY()}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="contained" onClick={handleProceed} color="primary">
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={() => blocker.proceed?.()}
|
||||||
|
color="primary"
|
||||||
|
>
|
||||||
{LL.LEAVE()}
|
{LL.LEAVE()}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
@@ -39,4 +38,4 @@ const BlockNavigation = ({ blocker }: { blocker: Blocker }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default memo(BlockNavigation);
|
export default BlockNavigation;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { memo, useContext } from 'react';
|
import { useContext } from 'react';
|
||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import { Navigate } from 'react-router';
|
import { Navigate } from 'react-router';
|
||||||
|
|
||||||
@@ -14,4 +14,4 @@ const RequireAdmin: FC<RequiredChildrenProps> = ({ children }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default memo(RequireAdmin);
|
export default RequireAdmin;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { memo, useContext, useEffect } from 'react';
|
import { useContext, useEffect } from 'react';
|
||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import { Navigate, useLocation } from 'react-router';
|
import { Navigate, useLocation } from 'react-router';
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@ const RequireAuthenticated: FC<RequiredChildrenProps> = ({ children }) => {
|
|||||||
if (!authenticationContext.me) {
|
if (!authenticationContext.me) {
|
||||||
storeLoginRedirect(location);
|
storeLoginRedirect(location);
|
||||||
}
|
}
|
||||||
}, [authenticationContext.me, location]);
|
});
|
||||||
|
|
||||||
return authenticationContext.me ? (
|
return authenticationContext.me ? (
|
||||||
<AuthenticatedContext.Provider
|
<AuthenticatedContext.Provider
|
||||||
@@ -31,4 +31,4 @@ const RequireAuthenticated: FC<RequiredChildrenProps> = ({ children }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default memo(RequireAuthenticated);
|
export default RequireAuthenticated;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { memo, useContext } from 'react';
|
import { useContext } from 'react';
|
||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import { Navigate } from 'react-router';
|
import { Navigate } from 'react-router';
|
||||||
|
|
||||||
@@ -16,4 +16,4 @@ const RequireUnauthenticated: FC<RequiredChildrenProps> = ({ children }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default memo(RequireUnauthenticated);
|
export default RequireUnauthenticated;
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { memo, useCallback } from 'react';
|
|
||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
|
|
||||||
@@ -16,12 +15,9 @@ const RouterTabs: FC<RouterTabsProps> = ({ value, children }) => {
|
|||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const smallDown = useMediaQuery(theme.breakpoints.down('sm'));
|
const smallDown = useMediaQuery(theme.breakpoints.down('sm'));
|
||||||
|
|
||||||
const handleTabChange = useCallback(
|
const handleTabChange = (_event: unknown, path: string) => {
|
||||||
(_event: unknown, path: string) => {
|
void navigate(path);
|
||||||
void navigate(path);
|
};
|
||||||
},
|
|
||||||
[navigate]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tabs
|
<Tabs
|
||||||
@@ -34,4 +30,4 @@ const RouterTabs: FC<RouterTabsProps> = ({ value, children }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default memo(RouterTabs);
|
export default RouterTabs;
|
||||||
|
|||||||
@@ -13,14 +13,8 @@ export const verifyAuthorization = () =>
|
|||||||
export const signIn = (request: SignInRequest) =>
|
export const signIn = (request: SignInRequest) =>
|
||||||
alovaInstance.Post<SignInResponse>('/rest/signIn', request);
|
alovaInstance.Post<SignInResponse>('/rest/signIn', request);
|
||||||
|
|
||||||
// Cache storage reference to avoid repeated checks
|
|
||||||
let cachedStorage: Storage | undefined;
|
|
||||||
|
|
||||||
export function getStorage() {
|
export function getStorage() {
|
||||||
if (!cachedStorage) {
|
return localStorage || sessionStorage;
|
||||||
cachedStorage = localStorage || sessionStorage;
|
|
||||||
}
|
|
||||||
return cachedStorage;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function storeLoginRedirect(location?: { pathname: string; search: string }) {
|
export function storeLoginRedirect(location?: { pathname: string; search: string }) {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user