mirror of
https://github.com/emsesp/EMS-ESP32.git
synced 2026-09-13 13:44:10 +00:00
Compare commits
920
Commits
v3.8.1
..
afb8efec53
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afb8efec53 | ||
|
|
84b23f19e5 | ||
|
|
668d59a2cd | ||
|
|
88f258d7e9 | ||
|
|
c45441e3be | ||
|
|
2994535158 | ||
|
|
fdfb560cf9 | ||
|
|
d56c5897fa | ||
|
|
cec0fbc95d | ||
|
|
8f118e07a3 | ||
|
|
b05ae6dbbb | ||
|
|
469ca8bf05 | ||
|
|
edd1677526 | ||
|
|
c363e0e2c8 | ||
|
|
d397a14b41 | ||
|
|
822096b635 | ||
|
|
0defebfdb6 | ||
|
|
574c4e45d5 | ||
|
|
48c118fed7 | ||
|
|
ef4335156c | ||
|
|
4d4269cdf7 | ||
|
|
95a8b78d1d | ||
|
|
982b0f304e | ||
|
|
1d9a0b6257 | ||
|
|
52486ed5b4 | ||
|
|
73f2634acc | ||
|
|
2a604008ff | ||
|
|
cd0a51f843 | ||
|
|
34cd93b7be | ||
|
|
d47915d815 | ||
|
|
cb97de7706 | ||
|
|
dbc5a73583 | ||
|
|
4d25c69c84 | ||
|
|
b430866bbb | ||
|
|
553752a7ca | ||
|
|
9c992d8fda | ||
|
|
e4d127db67 | ||
|
|
5b41217a67 | ||
|
|
d913824b35 | ||
|
|
ae3443c637 | ||
|
|
50c34c13f0 | ||
|
|
d8fab910a8 | ||
|
|
4231192991 | ||
|
|
b47e7d1dd6 | ||
|
|
fb55327373 | ||
|
|
4aab9e095a | ||
|
|
12ae358434 | ||
|
|
e22b82e913 | ||
|
|
285361c09f | ||
|
|
16caff789d | ||
|
|
835a8762f6 | ||
|
|
90f0c9c01b | ||
|
|
bde9ab53a1 | ||
|
|
901721fb99 | ||
|
|
dac1e1a69c | ||
|
|
1b2371c67a | ||
|
|
0f396200a2 | ||
|
|
1956ff8386 | ||
|
|
3a81c70018 | ||
|
|
7212439f1b | ||
|
|
6f7f343abd | ||
|
|
42e6e31476 | ||
|
|
96cf019f0a | ||
|
|
59b0417297 | ||
|
|
2369277a9e | ||
|
|
e166db8b70 | ||
|
|
36884867f7 | ||
|
|
7fb8eb3592 | ||
|
|
2e988df817 | ||
|
|
483e124484 | ||
|
|
e0f59a4c31 | ||
|
|
7eba8acbb4 | ||
|
|
34db81ab4b | ||
|
|
e9a422bd27 | ||
|
|
4a8ad71b63 | ||
|
|
d0e6a603bb | ||
|
|
36b4dd55a7 | ||
|
|
341a4d4e1d | ||
|
|
a61745b710 | ||
|
|
b1ebd81ca2 | ||
|
|
6487a3be70 | ||
|
|
2eb804de0b | ||
|
|
6801f7b8cf | ||
|
|
c9b90e282f | ||
|
|
11eeb521d5 | ||
|
|
1ff53e6ac7 | ||
|
|
67c8b8429b | ||
|
|
c624e82ada | ||
|
|
722e4665af | ||
|
|
ba7c9cdc1b | ||
|
|
8c647331f2 | ||
|
|
9a98f6b2ff | ||
|
|
1631b607c5 | ||
|
|
4646572c80 | ||
|
|
9b5f9e7a28 | ||
|
|
4575f62e36 | ||
|
|
aa8481c42e | ||
|
|
454b45ee65 | ||
|
|
500f9ef5c5 | ||
|
|
96f2e6d5a7 | ||
|
|
a469507fb6 | ||
|
|
2bef33f81a | ||
|
|
e584adaa07 | ||
|
|
faa327cd08 | ||
|
|
ec4572c9fc | ||
|
|
1398aac828 | ||
|
|
a25f73f0ec | ||
|
|
3d402b0c94 | ||
|
|
c91488d2fe | ||
|
|
ac36b072e8 | ||
|
|
77fac47584 | ||
|
|
c349c2ad0b | ||
|
|
2c268bfc06 | ||
|
|
0a66991902 | ||
|
|
d6fcf16257 | ||
|
|
3ab402c56d | ||
|
|
9cab3b480e | ||
|
|
edfbbe68b0 | ||
|
|
96e8a0d916 | ||
|
|
92f36c6849 | ||
|
|
8c90d10601 | ||
|
|
0ae46e9382 | ||
|
|
7989ab5b3f | ||
|
|
b7aa153f84 | ||
|
|
c760218024 | ||
|
|
3ee5b1ea1d | ||
|
|
9d5edd8e08 | ||
|
|
5397d5d33a | ||
|
|
bacd3faac2 | ||
|
|
2b187fb848 | ||
|
|
4a12066e37 | ||
|
|
a80ec9cf7e | ||
|
|
bc960980eb | ||
|
|
b82f8c979f | ||
|
|
5199375088 | ||
|
|
0ff0ff7d6c | ||
|
|
7721993937 | ||
|
|
ddd0c39e4a | ||
|
|
6512eac9af | ||
|
|
2bb88a6d3a | ||
|
|
342878f628 | ||
|
|
4e47be7fa6 | ||
|
|
2b25ae18d1 | ||
|
|
e9b300f20b | ||
|
|
98c9e011bc | ||
|
|
cb1cb356ce | ||
|
|
20fc5ede78 | ||
|
|
a20d1d8970 | ||
|
|
a647e16f68 | ||
|
|
49ab5e3d72 | ||
|
|
9c905c8bab | ||
|
|
7ab343a216 | ||
|
|
188cc7b666 | ||
|
|
c4c046f15f | ||
|
|
2350a1e1a4 | ||
|
|
99346c1db4 | ||
|
|
5ac1b486a7 | ||
|
|
1cd3cb854e | ||
|
|
eef1367ed2 | ||
|
|
bcad8f0819 | ||
|
|
df1065eb8e | ||
|
|
f36c02b9ed | ||
|
|
193e5d591c | ||
|
|
ff3f4d3a6d | ||
|
|
5129b475c5 | ||
|
|
2e3ab7be8e | ||
|
|
2f63172cbf | ||
|
|
03326c1b47 | ||
|
|
036722f825 | ||
|
|
8bb464f2a9 | ||
|
|
534989e4bb | ||
|
|
eca8641c64 | ||
|
|
0a5d0fbf88 | ||
|
|
aa76e94b43 | ||
|
|
fd284ce57d | ||
|
|
f31dbd44a5 | ||
|
|
5c55c2216e | ||
|
|
5a1d06d39e | ||
|
|
9443cf3c87 | ||
|
|
fa31dcdf24 | ||
|
|
2c80cc597e | ||
|
|
d7641fbb2f | ||
|
|
5742c03eee | ||
|
|
b2858ab3e7 | ||
|
|
2255ad0b17 | ||
|
|
ed8adf2617 | ||
|
|
5c2205cf65 | ||
|
|
f6cb695635 | ||
|
|
1450e79b64 | ||
|
|
fae0f9fb49 | ||
|
|
8638424d5b | ||
|
|
1d016ec356 | ||
|
|
f16b10f544 | ||
|
|
dd0c8a5ca8 | ||
|
|
7175191c66 | ||
|
|
f7a81c50b0 | ||
|
|
cfbcaa6fce | ||
|
|
abd1c36ec9 | ||
|
|
ebd6d15c48 | ||
|
|
e5e3c0bd40 | ||
|
|
f4979d905d | ||
|
|
496884c78a | ||
|
|
696c82364e | ||
|
|
c55655c0c5 | ||
|
|
55a0e4b2b5 | ||
|
|
7c4ff6e3e3 | ||
|
|
04e787fd80 | ||
|
|
eceb097cc7 | ||
|
|
1040450cb2 | ||
|
|
06f5ee5c4c | ||
|
|
58bf0b39c2 | ||
|
|
5a9ac0fd25 | ||
|
|
7f308f4036 | ||
|
|
61d3ad023a | ||
|
|
0e18645ba5 | ||
|
|
63f49a347b | ||
|
|
943048a920 | ||
|
|
2b8a0bd8b7 | ||
|
|
d83b56a13c | ||
|
|
1cfae73137 | ||
|
|
2d4583ff01 | ||
|
|
b46dfd7f54 | ||
|
|
388b741d94 | ||
|
|
1a603e70ec | ||
|
|
2a6b436b2e | ||
|
|
1f34a41d27 | ||
|
|
343ac5880c | ||
|
|
855edace20 | ||
|
|
aabc4f021c | ||
|
|
0aaa566e73 | ||
|
|
20e3d6a19b | ||
|
|
d21f5f9ab6 | ||
|
|
745d87c6d2 | ||
|
|
17f76e594d | ||
|
|
722a41fb43 | ||
|
|
f861924435 | ||
|
|
67953fccd6 | ||
|
|
0c910bc822 | ||
|
|
ec8c94f733 | ||
|
|
b6a7fb0bf0 | ||
|
|
8fe8d87924 | ||
|
|
95f475ab6f | ||
|
|
74e260b4be | ||
|
|
3e501ade4f | ||
|
|
679b152dd0 | ||
|
|
00201b1202 | ||
|
|
f2eb1b2e7f | ||
|
|
f8fa092ac3 | ||
|
|
39b02591d9 | ||
|
|
92d63d6f5d | ||
|
|
392812d489 | ||
|
|
88a777ca5f | ||
|
|
5aadf7234f | ||
|
|
b8a63aeac6 | ||
|
|
908a9ebbfc | ||
|
|
87a77586db | ||
|
|
ca26f93ae7 | ||
|
|
2551c255c4 | ||
|
|
437b8447a2 | ||
|
|
4042d47736 | ||
|
|
0bc5cc0fe8 | ||
|
|
fa265e8ea4 | ||
|
|
30cc720dcf | ||
|
|
cdc5011eb6 | ||
|
|
fe8cf2620d | ||
|
|
e818a5a943 | ||
|
|
6dde0ffc79 | ||
|
|
a790cf594e | ||
|
|
c260914dac | ||
|
|
9558e1d5ab | ||
|
|
7f98139deb | ||
|
|
ea8e0c1819 | ||
|
|
be0737f783 | ||
|
|
6cedc0adc1 | ||
|
|
06a81db88a | ||
|
|
5ee08443f9 | ||
|
|
515eb2a16b | ||
|
|
497d7cf7f3 | ||
|
|
ea5be9508e | ||
|
|
f5c15f83b4 | ||
|
|
e3bec544be | ||
|
|
27eadcb875 | ||
|
|
31de3ffc82 | ||
|
|
7664d50d69 | ||
|
|
5357f633d3 | ||
|
|
0406ee0529 | ||
|
|
423515ec37 | ||
|
|
9426ae9df6 | ||
|
|
b1944601ef | ||
|
|
c4cc6a9386 | ||
|
|
ad48f0010f | ||
|
|
d5e3571e17 | ||
|
|
cb6e452864 | ||
|
|
c3ff4422c8 | ||
|
|
1ce691500f | ||
|
|
05b1eaf175 | ||
|
|
4f2215b126 | ||
|
|
2cb7677098 | ||
|
|
dc096a9856 | ||
|
|
8496a911da | ||
|
|
4bff05a1c6 | ||
|
|
ff90662be1 | ||
|
|
8fb69826f9 | ||
|
|
7d94dc7cf0 | ||
|
|
f93b5290ff | ||
|
|
88d7124874 | ||
|
|
7ee6dcf0e0 | ||
|
|
9370ae69ec | ||
|
|
afee7246c6 | ||
|
|
2102180c46 | ||
|
|
064548316e | ||
|
|
e53aaffd4d | ||
|
|
0d14601385 | ||
|
|
96e3fdc323 | ||
|
|
fd359ab9ed | ||
|
|
192357f4f3 | ||
|
|
223700a1a8 | ||
|
|
643744d8d9 | ||
|
|
4085a96145 | ||
|
|
ba7ea60af5 | ||
|
|
ecf416dc0a | ||
|
|
8a45e79071 | ||
|
|
5c4dfcb9ae | ||
|
|
f4cee54042 | ||
|
|
6e9049c82b | ||
|
|
4498d853b0 | ||
|
|
f6308d61eb | ||
|
|
776ffdc931 | ||
|
|
14df556e36 | ||
|
|
f3e4040d72 | ||
|
|
511f5c1681 | ||
|
|
844b48accb | ||
|
|
4bba059ed5 | ||
|
|
cc1808289a | ||
|
|
dbf970d869 | ||
|
|
9cb2455af2 | ||
|
|
cfcc84d0c4 | ||
|
|
cbfebabfa3 | ||
|
|
c55b8c5e2e | ||
|
|
a2afc4342e | ||
|
|
b85085790a | ||
|
|
2d0bb34d17 | ||
|
|
8f3adc800f | ||
|
|
791285e7f4 | ||
|
|
b1bc8110cc | ||
|
|
0b5d643863 | ||
|
|
1acc71a6b9 | ||
|
|
41ff124d10 | ||
|
|
9a225569fd | ||
|
|
f99ca7f013 | ||
|
|
7349bc9ebf | ||
|
|
ccd3a7b223 | ||
|
|
c572155d8d | ||
|
|
d79f27422f | ||
|
|
844ee751b7 | ||
|
|
7b6d60691c | ||
|
|
c7816a644f | ||
|
|
a0196ff9b2 | ||
|
|
24ae72a7d1 | ||
|
|
0099c4faae | ||
|
|
0a7e0dc1a3 | ||
|
|
62e32d5ddd | ||
|
|
33602f65f3 | ||
|
|
f97f4d83f1 | ||
|
|
0493f93976 | ||
|
|
aaff759227 | ||
|
|
1314f643f7 | ||
|
|
55a5ffc6df | ||
|
|
e6a04b2c4f | ||
|
|
06293ebd2f | ||
|
|
238c9b7860 | ||
|
|
0823b7882b | ||
|
|
36f63da36f | ||
|
|
cf2f816527 | ||
|
|
e989891652 | ||
|
|
559336a2a7 | ||
|
|
0e6cb363ad | ||
|
|
66b1b1cb24 | ||
|
|
c2f0cdab15 | ||
|
|
dc1a9ad993 | ||
|
|
2522218385 | ||
|
|
258daef1cf | ||
|
|
df7e95b644 | ||
|
|
e67ce25eb9 | ||
|
|
88768aaf75 | ||
|
|
2543d2f484 | ||
|
|
96f5510153 | ||
|
|
1d6813fe30 | ||
|
|
0867e166e1 | ||
|
|
1d608b7705 | ||
|
|
6dc8072106 | ||
|
|
48b3ab9085 | ||
|
|
7f78e27649 | ||
|
|
46c5560222 | ||
|
|
cef5e69aa1 | ||
|
|
601395079e | ||
|
|
bbb05594e4 | ||
|
|
2f5400b49a | ||
|
|
ace071e974 | ||
|
|
a406d6d911 | ||
|
|
88f75038e0 | ||
|
|
f5bc3e82bb | ||
|
|
c206f069c6 | ||
|
|
6697aebc3b | ||
|
|
3d335dad27 | ||
|
|
ba7d9b33d8 | ||
|
|
04934c6bca | ||
|
|
7409c7286f | ||
|
|
51c0157d1d | ||
|
|
0b9eec0849 | ||
|
|
8785bde7b6 | ||
|
|
31166794dd | ||
|
|
2b90b59386 | ||
|
|
280341a11c | ||
|
|
e6274174b1 | ||
|
|
984d587613 | ||
|
|
2b4def1737 | ||
|
|
6ecb45cc86 | ||
|
|
1ae547a710 | ||
|
|
20d6966633 | ||
|
|
d5c96f6d48 | ||
|
|
d1192e707d | ||
|
|
3fd0573339 | ||
|
|
d670bc2b07 | ||
|
|
5292f7ed17 | ||
|
|
9bd987abb0 | ||
|
|
4bb24da89d | ||
|
|
7491e0d9b1 | ||
|
|
70cfbd96e2 | ||
|
|
0dacd2c71f | ||
|
|
3962adc911 | ||
|
|
0892738cd9 | ||
|
|
d85c1dc8e8 | ||
|
|
ecb8b5df0c | ||
|
|
972fb9cce5 | ||
|
|
90894898c2 | ||
|
|
144d6589ef | ||
|
|
a7588779a3 | ||
|
|
4bc7ed3db2 | ||
|
|
5f9eb8d6d2 | ||
|
|
2f85b367b0 | ||
|
|
824c036f9a | ||
|
|
0bb27f03f3 | ||
|
|
1602f9bf02 | ||
|
|
800cd5bec8 | ||
|
|
85a12bf581 | ||
|
|
a1f24b38fa | ||
|
|
44168216fe | ||
|
|
dee4f3531d | ||
|
|
bf07f904fc | ||
|
|
26c2b1b14e | ||
|
|
7153bfefba | ||
|
|
807b77d418 | ||
|
|
c69a7b03ef | ||
|
|
a39626bb0e | ||
|
|
68e14875b3 | ||
|
|
2d6d664109 | ||
|
|
dd58973118 | ||
|
|
17d3f182a0 | ||
|
|
1961090c4c | ||
|
|
33fda705c0 | ||
|
|
208717a896 | ||
|
|
6afc6b1baa | ||
|
|
a4ddd73fc8 | ||
|
|
4459b1e333 | ||
|
|
b2a66a0789 | ||
|
|
33d1315395 | ||
|
|
a0cae13f53 | ||
|
|
d1e2634594 | ||
|
|
b441fd83dd | ||
|
|
690cb9df96 | ||
|
|
377407f3d1 | ||
|
|
b2247f5f58 | ||
|
|
ae46ca2b8a | ||
|
|
82ab22518e | ||
|
|
6e0530c84c | ||
|
|
59eba07a33 | ||
|
|
5f6d156f63 | ||
|
|
07a9bd5f33 | ||
|
|
5a8771a8bd | ||
|
|
7cad4b2c8d | ||
|
|
76a68629d1 | ||
|
|
27f2cb0693 | ||
|
|
2f18bfcce3 | ||
|
|
ddb2390bf7 | ||
|
|
224bf63ded | ||
|
|
a7fbde48ef | ||
|
|
3a3d0fb05e | ||
|
|
8ed71e0192 | ||
|
|
91912cf5be | ||
|
|
4f9851a9fe | ||
|
|
434b6a81c4 | ||
|
|
ec20f0743c | ||
|
|
09a72068e6 | ||
|
|
fcc56a9bf6 | ||
|
|
84d321f1b4 | ||
|
|
ea7c2a1548 | ||
|
|
a897afd4e6 | ||
|
|
344dcd0019 | ||
|
|
7854349dbe | ||
|
|
d1d046f3fd | ||
|
|
b988c67c8e | ||
|
|
37a94f8e0f | ||
|
|
34d12bfb49 | ||
|
|
ff0990ec0e | ||
|
|
459270e035 | ||
|
|
2af709b3d5 | ||
|
|
b84d53dc39 | ||
|
|
57997d0acb | ||
|
|
76a23870d5 | ||
|
|
193e43ddc5 | ||
|
|
fc451863c0 | ||
|
|
017a7de639 | ||
|
|
7b61429a02 | ||
|
|
fed15f0f96 | ||
|
|
ad5d13168b | ||
|
|
db69c43d35 | ||
|
|
97d727a19b | ||
|
|
ff952935a2 | ||
|
|
ae5beccb9d | ||
|
|
a9cdab1ce9 | ||
|
|
015c100a1d | ||
|
|
764c660b14 | ||
|
|
ca0f32b087 | ||
|
|
8ad91de54a | ||
|
|
e7a14183d2 | ||
|
|
5e1b47707f | ||
|
|
56fc303816 | ||
|
|
d7a6a6f803 | ||
|
|
58da157272 | ||
|
|
b5014bf9ac | ||
|
|
14351436a7 | ||
|
|
03d969cb39 | ||
|
|
c55df4eb6e | ||
|
|
cc631ff07f | ||
|
|
b34a1b57d6 | ||
|
|
9cad606a55 | ||
|
|
6587ddcead | ||
|
|
ad05eec952 | ||
|
|
847fa4f46c | ||
|
|
c5897b7ee1 | ||
|
|
99ef4c0c18 | ||
|
|
cc118adec6 | ||
|
|
ca94e37495 | ||
|
|
e2bd721c3e | ||
|
|
033ce24fb7 | ||
|
|
eab7cdd7b5 | ||
|
|
666ba41f67 | ||
|
|
2579450eae | ||
|
|
87a3ca8393 | ||
|
|
9ff4be41f7 | ||
|
|
da3ed6cd3a | ||
|
|
23519a8a90 | ||
|
|
242708358e | ||
|
|
3cc3c74e5a | ||
|
|
cb4cb39396 | ||
|
|
363799c9c6 | ||
|
|
132f83aa79 | ||
|
|
f998714225 | ||
|
|
323fc1bb99 | ||
|
|
3062d3f0e3 | ||
|
|
8f37bb7623 | ||
|
|
a57ed90756 | ||
|
|
eaf8332d16 | ||
|
|
522286ff74 | ||
|
|
747047556e | ||
|
|
df3d75c702 | ||
|
|
e40beeadd4 | ||
|
|
751f10603d | ||
|
|
751c540cb3 | ||
|
|
d7bbc329bb | ||
|
|
41cd49a61c | ||
|
|
fd5a39702b | ||
|
|
4d3408254e | ||
|
|
2cbb5ec5f2 | ||
|
|
3b765b308e | ||
|
|
53ac82520e | ||
|
|
381fcf4080 | ||
|
|
a3f0faf022 | ||
|
|
b3a8737a71 | ||
|
|
6e76bcc9af | ||
|
|
6473c55317 | ||
|
|
1a880f14a0 | ||
|
|
e39af36589 | ||
|
|
c5b262af8a | ||
|
|
43ec5c1925 | ||
|
|
5e260f0239 | ||
|
|
ab67f97b40 | ||
|
|
9ac35e2e14 | ||
|
|
7c6259dddd | ||
|
|
1cff1abc33 | ||
|
|
d834d46586 | ||
|
|
1107e1bdf3 | ||
|
|
3a11327e7e | ||
|
|
74062bab57 | ||
|
|
6802336b6b | ||
|
|
a9db134d3a | ||
|
|
ee7be1d907 | ||
|
|
5ecda88457 | ||
|
|
7056c446fa | ||
|
|
147c09ae64 | ||
|
|
112adf9eb0 | ||
|
|
469d412951 | ||
|
|
6edbac86e2 | ||
|
|
0e08334132 | ||
|
|
3d51acf9e7 | ||
|
|
fd6ea5ed7e | ||
|
|
db2be70d66 | ||
|
|
c36f231990 | ||
|
|
d18e5b1f14 | ||
|
|
20327d817d | ||
|
|
26102121e1 | ||
|
|
8e64c6303e | ||
|
|
051c332426 | ||
|
|
a09258325e | ||
|
|
74c76eb90b | ||
|
|
daffdcf58e | ||
|
|
61dca0cbda | ||
|
|
2bff299193 | ||
|
|
4bc4fa903f | ||
|
|
1329b13db3 | ||
|
|
29380f0303 | ||
|
|
9dd894f0fe | ||
|
|
6b2370b79d | ||
|
|
dbc636c9bf | ||
|
|
30d1ae5642 | ||
|
|
79aceef382 | ||
|
|
a28e52210a | ||
|
|
0c0660c04b | ||
|
|
08eb294213 | ||
|
|
c9fd076394 | ||
|
|
888baed81a | ||
|
|
4de3955db2 | ||
|
|
25f08c7624 | ||
|
|
35550553be | ||
|
|
06ff219385 | ||
|
|
e705a5629f | ||
|
|
1e8013100c | ||
|
|
62c8f55568 | ||
|
|
cb3c9653ce | ||
|
|
0b5a83f6ae | ||
|
|
a079169005 | ||
|
|
845c51d5f9 | ||
|
|
c40d828749 | ||
|
|
d6d3a034ad | ||
|
|
84ad08887a | ||
|
|
ece08d96ee | ||
|
|
ed0a678020 | ||
|
|
854f4d559a | ||
|
|
f186f2a8f2 | ||
|
|
37107d8500 | ||
|
|
6b68cb7c61 | ||
|
|
a1e0288e09 | ||
|
|
e6c173bdf9 | ||
|
|
dde6a8c5db | ||
|
|
e2750b8572 | ||
|
|
acd23925b5 | ||
|
|
b0db054e11 | ||
|
|
d9b6de0652 | ||
|
|
c54da18822 | ||
|
|
51cea8e757 | ||
|
|
bbb086ea41 | ||
|
|
539e6ed080 | ||
|
|
555801dc5c | ||
|
|
1d33a26318 | ||
|
|
86a20fc97a | ||
|
|
d6e00c4534 | ||
|
|
6f81945da6 | ||
|
|
865c309475 | ||
|
|
77b8b21aea | ||
|
|
2f5edffec6 | ||
|
|
71de64502e | ||
|
|
6994d3559a | ||
|
|
a7d484d218 | ||
|
|
a810c41acd | ||
|
|
2fbfdf94ab | ||
|
|
2d7c8f0863 | ||
|
|
c3b734ab47 | ||
|
|
644abf105d | ||
|
|
5a8a451774 | ||
|
|
dae139aa01 | ||
|
|
b13fcd8939 | ||
|
|
26b42b4eea | ||
|
|
c9005e8aa9 | ||
|
|
6658b11adf | ||
|
|
e542f5809f | ||
|
|
ce1dd6233d | ||
|
|
fe488443da | ||
|
|
b264a39780 | ||
|
|
d2302eaa85 | ||
|
|
a813d38108 | ||
|
|
685a49c212 | ||
|
|
994706c9f2 | ||
|
|
2c8eb534af | ||
|
|
5210fab4cb | ||
|
|
49787d27f1 | ||
|
|
dfe7b46461 | ||
|
|
8a72ab42cb | ||
|
|
c4db8e3914 | ||
|
|
8d0225e595 | ||
|
|
f8257de0dd | ||
|
|
3b3ecc9f1d | ||
|
|
966049d0c9 | ||
|
|
907a65a701 | ||
|
|
f97b8e14e7 | ||
|
|
e65f634b21 | ||
|
|
fc71ed2b9d | ||
|
|
84105acf5d | ||
|
|
def5173692 | ||
|
|
6b31fef1af | ||
|
|
c9c059ca65 | ||
|
|
4d3b31e5a1 | ||
|
|
5a8195d430 | ||
|
|
24a7a607f3 | ||
|
|
061f9ffc52 | ||
|
|
9e17936bfc | ||
|
|
18bb2c4f39 | ||
|
|
7c3782a43f | ||
|
|
3ac807bdd5 | ||
|
|
1111458863 | ||
|
|
99c5e2230c | ||
|
|
3317aa845a | ||
|
|
97cd657336 | ||
|
|
3338f919bd | ||
|
|
7dd13bcab7 | ||
|
|
f226cb359f | ||
|
|
abbba0aa42 | ||
|
|
39b5a52b01 | ||
|
|
b6c3fc5bee | ||
|
|
909bea00df | ||
|
|
9522945e06 | ||
|
|
d6a9f2a731 | ||
|
|
0f30c81554 | ||
|
|
e514ba4bb5 | ||
|
|
38e63e3eaa | ||
|
|
0058324edd | ||
|
|
ac143d607a | ||
|
|
e9e3759db3 | ||
|
|
51d90095aa | ||
|
|
fb09e10f19 | ||
|
|
16c0370443 | ||
|
|
67bb38dcf4 | ||
|
|
049231a36e | ||
|
|
349d6b7375 | ||
|
|
b72b368d3c | ||
|
|
7f9fd44a02 | ||
|
|
a400c5974c | ||
|
|
afca995fe5 | ||
|
|
81504fedc5 | ||
|
|
3da3345683 | ||
|
|
c6c2889306 | ||
|
|
b60f0d260a | ||
|
|
cd750e4777 | ||
|
|
4e5d503b35 | ||
|
|
bd09e17e49 | ||
|
|
835eb743bb | ||
|
|
69a129d80e | ||
|
|
434bf483fd | ||
|
|
2b8e170b40 | ||
|
|
dc9b95f3e7 | ||
|
|
1616b0da0a | ||
|
|
91c457b22b | ||
|
|
70c60647c7 | ||
|
|
c0bea66d27 | ||
|
|
ed7cc078ed | ||
|
|
60b7d6d795 | ||
|
|
947f29cca0 | ||
|
|
d2a13ec0da | ||
|
|
cc39ba409e | ||
|
|
09473f17a0 | ||
|
|
ac9db6256e | ||
|
|
096f628d97 | ||
|
|
bbc2de08a5 | ||
|
|
22312812bb | ||
|
|
df808a2bcf | ||
|
|
d04e7c36f3 | ||
|
|
205d826adb | ||
|
|
3584975acb | ||
|
|
30b9ca4e6c | ||
|
|
7c6ff01ebe | ||
|
|
a54edcaf5b | ||
|
|
e446954844 | ||
|
|
4a2d0d6787 | ||
|
|
9725314135 | ||
|
|
4db8e43648 | ||
|
|
e610f0d57f | ||
|
|
8244af2940 | ||
|
|
cc60062678 | ||
|
|
40f371d23b | ||
|
|
817b791e59 | ||
|
|
25a7aac360 | ||
|
|
37115a174d | ||
|
|
1397f81fd0 | ||
|
|
56365cb403 | ||
|
|
dfd245ee7b | ||
|
|
9c81e4b34d | ||
|
|
67676df131 | ||
|
|
a73b129596 | ||
|
|
4600d886b5 | ||
|
|
e3305ab9db | ||
|
|
0fe45a2405 | ||
|
|
db87213242 | ||
|
|
b0157f288e | ||
|
|
5c3c010d5a | ||
|
|
c804cedd7a | ||
|
|
a9f50d9371 | ||
|
|
65a3226404 | ||
|
|
45690f5418 | ||
|
|
aa30ca99bf | ||
|
|
6836b6197f | ||
|
|
c0ca9d1069 | ||
|
|
5e79e1d57f | ||
|
|
8c732f9f1e | ||
|
|
5e94c2f636 | ||
|
|
69d4163b9d | ||
|
|
b1e974a82c | ||
|
|
34a2b20be8 | ||
|
|
f1fc8d9aae | ||
|
|
b04355e3e1 | ||
|
|
cd3ae5cdf2 | ||
|
|
a261ca23af | ||
|
|
cb96904a5c | ||
|
|
4a2d78f8e1 | ||
|
|
f5af4fb52f | ||
|
|
2037bc3a10 | ||
|
|
64d17d7c65 | ||
|
|
92e2633342 | ||
|
|
96a7ea8a02 | ||
|
|
5c4aaa4510 | ||
|
|
c05e1cb77b | ||
|
|
5879ce4090 | ||
|
|
ac3e5c793c | ||
|
|
64e5d29996 | ||
|
|
b320d8ded2 | ||
|
|
4326fb931b | ||
|
|
ced7051ce7 | ||
|
|
0be1b20996 | ||
|
|
6c55460622 | ||
|
|
421da246ed | ||
|
|
d627404dc2 | ||
|
|
148a721e17 | ||
|
|
f317123c26 | ||
|
|
e4df1887b0 | ||
|
|
34142c3e85 | ||
|
|
6e7f8bdf02 | ||
|
|
3dd9fcfb58 | ||
|
|
35e2954b8b | ||
|
|
59aa63db0f | ||
|
|
7a41a190f8 | ||
|
|
6741232450 | ||
|
|
a811670c5a | ||
|
|
72f08a86cf | ||
|
|
27c471f45f | ||
|
|
e303972d26 | ||
|
|
97bb03d703 | ||
|
|
e9f77c1bde | ||
|
|
81cba6c0a8 | ||
|
|
89029df25e | ||
|
|
3463b6818d | ||
|
|
349843e666 | ||
|
|
96ae3bbbba | ||
|
|
b153364b60 | ||
|
|
ccc40937fb | ||
|
|
909edf394d | ||
|
|
ac8ef646e9 | ||
|
|
3ef279ea10 | ||
|
|
769beeda37 | ||
|
|
f83404c216 | ||
|
|
c239658131 | ||
|
|
be82afd778 | ||
|
|
2156c96368 | ||
|
|
f7f078d82a | ||
|
|
95168cf514 | ||
|
|
6dc601c4a2 | ||
|
|
6b87bbb882 | ||
|
|
abdf2c5037 | ||
|
|
7beec1b80f | ||
|
|
3a0e46f064 | ||
|
|
85cc85a923 | ||
|
|
ca0079c0df | ||
|
|
28b662ad43 | ||
|
|
6bac6bbfeb | ||
|
|
958ec1002b | ||
|
|
438852ecaf | ||
|
|
92d82c0a00 | ||
|
|
7d37267f57 | ||
|
|
5641d53cc3 | ||
|
|
8fc6752290 | ||
|
|
cca6f87500 | ||
|
|
758d76051f | ||
|
|
95f7e66cff | ||
|
|
4e194287c9 | ||
|
|
3545830552 | ||
|
|
074f4c32ed | ||
|
|
b3fec5ed7d | ||
|
|
ffb90b8f9a | ||
|
|
584618043d | ||
|
|
d702c485b7 | ||
|
|
d3561da331 | ||
|
|
0e0aaf37df | ||
|
|
5ec068409f | ||
|
|
8796b6d340 | ||
|
|
bfbb18655d | ||
|
|
9088651e53 | ||
|
|
3e8f379502 | ||
|
|
265c2c4231 | ||
|
|
f671d79280 | ||
|
|
97c89d1d13 | ||
|
|
e0a26a38fa | ||
|
|
038f06e59f | ||
|
|
f4d2bae04f | ||
|
|
d443e275ea | ||
|
|
30d2057e01 | ||
|
|
d952b9aaae | ||
|
|
3402215e8d | ||
|
|
f01031dc26 | ||
|
|
01d4d116b9 | ||
|
|
bc7f82eef1 | ||
|
|
efdb355033 | ||
|
|
87c9fd010f |
@@ -22,7 +22,7 @@ _Make sure your have performed every step and checked the applicable boxes befor
|
||||
- [ ] Searched the issue in [issues](https://github.com/emsesp/EMS-ESP32/issues)
|
||||
- [ ] Searched the issue in [discussions](https://github.com/emsesp/EMS-ESP32/discussions)
|
||||
- [ ] Searched the issue in the [docs](https://emsesp.org/Troubleshooting/)
|
||||
- [ ] Searched the issue in the [chat](https://discord.gg/3J3GgnzpyT)
|
||||
- [ ] Searched the issue in the [chat](https://discord.gg/GP9DPSgeJq)
|
||||
- [ ] Provide the System information in the area below, taken from `http://<IP>/api/system`
|
||||
|
||||
```json
|
||||
|
||||
@@ -7,5 +7,5 @@ contact_links:
|
||||
url: https://github.com/emsesp/EMS-ESP32/discussions
|
||||
about: EMS-ESP usage Questions, Feature Requests and Projects.
|
||||
- name: EMS-ESP Users Chat
|
||||
url: https://discord.gg/3J3GgnzpyT
|
||||
url: https://discord.gg/GP9DPSgeJq
|
||||
about: Chat for feedback, questions and troubleshooting.
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
node-version: 24
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Enable Corepack
|
||||
run: corepack enable pnpm
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
|
||||
- name: Build webUI
|
||||
run: |
|
||||
platformio run -e build_webUI
|
||||
platformio run -e build-webUI
|
||||
|
||||
- name: Build modbus
|
||||
run: |
|
||||
@@ -62,13 +62,13 @@ jobs:
|
||||
platformio run
|
||||
|
||||
- name: Commit the generated files
|
||||
uses: stefanzweifel/git-auto-commit-action@v5
|
||||
uses: stefanzweifel/git-auto-commit-action@v7
|
||||
with:
|
||||
commit_message: "chore: update generated files for v${{steps.build_info.outputs.VERSION}}"
|
||||
|
||||
- name: Create GitHub Release
|
||||
id: 'automatic_releases'
|
||||
uses: emsesp/action-automatic-releases@v1.0.0
|
||||
uses: emsesp/action-automatic-releases@v1.0.1
|
||||
with:
|
||||
repo_token: '${{ secrets.GITHUB_TOKEN }}'
|
||||
title: Development Build v${{steps.build_info.outputs.VERSION}}
|
||||
@@ -77,3 +77,23 @@ jobs:
|
||||
files: |
|
||||
CHANGELOG_LATEST.md
|
||||
./build/firmware/*.*
|
||||
|
||||
- name: Update version in Cloudflare KV store
|
||||
if: github.repository == 'emsesp/EMS-ESP32'
|
||||
env:
|
||||
CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}
|
||||
CF_NAMESPACE_ID: ${{ secrets.CF_NAMESPACE_ID }}
|
||||
CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
|
||||
VERSION: ${{ steps.build_info.outputs.VERSION }}
|
||||
run: |
|
||||
JSON_DATA=$(jq -n \
|
||||
--arg version "$VERSION" \
|
||||
--arg date "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
'{version: $version, date: $date}')
|
||||
echo "JSON_DATA: $JSON_DATA"
|
||||
curl -sS --fail-with-body \
|
||||
-X PUT "https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/storage/kv/namespaces/${CF_NAMESPACE_ID}/values/dev" \
|
||||
-H "Authorization: Bearer ${CF_API_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$JSON_DATA"
|
||||
echo
|
||||
|
||||
@@ -11,7 +11,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: GitHub Releases To Discord
|
||||
uses: SethCohen/github-releases-to-discord@v1.13.1
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install python 3.13
|
||||
uses: actions/setup-python@v6
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
BUILD_WRAPPER_OUT_DIR: bw-output
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install Build Wrapper
|
||||
|
||||
@@ -26,11 +26,18 @@ jobs:
|
||||
node-version: 24
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Enable Corepack
|
||||
run: corepack enable pnpm
|
||||
|
||||
|
||||
- name: Get the EMS-ESP version
|
||||
id: build_info
|
||||
run: |
|
||||
version=`grep -E '^#define EMSESP_APP_VERSION' ./src/emsesp_version.h | awk -F'"' '{print $2}'`
|
||||
echo "VERSION=$version" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Install PlatformIO
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
@@ -39,7 +46,7 @@ jobs:
|
||||
|
||||
- name: Build webUI
|
||||
run: |
|
||||
platformio run -e build_webUI
|
||||
platformio run -e build-webUI
|
||||
|
||||
- name: Build modbus
|
||||
run: |
|
||||
@@ -54,10 +61,30 @@ jobs:
|
||||
platformio run
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: emsesp/action-automatic-releases@v1.0.0
|
||||
uses: emsesp/action-automatic-releases@v1.0.1
|
||||
with:
|
||||
repo_token: '${{ secrets.GITHUB_TOKEN }}'
|
||||
prerelease: false
|
||||
files: |
|
||||
CHANGELOG.md
|
||||
./build/firmware/*.*
|
||||
|
||||
- name: Update version in Cloudflare KV store
|
||||
if: github.repository == 'emsesp/EMS-ESP32'
|
||||
env:
|
||||
CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}
|
||||
CF_NAMESPACE_ID: ${{ secrets.CF_NAMESPACE_ID }}
|
||||
CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
|
||||
VERSION: ${{ steps.build_info.outputs.VERSION }}
|
||||
run: |
|
||||
JSON_DATA=$(jq -n \
|
||||
--arg version "$VERSION" \
|
||||
--arg date "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
'{version: $version, date: $date}')
|
||||
echo "JSON_DATA: $JSON_DATA"
|
||||
curl -sS --fail-with-body \
|
||||
-X PUT "https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/storage/kv/namespaces/${CF_NAMESPACE_ID}/values/stable" \
|
||||
-H "Authorization: Bearer ${CF_API_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$JSON_DATA"
|
||||
echo
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
node-version: 24
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Enable Corepack
|
||||
run: corepack enable pnpm
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
|
||||
- name: Build webUI
|
||||
run: |
|
||||
platformio run -e build_webUI
|
||||
platformio run -e build-webUI
|
||||
|
||||
- name: Build modbus
|
||||
run: |
|
||||
@@ -63,7 +63,7 @@ jobs:
|
||||
|
||||
- name: Create GitHub Release
|
||||
id: 'automatic_releases'
|
||||
uses: emsesp/action-automatic-releases@v1.0.0
|
||||
uses: emsesp/action-automatic-releases@v1.0.1
|
||||
with:
|
||||
repo_token: '${{ secrets.GITHUB_TOKEN }}'
|
||||
title: Test Build v${{steps.build_info.outputs.VERSION}}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
name: 'Update versions'
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
update-version:
|
||||
name: 'Update versions in Cloudflare KV store'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Get and Send EMS-ESP version to Cloudflare
|
||||
env:
|
||||
CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}
|
||||
CF_NAMESPACE_ID: ${{ secrets.CF_NAMESPACE_ID }}
|
||||
CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
|
||||
run: |
|
||||
version=$(grep -E '^#define EMSESP_APP_VERSION' ./src/emsesp_version.h | awk -F'"' '{print $2}')
|
||||
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
|
||||
KV_ENV="stable"
|
||||
else
|
||||
KV_ENV="dev"
|
||||
fi
|
||||
JSON_DATA=$(jq -n \
|
||||
--arg version "$version" \
|
||||
--arg date "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
'{version: $version, date: $date}')
|
||||
echo "KV_ENV: $KV_ENV"
|
||||
echo "JSON_DATA: $JSON_DATA"
|
||||
curl -sS --fail-with-body \
|
||||
-X PUT "https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/storage/kv/namespaces/${CF_NAMESPACE_ID}/values/${KV_ENV}" \
|
||||
-H "Authorization: Bearer ${CF_API_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$JSON_DATA"
|
||||
echo
|
||||
+5
-1
@@ -2,6 +2,7 @@
|
||||
.vscode/c_cpp_properties.json
|
||||
.vscode/extensions.json
|
||||
.vscode/launch.json
|
||||
.vscode/settings.json
|
||||
|
||||
# c++ compiling
|
||||
.clang_complete
|
||||
@@ -63,7 +64,7 @@ words-found-verbose.txt
|
||||
# sonarlint
|
||||
compile_commands.json
|
||||
|
||||
# pioarduino + hybrid
|
||||
# other files
|
||||
managed_components
|
||||
dependencies.lock
|
||||
CMakeLists.txt
|
||||
@@ -75,3 +76,6 @@ pnpm-lock.yaml
|
||||
.cache/
|
||||
interface/.tsbuildinfo
|
||||
test/test_api/package-lock.json
|
||||
.clangd
|
||||
mklittlefs
|
||||
.cursor
|
||||
|
||||
Vendored
-101
@@ -1,101 +0,0 @@
|
||||
{
|
||||
"search.exclude": {
|
||||
"**/.yarn": true,
|
||||
"**/.pnp.*": true
|
||||
},
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll": "explicit"
|
||||
},
|
||||
"eslint.validate": [
|
||||
"typescript"
|
||||
],
|
||||
"eslint.codeActionsOnSave.rules": null,
|
||||
"eslint.nodePath": "interface/.yarn/sdks",
|
||||
"eslint.workingDirectories": ["interface"],
|
||||
"prettier.prettierPath": "",
|
||||
"typescript.enablePromptUseWorkspaceTsdk": true,
|
||||
"files.associations": {
|
||||
"*.tsx": "typescriptreact",
|
||||
"*.tcc": "cpp",
|
||||
"optional": "cpp",
|
||||
"istream": "cpp",
|
||||
"ostream": "cpp",
|
||||
"ratio": "cpp",
|
||||
"system_error": "cpp",
|
||||
"array": "cpp",
|
||||
"functional": "cpp",
|
||||
"regex": "cpp",
|
||||
"tuple": "cpp",
|
||||
"type_traits": "cpp",
|
||||
"utility": "cpp",
|
||||
"string": "cpp",
|
||||
"string_view": "cpp",
|
||||
"atomic": "cpp",
|
||||
"bitset": "cpp",
|
||||
"cctype": "cpp",
|
||||
"chrono": "cpp",
|
||||
"clocale": "cpp",
|
||||
"cmath": "cpp",
|
||||
"condition_variable": "cpp",
|
||||
"cstdarg": "cpp",
|
||||
"cstddef": "cpp",
|
||||
"cstdint": "cpp",
|
||||
"cstdio": "cpp",
|
||||
"cstdlib": "cpp",
|
||||
"cstring": "cpp",
|
||||
"ctime": "cpp",
|
||||
"cwchar": "cpp",
|
||||
"cwctype": "cpp",
|
||||
"deque": "cpp",
|
||||
"list": "cpp",
|
||||
"unordered_map": "cpp",
|
||||
"unordered_set": "cpp",
|
||||
"vector": "cpp",
|
||||
"exception": "cpp",
|
||||
"algorithm": "cpp",
|
||||
"iterator": "cpp",
|
||||
"map": "cpp",
|
||||
"memory": "cpp",
|
||||
"memory_resource": "cpp",
|
||||
"numeric": "cpp",
|
||||
"random": "cpp",
|
||||
"set": "cpp",
|
||||
"fstream": "cpp",
|
||||
"initializer_list": "cpp",
|
||||
"iomanip": "cpp",
|
||||
"iosfwd": "cpp",
|
||||
"iostream": "cpp",
|
||||
"limits": "cpp",
|
||||
"mutex": "cpp",
|
||||
"new": "cpp",
|
||||
"sstream": "cpp",
|
||||
"stdexcept": "cpp",
|
||||
"streambuf": "cpp",
|
||||
"thread": "cpp",
|
||||
"cinttypes": "cpp",
|
||||
"typeinfo": "cpp"
|
||||
},
|
||||
"todo-tree.filtering.excludeGlobs": [
|
||||
"**/vendor/**",
|
||||
"**/node_modules/**",
|
||||
"**/dist/**",
|
||||
"**/bower_components/**",
|
||||
"**/build/**",
|
||||
"**/.vscode/**",
|
||||
"**/.github/**",
|
||||
"**/_output/**",
|
||||
"**/*.min.*",
|
||||
"**/*.map",
|
||||
"**/ArduinoJson/**"
|
||||
],
|
||||
"cSpell.enableFiletypes": [
|
||||
"ini",
|
||||
"makefile"
|
||||
],
|
||||
"typescript.preferences.preferTypeOnlyAutoImports": true,
|
||||
"sonarlint.pathToCompileCommands": "${workspaceFolder}/compile_commands.json",
|
||||
"sonarlint.connectedMode.project": {
|
||||
"connectionId": "emsesp",
|
||||
"projectKey": "emsesp_EMS-ESP32"
|
||||
}
|
||||
}
|
||||
+70
-1
@@ -5,6 +5,74 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [3.8.3] 1 August 2026
|
||||
|
||||
## Added
|
||||
|
||||
- KM300 at address 0x4A [#3084](https://github.com/emsesp/EMS-ESP32/issues/3084)
|
||||
|
||||
## Fixed
|
||||
|
||||
- signed value for solarInfuence [#3077](https://github.com/emsesp/EMS-ESP32/issues/3077)
|
||||
- set bin file upload limit to 1M again [#3086](https://github.com/emsesp/EMS-ESP32/issues/3086)
|
||||
- check arithmetric operations on strings [#3127](https://github.com/emsesp/EMS-ESP32/discussions/3127)
|
||||
- prevent system crash during WiFi network discovery on ESP32-S3 hardware [#3128](https://github.com/emsesp/EMS-ESP32/pull/3128)
|
||||
- fix setting date/time on Junkers thermostats
|
||||
- offset of hpminflowtemp [#3144](https://github.com/emsesp/EMS-ESP32/issues/3144)
|
||||
- water circulation command [#3149](https://github.com/emsesp/EMS-ESP32/pull/3149)
|
||||
- start scheduler after customEntities, delay scheduler loop after startup, handling of `system/restart` command [#3146](https://github.com/emsesp/EMS-ESP32/issues/3146)
|
||||
- possible memory leaks fixed.
|
||||
- repeated startup schedules compute value
|
||||
- call `system/message` called from scheduler processes compute twice
|
||||
- water module SM100/200 as dhw1/dhw2 [#3164](https://github.com/emsesp/EMS-ESP32/issues/3164)
|
||||
|
||||
## Changed
|
||||
|
||||
- call compute value direct, enlarge TCP stack [#3127](https://github.com/emsesp/EMS-ESP32/discussions/3127)
|
||||
- Dewtemperature for Easycontrol calculated by ems-esp [#3135](https://github.com/emsesp/EMS-ESP32/issues/3135)
|
||||
- add option for sync thermostat to ntp, allow different time zones [#3148](https://github.com/emsesp/EMS-ESP32/discussions/3148), **defaults to sync**.
|
||||
- block too many GET requests [mentioned in #3104](https://github.com/emsesp/EMS-ESP32/issues/3104)
|
||||
- set 10 sec timeout for http requests [#3104](https://github.com/emsesp/EMS-ESP32/issues/3104)
|
||||
- language code "cs" [#3161](https://github.com/emsesp/EMS-ESP32/issues/3161)
|
||||
|
||||
## [3.8.2] 12 May 2026
|
||||
|
||||
## Added
|
||||
|
||||
- comfortpoint for BC400 [#2935](https://github.com/emsesp/EMS-ESP32/issues/2935)
|
||||
- customize device brand [#2784](https://github.com/emsesp/EMS-ESP32/issues/2784)
|
||||
- set model for ems-esp devices temperature, analog, etc. [#2958](https://github.com/emsesp/EMS-ESP32/discussions/2958)
|
||||
- prometheus metrics for temperature/analog/scheduler/custom [#2962](https://github.com/emsesp/EMS-ESP32/issues/2962)
|
||||
- boiler pumpkick [#2965](https://github.com/emsesp/EMS-ESP32/discussions/2965)
|
||||
- heatpump reset [#2933](https://github.com/emsesp/EMS-ESP32/issues/2933)
|
||||
- 2.nd freshwater module (dhw4, dhw5) [#2991](https://github.com/emsesp/EMS-ESP32/issues/2991)
|
||||
- full system backup and restore
|
||||
- auto-logic to set ht3/ems+ tx-mode
|
||||
- polariity for digital_in sensors [#3070](https://github.com/emsesp/EMS-ESP32/discussions/3070)
|
||||
|
||||
## Fixed
|
||||
|
||||
- SRC climate creation [#2936](https://github.com/emsesp/EMS-ESP32/issues/2936) and [#2960](https://github.com/emsesp/EMS-ESP32/issues/2960)
|
||||
- missing translations [#3015](https://github.com/emsesp/EMS-ESP32/issues/3015)
|
||||
- custom entities check fetch length
|
||||
- modbus initialization [#3064](https://github.com/emsesp/EMS-ESP32/issues/3064)
|
||||
|
||||
## Changed
|
||||
|
||||
- weblogbuffer up to 1000 messages with PSRAM, mentioned in [#2933](https://github.com/emsesp/EMS-ESP32/issues/2933)
|
||||
- validate custom entity writes, [#2931](https://github.com/emsesp/EMS-ESP32/issues/2931)
|
||||
- remove wrong burnMinPower [#2918](https://github.com/emsesp/EMS-ESP32/issues/2918)
|
||||
- store scheduler active state to nvs [#2946](https://github.com/emsesp/EMS-ESP32/discussions/2946)
|
||||
- translated modes `heat` and `eco` for HA-climate mode-str-tpl
|
||||
- support `minflowtemp` and `baseflowtemp` [#2969](https://github.com/emsesp/EMS-ESP32/discussions/2969)
|
||||
- update version if it is 00.00 in first read [#2981](https://github.com/emsesp/EMS-ESP32/issues/2981)
|
||||
- device class for % values [#2980](https://github.com/emsesp/EMS-ESP32/issues/2980)
|
||||
- fetch telegrams: set length to fetch [#3017](https://github.com/emsesp/EMS-ESP32/issues/3017)
|
||||
- move http client from stack to heap
|
||||
- heap optimizations [#3021](https://github.com/emsesp/EMS-ESP32/discussions/3021)
|
||||
- check and read 0x470 as summer2_typeids[0] only if received [#2686](https://github.com/emsesp/EMS-ESP32/issues/2686), [#3055](https://github.com/emsesp/EMS-ESP32/issues/3055)
|
||||
- default bus-id: gateway1(0x49), tx-mode: auto
|
||||
|
||||
## [3.8.1] 11 January 2026
|
||||
|
||||
## Added
|
||||
@@ -33,6 +101,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- board profile `CUSTOM` can only be selected in developer mode
|
||||
- mqtt sends round values without decimals (`28` instead of `28.0`)
|
||||
|
||||
|
||||
## [3.8.0] 31 December 2025
|
||||
|
||||
## Added
|
||||
@@ -906,4 +975,4 @@ There are breaking changes between 3.5.x and earlier versions of 3.6.0. Please r
|
||||
- some names of mqtt-tags like in v2.2.1
|
||||
- new ESP32 partition side to allow for smoother OTA and fallback
|
||||
- Network Gateway IP is optional (#682)emsesp/EMS-ESP
|
||||
- moved to a new GitHub repo <https://github.com/emsesp/EMS-ESP32>
|
||||
- moved to a new GitHub repo <https://github.com/emsesp/EMS-ESP32>
|
||||
+27
-1
@@ -2,10 +2,36 @@
|
||||
|
||||
For more details go to [emsesp.org](https://emsesp.org/).
|
||||
|
||||
## [3.8.2]
|
||||
## [3.9.0]
|
||||
|
||||
This release is based on the latest Espressif/Arduino core version 3. It brings in many memory and performance optimizations. Note it does require the user to manually migrate settings from 3.8.x to 3.9.0.
|
||||
|
||||
## Added
|
||||
|
||||
- user-requested LED blink [#3063](https://github.com/emsesp/EMS-ESP32/issues/3063)
|
||||
- Commands Service that can be called via MQTT or API or used in the Scheduler Service
|
||||
- option to disable factory reset [#3150](https://github.com/emsesp/EMS-ESP32/issues/3150)
|
||||
- TLS support with 4MB boards without PSRAM
|
||||
|
||||
## Fixed
|
||||
|
||||
- shunting yard show json
|
||||
- memory leak when using different timezones for ems-esp and thermostat[#3184](https://github.com/emsesp/EMS-ESP32/issues/3184)
|
||||
- LED stayed off on a healthy system when "Disable LED" was unchecked
|
||||
- Ethernet MAC address changed with the new SDK, breaking DHCP reservations (currently disabled)
|
||||
- "IPv4 nameserver" showed an IPv6 address when IPv6 was in use
|
||||
- HA Discovery warning on Uptime after EMS-ESP boot due to NTP not ready
|
||||
- hc/control setting for UI800 thermostats [#3181](https://github.com/emsesp/EMS-ESP32/discussions/3181)
|
||||
|
||||
## Changed
|
||||
|
||||
- various memory optimizations [#3083](https://github.com/emsesp/EMS-ESP32/issues/3083)
|
||||
- Scheduler name is now mandatory
|
||||
- network fallback to AP only after start [#3090](https://github.com/emsesp/EMS-ESP32/issues/3090)
|
||||
- replaced Web async-validator with custom validator and toast with native snackbar to reduce bundle size
|
||||
- Gateway and Connect devices are shown in the Devices page, but disabled [3126](https://github.com/emsesp/EMS-ESP32/discussions/3126)
|
||||
- show control setting only for master thermostats (0x10) [#3173](https://github.com/emsesp/EMS-ESP32/issues/3173)
|
||||
- remove devices without entities not listed in 0x07 telegram
|
||||
- changes to the Dialogs in Dashboard page, removed the run icon in the Commands page. Renamed Execute to Run.
|
||||
- set `None` for undefined values in HA `val_tpl`
|
||||
|
||||
|
||||
@@ -47,23 +47,23 @@ MAKEFLAGS += -j$(JOBS) -l$(shell echo $$(($(JOBS) * 2)))
|
||||
#----------------------------------------------------------------------
|
||||
TARGET := emsesp
|
||||
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
|
||||
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
|
||||
SOURCES := src/core src/devices src/web src/test lib_standalone lib/espMqttClient/src lib/espMqttClient/src/* lib/ArduinoJson/src lib/uuid-common/src lib/uuid-console/src lib/uuid-log/src lib/PButton
|
||||
INCLUDES := src/core src/devices src/web src/test lib_standalone lib/* lib/espMqttClient/src lib/espMqttClient/src/Transport lib/ArduinoJson/src lib/uuid-common/src lib/uuid-console/src lib/uuid-log/src lib/uuid-telnet/src lib/uuid-syslog/src
|
||||
LIBRARIES :=
|
||||
|
||||
CPPCHECK = cppcheck
|
||||
CHECKFLAGS = -q --force --std=gnu++17
|
||||
CHECKFLAGS = -q --force --std=gnu++20
|
||||
|
||||
#----------------------------------------------------------------------
|
||||
# Languages Standard
|
||||
#----------------------------------------------------------------------
|
||||
C_STANDARD := -std=c17
|
||||
CXX_STANDARD := -std=gnu++17
|
||||
C_STANDARD := -std=c20
|
||||
CXX_STANDARD := -std=gnu++20
|
||||
|
||||
#----------------------------------------------------------------------
|
||||
# Defined Symbols
|
||||
#----------------------------------------------------------------------
|
||||
DEFINES += -DARDUINOJSON_ENABLE -DARDUINOJSON_ENABLE_ARDUINO_STRING -DARDUINOJSON_USE_DOUBLE=0
|
||||
DEFINES += -DARDUINOJSON_ENABLE -DARDUINOJSON_ENABLE_ARDUINO_STRING -DARDUINOJSON_USE_DOUBLE=0
|
||||
DEFINES += -DEMSESP_STANDALONE -DEMSESP_TEST -DEMSESP_DEBUG -DEMC_RX_BUFFER_SIZE=1500
|
||||
DEFINES += $(ARGS)
|
||||
|
||||
@@ -79,6 +79,10 @@ SYMBOLS := $(CURDIR)/$(BUILD)/$(TARGET).out
|
||||
CSOURCES := $(shell find $(SOURCES) -name "*.c" 2>/dev/null)
|
||||
CXXSOURCES := $(shell find $(SOURCES) -name "*.cpp" 2>/dev/null)
|
||||
|
||||
# Exclude files not needed for standalone build, if they exist
|
||||
CSOURCES := $(filter-out src/core/ModuleLibrary.c,$(CSOURCES))
|
||||
CXXSOURCES := $(filter-out src/core/ModuleLibrary.cpp,$(CXXSOURCES))
|
||||
|
||||
OBJS := $(patsubst %,$(BUILD)/%.o,$(basename $(CSOURCES)) $(basename $(CXXSOURCES)))
|
||||
DEPS := $(patsubst %,$(BUILD)/%.d,$(basename $(CSOURCES)) $(basename $(CXXSOURCES)))
|
||||
|
||||
@@ -108,8 +112,8 @@ CXX := /usr/bin/g++
|
||||
CPPFLAGS += $(DEFINES) $(DEFAULTS) $(INCLUDE)
|
||||
CPPFLAGS += -ggdb -g3 -MMD
|
||||
CPPFLAGS += -flto=auto
|
||||
CPPFLAGS += -Wall -Wextra -Werror -Wswitch-enum
|
||||
CPPFLAGS += -Wno-unused-parameter -Wno-missing-braces -Wno-vla-cxx-extension
|
||||
CPPFLAGS += -Wall -Wextra -Werror
|
||||
CPPFLAGS += -Wno-unused-parameter -Wno-missing-braces -Wno-vla-cxx-extension -Wno-switch-enum -Wno-unused-lambda-capture
|
||||
CPPFLAGS += -ffunction-sections -fdata-sections -fno-exceptions -fno-rtti -fno-threadsafe-statics
|
||||
CPPFLAGS += -Os -DNDEBUG
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<a href="https://emsesp.org">
|
||||
<img src="https://img.shields.io/badge/Documentation-0077b5?style=for-the-badge&logo=googledocs&logoColor=white" alt="Guides" />
|
||||
</a>
|
||||
<a href="https://discord.gg/3J3GgnzpyT">
|
||||
<a href="https://discord.gg/GP9DPSgeJq">
|
||||
<img src="https://img.shields.io/badge/Discord-7289da?style=for-the-badge&logo=discord&logoColor=white" alt="Discord" />
|
||||
</a>
|
||||
<a href="https://github.com/emsesp/EMS-ESP32/blob/main/CHANGELOG.md">
|
||||
@@ -32,7 +32,8 @@
|
||||
[](https://sonarcloud.io/summary/new_code?id=emsesp_EMS-ESP32)
|
||||
[](https://app.codacy.com/gh/emsesp/EMS-ESP32/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade)
|
||||
[](https://github.com/emsesp/EMS-ESP32/releases)
|
||||
[](https://discord.gg/3J3GgnzpyT)
|
||||
[](https://discord.gg/GP9DPSgeJq)
|
||||
[](https://deepwiki.com/emsesp/EMS-ESP32)
|
||||
|
||||
[](https://github.com/emsesp/EMS-ESP32/stargazers)
|
||||
[](https://github.com/emsesp/EMS-ESP32/network)
|
||||
@@ -40,7 +41,8 @@
|
||||
|
||||
**EMS-ESP** is an open-source firmware for the Espressif ESP32 microcontroller to communicate with **EMS** (Energy Management System) compatible equipment from manufacturers such as Bosch, Buderus, Nefit, Junkers, Worcester, Sieger, elm.leblanc and iVT.
|
||||
|
||||
It requires a small circuit to interface with the EMS bus which can be purchased from <https://bbqkees-electronics.nl> or custom built.
|
||||
It requires a small circuit to interface with the EMS bus which can be purchased from <https://bbqkees-electronics.nl>. These gateways are tested thoroughly and certified to work with EMS-ESP.
|
||||
|
||||
|
||||
## 📦 **Key Features**
|
||||
|
||||
@@ -64,17 +66,17 @@ Head over to the [Installation Guide](https://emsesp.org/Installing) section of
|
||||
|
||||
## 📋 **Documentation**
|
||||
|
||||
Visit [emsesp.org](https://emsesp.org) for more details on how to install and configure EMS-ESP. There is also a collection of Frequently Asked Questions and Troubleshooting tips with example customizations from the community.
|
||||
Visit [emsesp.org](https://emsesp.org) for more details on how to setup and configure EMS-ESP. You'll also find more a collection of example configuarations, Frequently Asked Questions and Troubleshooting tips.
|
||||
|
||||
## 💬 **Getting Support**
|
||||
|
||||
To chat with the community reach out on our [Discord Server](https://discord.gg/3J3GgnzpyT).
|
||||
To chat with the community reach out on our [Discord Server](https://discord.gg/GP9DPSgeJq).
|
||||
|
||||
If you find an issue or have a request, see [how to request support](https://emsesp.org/Support/) on how to submit a bug report or feature request.
|
||||
If you find an issue or have a request, see the [Getting Support](https://emsesp.org/Support/) section of the documentation. Note if you are using a non-BBQKees EMS gateway, you may need to contact the manufacturer for support.
|
||||
|
||||
## 🎥 **Live Demo**
|
||||
|
||||
For a live demo go to [demo.emsesp.org](https://demo.emsesp.org). Pick a language from the sign on page and log in with any username or password. Note not all features are operational as it's based on static data.
|
||||
To see a live demo go to [demo.emsesp.org](https://demo.emsesp.org). Pick a language and use any username and password to log in. Note whast you're seeing is static example data so not all features are operational.
|
||||
|
||||
## 💖 **Contributors**
|
||||
|
||||
@@ -84,7 +86,7 @@ If you like **EMS-ESP**, please give it a ✨ on GitHub, or even better fork it
|
||||
|
||||
## 📦 **Building**
|
||||
|
||||
See the [Building Guide](https://emsesp.org/Building) section of the documentation for instructions on how to build EMS-ESP.
|
||||
See the [Building the firmware](https://emsesp.org/Building) guide in the documentation for instructions on how to build EMS-ESP from this source code.
|
||||
|
||||
## 📢 **Libraries used**
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
},
|
||||
"core": "esp32",
|
||||
"extra_flags": [
|
||||
"-DTASMOTA_SDK",
|
||||
"-DARDUINO_LOLIN_C3_MINI",
|
||||
"-DARDUINO_USB_MODE=1",
|
||||
"-DARDUINO_USB_CDC_ON_BOOT=1"
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
"core": "esp32",
|
||||
"extra_flags": [
|
||||
"-DBOARD_HAS_PSRAM",
|
||||
"-DTASMOTA_SDK",
|
||||
"-DARDUINO_USB_CDC_ON_BOOT=1",
|
||||
"-DARDUINO_USB_MODE=0"
|
||||
],
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"arduino",
|
||||
"espidf"
|
||||
],
|
||||
"name": "Espressif ESP32-S3 32M Flash OPI PSRAM, 4608KB Code/OTA, 2MB FS",
|
||||
"name": "Tasmota ESP32-S3 32M Flash OPI PSRAM, 4608KB Code/OTA, 2MB FS",
|
||||
"upload": {
|
||||
"flash_size": "32MB",
|
||||
"maximum_ram_size": 327680,
|
||||
|
||||
+1
-2
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"build": {
|
||||
"core": "esp32",
|
||||
"extra_flags": "-DTASMOTA_SDK",
|
||||
"f_cpu": "240000000L",
|
||||
"f_flash": "40000000L",
|
||||
"flash_mode": "dio",
|
||||
@@ -19,7 +18,7 @@
|
||||
"arduino",
|
||||
"espidf"
|
||||
],
|
||||
"name": "Espressif ESP32 16M Flash, 4608KB Code/OTA, 2MB FS",
|
||||
"name": "Tasmota ESP32 16M Flash, 4608KB Code/OTA, 2MB FS",
|
||||
"upload": {
|
||||
"flash_size": "16MB",
|
||||
"maximum_ram_size": 327680,
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@
|
||||
"arduino",
|
||||
"espidf"
|
||||
],
|
||||
"name": "Espressif ESP32 16M Flash DIO PSRAM, 4608KB Code/OTA, 2MB FS",
|
||||
"name": "Tasmota ESP32 16M Flash DIO PSRAM, 4608KB Code/OTA, 2MB FS",
|
||||
"upload": {
|
||||
"flash_size": "16MB",
|
||||
"maximum_ram_size": 327680,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"build": {
|
||||
"core": "esp32",
|
||||
"extra_flags": "-DTASMOTA_SDK",
|
||||
"f_cpu": "240000000L",
|
||||
"f_flash": "40000000L",
|
||||
"flash_mode": "dio",
|
||||
|
||||
+4
-1
@@ -9,6 +9,7 @@
|
||||
}
|
||||
],
|
||||
"dictionaries": ["project-words"],
|
||||
"caseSensitive": false,
|
||||
"ignorePaths": [
|
||||
"node_modules",
|
||||
"compile_commands.json",
|
||||
@@ -36,6 +37,8 @@
|
||||
"pnpm-*.yaml",
|
||||
"vite.config.ts",
|
||||
"lib/esp32-psram/**",
|
||||
"test/test_api/test_api.h"
|
||||
"test/test_api/test_api.h",
|
||||
"lib_standalone/**",
|
||||
"**/*.js"
|
||||
]
|
||||
}
|
||||
+332
-74
@@ -1,85 +1,343 @@
|
||||
{
|
||||
"type": "settings",
|
||||
"Network": {
|
||||
"ssid": "my_wifi_ssid",
|
||||
"bssid": "",
|
||||
"password": "my_wifi_password",
|
||||
"hostname": "ems-esp"
|
||||
"type": "systembackup",
|
||||
"version": "3.9.0",
|
||||
"date": "2026-06-14T10:59:16",
|
||||
"systembackup": [
|
||||
{
|
||||
"type": "settings",
|
||||
"Network": {
|
||||
"ssid": "",
|
||||
"bssid": "",
|
||||
"password": "",
|
||||
"hostname": "ems-esp",
|
||||
"static_ip_config": false,
|
||||
"bandwidth20": false,
|
||||
"nosleep": true,
|
||||
"enableMDNS": true,
|
||||
"enableCORS": false,
|
||||
"CORSOrigin": "*",
|
||||
"tx_power": 0
|
||||
},
|
||||
"AP": {
|
||||
"provision_mode": 2,
|
||||
"ssid": "ems-esp",
|
||||
"password": "ems-esp-neo",
|
||||
"channel": 1,
|
||||
"ssid_hidden": false,
|
||||
"max_clients": 4,
|
||||
"local_ip": "192.168.4.1",
|
||||
"gateway_ip": "192.168.4.1",
|
||||
"subnet_mask": "255.255.255.0"
|
||||
},
|
||||
"MQTT": {
|
||||
"enableTLS": false,
|
||||
"rootCA": "",
|
||||
"enabled": true,
|
||||
"host": "192.168.1.2",
|
||||
"port": 1883,
|
||||
"base": "ems-esp",
|
||||
"username": "username",
|
||||
"password": "password",
|
||||
"client_id": "esp32",
|
||||
"keep_alive": 60,
|
||||
"clean_session": false,
|
||||
"entity_format": 1,
|
||||
"publish_time_boiler": 10,
|
||||
"publish_time_thermostat": 10,
|
||||
"publish_time_solar": 10,
|
||||
"publish_time_mixer": 10,
|
||||
"publish_time_water": 10,
|
||||
"publish_time_other": 60,
|
||||
"publish_time_sensor": 10,
|
||||
"publish_time_heartbeat": 60,
|
||||
"mqtt_qos": 0,
|
||||
"mqtt_retain": false,
|
||||
"ha_enabled": true,
|
||||
"nested_format": 1,
|
||||
"discovery_prefix": "homeassistant",
|
||||
"discovery_type": 0,
|
||||
"ha_number_mode": 1,
|
||||
"publish_single": false,
|
||||
"publish_single2cmd": false,
|
||||
"send_response": false
|
||||
},
|
||||
"NTP": {
|
||||
"enabled": true,
|
||||
"server": "time.google.com",
|
||||
"tz_label": "Europe/Amsterdam",
|
||||
"tz_format": "CET-1CEST,M3.5.0,M10.5.0/3"
|
||||
},
|
||||
"Security": {
|
||||
"jwt_secret": "ems-esp-neo",
|
||||
"users": [
|
||||
{
|
||||
"username": "admin",
|
||||
"password": "admin",
|
||||
"admin": true
|
||||
},
|
||||
{
|
||||
"username": "guest",
|
||||
"password": "guest",
|
||||
"admin": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"Settings": {
|
||||
"version": "3.9.0",
|
||||
"board_profile": "E32V2_2",
|
||||
"platform": "ESP32",
|
||||
"system_name": "prod",
|
||||
"locale": "en",
|
||||
"tx_mode": 1,
|
||||
"ems_bus_id": 73,
|
||||
"syslog_enabled": false,
|
||||
"syslog_level": 3,
|
||||
"trace_raw": false,
|
||||
"syslog_mark_interval": 0,
|
||||
"syslog_host": "",
|
||||
"syslog_port": 514,
|
||||
"boiler_heatingoff": false,
|
||||
"remote_timeout": 24,
|
||||
"remote_timeout_en": false,
|
||||
"shower_timer": true,
|
||||
"shower_alert": false,
|
||||
"shower_alert_coldshot": 10,
|
||||
"shower_alert_trigger": 7,
|
||||
"shower_min_duration": 180,
|
||||
"rx_gpio": 4,
|
||||
"tx_gpio": 5,
|
||||
"dallas_gpio": 14,
|
||||
"dallas_parasite": false,
|
||||
"led_gpio": 32,
|
||||
"hide_led": true,
|
||||
"led_type": 1,
|
||||
"low_clock": false,
|
||||
"telnet_enabled": true,
|
||||
"notoken_api": false,
|
||||
"readonly_mode": false,
|
||||
"analog_enabled": true,
|
||||
"pbutton_gpio": 34,
|
||||
"solar_maxflow": 30,
|
||||
"fahrenheit": false,
|
||||
"bool_format": 1,
|
||||
"bool_dashboard": 1,
|
||||
"enum_format": 1,
|
||||
"weblog_level": 6,
|
||||
"weblog_buffer": 50,
|
||||
"weblog_compact": true,
|
||||
"phy_type": 1,
|
||||
"eth_power": 15,
|
||||
"eth_phy_addr": 0,
|
||||
"eth_clock_mode": 1,
|
||||
"modbus_enabled": false,
|
||||
"modbus_port": 502,
|
||||
"modbus_max_clients": 10,
|
||||
"modbus_timeout": 300,
|
||||
"developer_mode": true,
|
||||
"disable_reset": false,
|
||||
"email_enabled": true,
|
||||
"email_security": 2,
|
||||
"email_server": "smtp.gmail.com",
|
||||
"email_port": 587,
|
||||
"email_login": "test@emsesp.org",
|
||||
"email_pass": "password",
|
||||
"email_sender": "test@emsesp.org",
|
||||
"email_recp": "test@emsesp.org",
|
||||
"email_subject": "ems-esp notification"
|
||||
}
|
||||
},
|
||||
"AP": {
|
||||
"provision_mode": 2,
|
||||
"ssid": "ems-esp",
|
||||
"password": "ems-esp-neo",
|
||||
"channel": 1,
|
||||
"ssid_hidden": false,
|
||||
"max_clients": 4,
|
||||
"local_ip": "192.168.4.1",
|
||||
"gateway_ip": "192.168.4.1",
|
||||
"subnet_mask": "255.255.255.0"
|
||||
{
|
||||
"type": "commands",
|
||||
"Commands": {
|
||||
"commands": [
|
||||
{
|
||||
"id": 1,
|
||||
"cmd": "{\"url\":\"https://api.pushover.net/1/messages.json\"}",
|
||||
"value": "{\"user\":\"userkey\", \"token\":\"tokenkey\", \"message\":\"custom/message2\"}",
|
||||
"name": "push_over"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"cmd": "system/message",
|
||||
"value": "hello",
|
||||
"name": "sendmessage"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"cmd": "system/message",
|
||||
"value": "{\"url\":\"http://emsesp.org/versions.json\"}",
|
||||
"name": "sendversions"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"cmd": "custom/message",
|
||||
"value": "{\"url\":\"http://emsesp.org/versions.json\"}",
|
||||
"name": "getversions"
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"cmd": "system/sendmail",
|
||||
"value": "\"test email with \" + custom/message2",
|
||||
"name": "sendmail"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"MQTT": {
|
||||
"enableTLS": false,
|
||||
"rootCA": "",
|
||||
"enabled": false,
|
||||
"host": "127.0.0.1",
|
||||
"port": 1883,
|
||||
"base": "ems-esp",
|
||||
"username": "username",
|
||||
"password": "password",
|
||||
"client_id": "ems-esp",
|
||||
"entity_format": 1,
|
||||
"publish_time_boiler": 10,
|
||||
"publish_time_thermostat": 10,
|
||||
"publish_time_solar": 10,
|
||||
"publish_time_mixer": 10,
|
||||
"publish_time_water": 10,
|
||||
"publish_time_other": 60,
|
||||
"publish_time_sensor": 10,
|
||||
"publish_time_heartbeat": 60,
|
||||
"mqtt_qos": 0,
|
||||
"mqtt_retain": false,
|
||||
"ha_enabled": false,
|
||||
"nested_format": 1,
|
||||
"discovery_prefix": "homeassistant",
|
||||
"discovery_type": 0,
|
||||
"publish_single": false,
|
||||
"publish_single2cmd": false,
|
||||
"send_response": false
|
||||
{
|
||||
"type": "schedule",
|
||||
"Schedule": {
|
||||
"schedule": [
|
||||
{
|
||||
"id": 1,
|
||||
"flags": 128,
|
||||
"active": false,
|
||||
"time": "",
|
||||
"cmd_name": "sendmail",
|
||||
"name": "sendmailtest"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"NTP": {
|
||||
"enabled": true,
|
||||
"server": "time.google.com",
|
||||
"tz_label": "Europe/Amsterdam",
|
||||
"tz_format": "CET-1CEST,M3.5.0,M10.5.0/3"
|
||||
{
|
||||
"type": "customizations",
|
||||
"Customizations": {
|
||||
"as": [
|
||||
{
|
||||
"gpio": 39,
|
||||
"name": "core_voltage",
|
||||
"offset": 0,
|
||||
"factor": 0.003771,
|
||||
"uom": 23,
|
||||
"type": 3,
|
||||
"is_system": true
|
||||
},
|
||||
{
|
||||
"gpio": 36,
|
||||
"name": "supply_voltage",
|
||||
"offset": 0,
|
||||
"factor": 0.017,
|
||||
"uom": 23,
|
||||
"type": 3,
|
||||
"is_system": true
|
||||
},
|
||||
{
|
||||
"gpio": 2,
|
||||
"name": "led",
|
||||
"offset": 0,
|
||||
"factor": 1,
|
||||
"uom": 0,
|
||||
"type": 6,
|
||||
"is_system": true
|
||||
}
|
||||
],
|
||||
"masked_entities": []
|
||||
}
|
||||
},
|
||||
"Security": {
|
||||
"jwt_secret": "ems-esp-neo",
|
||||
"users": [
|
||||
{
|
||||
"type": "entities",
|
||||
"Entities": {
|
||||
"entities": [
|
||||
{
|
||||
"id": 0,
|
||||
"ram": 1,
|
||||
"device_id": 0,
|
||||
"type_id": 0,
|
||||
"offset": 0,
|
||||
"factor": 1,
|
||||
"name": "message",
|
||||
"uom": 0,
|
||||
"value_type": 9,
|
||||
"writeable": true,
|
||||
"hide": false,
|
||||
"value": "{\"stable\":{\"version\":\"3.8.3\",\"date\":\"2026-08-01\"},\"dev\":{\"version\":\"3.9.0-dev.0\",\"date\":\"2026-08-01T09:55:29Z\"}}"
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"ram": 1,
|
||||
"device_id": 0,
|
||||
"type_id": 0,
|
||||
"offset": 0,
|
||||
"factor": 1,
|
||||
"name": "message2",
|
||||
"uom": 0,
|
||||
"value_type": 9,
|
||||
"writeable": true,
|
||||
"hide": false,
|
||||
"value": "testing"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "modules",
|
||||
"Modules": {
|
||||
"modules": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "nvs",
|
||||
"nvs": [
|
||||
{
|
||||
"username": "admin",
|
||||
"password": "admin",
|
||||
"admin": true
|
||||
"type": 1,
|
||||
"key": "nompower",
|
||||
"value": 30
|
||||
},
|
||||
{
|
||||
"username": "guest",
|
||||
"password": "guest",
|
||||
"admin": false
|
||||
"type": 33,
|
||||
"key": "boot",
|
||||
"value": "3.9.0"
|
||||
},
|
||||
{
|
||||
"type": 17,
|
||||
"key": "led",
|
||||
"value": 0
|
||||
},
|
||||
{
|
||||
"type": 33,
|
||||
"key": "intTemp",
|
||||
"value": "28_1767_7B13_2502"
|
||||
},
|
||||
{
|
||||
"type": 4,
|
||||
"key": "d_boot",
|
||||
"value": 1
|
||||
},
|
||||
{
|
||||
"type": 33,
|
||||
"key": "app1",
|
||||
"value": "3.9.0"
|
||||
},
|
||||
{
|
||||
"type": 33,
|
||||
"key": "app0",
|
||||
"value": "3.9.0"
|
||||
},
|
||||
{
|
||||
"type": 66,
|
||||
"key": "nrgheat",
|
||||
"value": 49355.29793
|
||||
},
|
||||
{
|
||||
"type": 4,
|
||||
"key": "d_app1",
|
||||
"value": 1781336521
|
||||
},
|
||||
{
|
||||
"type": 1,
|
||||
"key": "fresh_firmware",
|
||||
"value": 0
|
||||
},
|
||||
{
|
||||
"type": 4,
|
||||
"key": "d_app0",
|
||||
"value": 1781363944
|
||||
},
|
||||
{
|
||||
"type": 66,
|
||||
"key": "nrgww",
|
||||
"value": 41852.79107
|
||||
}
|
||||
]
|
||||
},
|
||||
"Settings": {
|
||||
"board_profile": "S3",
|
||||
"locale": "en",
|
||||
"tx_mode": 1,
|
||||
"ems_bus_id": 11,
|
||||
"boiler_heatingoff": false,
|
||||
"hide_led": true,
|
||||
"telnet_enabled": true,
|
||||
"notoken_api": false,
|
||||
"analog_enabled": true,
|
||||
"fahrenheit": false,
|
||||
"bool_format": 1,
|
||||
"bool_dashboard": 1,
|
||||
"enum_format": 1
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+2121
-1981
File diff suppressed because it is too large
Load Diff
+6011
-5911
File diff suppressed because it is too large
Load Diff
+11
-9
@@ -72,6 +72,7 @@ telegram_type_id,name,is_fetched
|
||||
0xE6,UBAParametersPlus,fetched
|
||||
0xE9,UBAMonitorWWPlus,
|
||||
0xEA,UBAParameterWWPlus,fetched
|
||||
0xEB,PumpKick,fetched
|
||||
0x0101,ISM1Set,fetched
|
||||
0x0103,ISM1StatusMessage,fetched
|
||||
0x0104,ISM2StatusMessage,
|
||||
@@ -111,7 +112,7 @@ telegram_type_id,name,is_fetched
|
||||
0x02A0,RC300Curves,
|
||||
0x02A1,RC300Curves,
|
||||
0x02A2,RC300Curves,
|
||||
0x02A5,RC300Monitor,
|
||||
0x02A5,CRFMonitor,
|
||||
0x02A6,RC300Monitor,
|
||||
0x02A7,RC300Monitor,
|
||||
0x02A8,CRFMonitor,
|
||||
@@ -170,14 +171,15 @@ telegram_type_id,name,is_fetched
|
||||
0x0468,HPSet,
|
||||
0x0469,HPSet,
|
||||
0x046A,HPSet,
|
||||
0x0471,RC300Summer2,
|
||||
0x0472,RC300Summer2,
|
||||
0x0473,RC300Summer2,
|
||||
0x0474,RC300Summer2,
|
||||
0x0475,RC300Summer2,
|
||||
0x0476,RC300Summer2,
|
||||
0x0477,RC300Summer2,
|
||||
0x0478,RC300Summer2,
|
||||
0x0470,RC300Summer3,fetched
|
||||
0x0471,RC300Sumr2,
|
||||
0x0472,RC300Sumr2,
|
||||
0x0473,RC300Sumr2,
|
||||
0x0474,RC300Sumr2,
|
||||
0x0475,RC300Sumr2,
|
||||
0x0476,RC300Sumr2,
|
||||
0x0477,RC300Sumr2,
|
||||
0x0478,RC300Sumr2,
|
||||
0x047B,HP2,
|
||||
0x0484,HPSilentMode,fetched
|
||||
0x0485,HpCooling,fetched
|
||||
|
||||
|
@@ -3,7 +3,7 @@ build_flags =
|
||||
; WiFi settings
|
||||
-D FACTORY_WIFI_SSID=\"\"
|
||||
-D FACTORY_WIFI_PASSWORD=\"\"
|
||||
-D FACTORY_WIFI_HOSTNAME=\"ems-esp\"
|
||||
-D FACTORY_NETWORK_HOSTNAME=\"ems-esp\"
|
||||
|
||||
; Access point settings
|
||||
-D FACTORY_AP_PROVISION_MODE=AP_MODE_DISCONNECTED
|
||||
@@ -24,6 +24,7 @@ build_flags =
|
||||
-D FACTORY_NTP_TIME_ZONE_LABEL=\"Europe/Amsterdam\"
|
||||
-D FACTORY_NTP_TIME_ZONE_FORMAT=\"CET-1CEST,M3.5.0,M10.5.0/3\"
|
||||
-D FACTORY_NTP_SERVER=\"time.google.com\"
|
||||
-D FACTORY_NTP_THERMOSTAT_SYNC=1
|
||||
|
||||
; MQTT settings
|
||||
-D FACTORY_MQTT_ENABLED=false
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"adapter": "react",
|
||||
"baseLocale": "pl",
|
||||
"$schema": "https://unpkg.com/typesafe-i18n@5.26.2/schema/typesafe-i18n.json"
|
||||
"$schema": "https://unpkg.com/typesafe-i18n@5.27.1/schema/typesafe-i18n.json"
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
// @ts-check
|
||||
import eslint from '@eslint/js';
|
||||
import prettierConfig from 'eslint-config-prettier';
|
||||
import { defineConfig } from 'eslint/config';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default tseslint.config(
|
||||
export default defineConfig(
|
||||
eslint.configs.recommended,
|
||||
...tseslint.configs.recommendedTypeChecked,
|
||||
prettierConfig,
|
||||
|
||||
+30
-39
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"name": "EMS-ESP",
|
||||
"version": "3.8.0",
|
||||
"version": "3.9.0",
|
||||
"description": "EMS-ESP WebUI",
|
||||
"homepage": "https://emsesp.org",
|
||||
"author": "proddy, emsesp.org",
|
||||
"author": "emsesp.org",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -17,55 +17,46 @@
|
||||
"preview-standalone": "typesafe-i18n --no-watch && vite build && concurrently -c \"auto\" \"pnpm:mock-rest\" \"vite preview\"",
|
||||
"standalone": "concurrently -c \"auto\" \"typesafe-i18n\" \"pnpm:mock-rest\" \"vite dev\"",
|
||||
"typesafe-i18n": "typesafe-i18n --no-watch",
|
||||
"build_webUI": "typesafe-i18n --no-watch && vite build && node progmem-generator.js",
|
||||
"build-webUI": "typesafe-i18n --no-watch && vite build && node progmem-generator.js",
|
||||
"format": "prettier -l -w '**/*.{ts,tsx,js,css,json,md}'",
|
||||
"lint": "eslint . --fix",
|
||||
"standalone-devcontainer": "concurrently -c \"auto\" \"typesafe-i18n\" \"pnpm:mock-rest\" \"vite --host\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@alova/adapter-xhr": "2.3.1",
|
||||
"@alova/adapter-xhr": "2.3.3",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@mui/icons-material": "^7.3.7",
|
||||
"@mui/material": "^7.3.7",
|
||||
"@preact/compat": "^18.3.1",
|
||||
"@mui/icons-material": "^9.3.1",
|
||||
"@mui/material": "^9.3.1",
|
||||
"@table-library/react-table-library": "4.1.15",
|
||||
"alova": "3.4.1",
|
||||
"async-validator": "^4.2.5",
|
||||
"etag": "^1.8.1",
|
||||
"formidable": "^3.5.4",
|
||||
"alova": "^3.5.4",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"magic-string": "^0.30.21",
|
||||
"mime-types": "^3.0.2",
|
||||
"preact": "^10.28.2",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-router": "^7.12.0",
|
||||
"react-toastify": "^11.0.5",
|
||||
"typesafe-i18n": "^5.26.2",
|
||||
"typescript": "^5.9.3"
|
||||
"preact": "^10.29.8",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-icons": "^5.7.0",
|
||||
"react-router": "^8.3.0",
|
||||
"typesafe-i18n": "^5.27.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.28.5",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@preact/compat": "^18.3.1",
|
||||
"@preact/preset-vite": "^2.10.2",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@preact/preset-vite": "^2.10.6",
|
||||
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
||||
"@types/node": "^25.0.6",
|
||||
"@types/react": "^19.2.8",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"axe-core": "^4.11.1",
|
||||
"concurrently": "^9.2.1",
|
||||
"eslint": "^9.39.2",
|
||||
"@types/node": "^26.2.0",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"concurrently": "^10.0.5",
|
||||
"eslint": "^10.8.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"prettier": "^3.7.4",
|
||||
"rollup-plugin-visualizer": "^6.0.5",
|
||||
"terser": "^5.44.1",
|
||||
"typescript-eslint": "^8.52.0",
|
||||
"vite": "^7.3.1",
|
||||
"vite-plugin-imagemin": "^0.6.1",
|
||||
"vite-tsconfig-paths": "^6.0.4"
|
||||
"etag": "^1.8.1",
|
||||
"mime-types": "^3.0.2",
|
||||
"prettier": "^3.9.6",
|
||||
"rollup-plugin-visualizer": "^7.1.1",
|
||||
"terser": "^5.50.0",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.67.0",
|
||||
"vite": "^8.2.1",
|
||||
"vite-plugin-imagemin": "^0.6.1"
|
||||
},
|
||||
"packageManager": "pnpm@10.28.0+sha512.05df71d1421f21399e053fde567cea34d446fa02c76571441bfc1c7956e98e363088982d940465fd34480d4d90a0668bc12362f8aa88000a64e83d0b0e47be48"
|
||||
"packageManager": "pnpm@11.22.0+sha512.1ff870c4c6133dfd88fb2afc46dd13d47f09c9794b438c6fdb47ca98caf3bc16381ee0be93a091b8e3824cf01f889f46d7d9e20910fb0be1ab0fb5baa80dd621"
|
||||
}
|
||||
|
||||
Generated
+1353
-1575
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,8 @@
|
||||
onlyBuiltDependencies:
|
||||
- cwebp-bin
|
||||
- esbuild
|
||||
- gifsicle
|
||||
- jpegtran-bin
|
||||
- mozjpeg
|
||||
- optipng-bin
|
||||
- pngquant-bin
|
||||
allowBuilds:
|
||||
cwebp-bin: false
|
||||
esbuild: false
|
||||
gifsicle: false
|
||||
jpegtran-bin: false
|
||||
mozjpeg: false
|
||||
optipng-bin: false
|
||||
pngquant-bin: false
|
||||
|
||||
@@ -24,22 +24,60 @@ let bundleStats = {
|
||||
other: { count: 0, uncompressed: 0, compressed: 0 }
|
||||
};
|
||||
|
||||
const generateWWWClass =
|
||||
() => `typedef std::function<void(const char * uri, const String & contentType, const uint8_t * content, size_t len, const String & hash)> RouteRegistrationHandler;
|
||||
// Bundle Statistics:
|
||||
// AsyncWebHandler that performs the lookup.
|
||||
const generateWWWClass = () => `// Bundle Statistics:
|
||||
// - Total compressed size: ${(totalSize / 1000).toFixed(1)} KB
|
||||
// - Total uncompressed size: ${(Object.values(bundleStats).reduce((sum, stat) => sum + stat.uncompressed, 0) / 1000).toFixed(1)} KB
|
||||
// - Compression ratio: ${(((Object.values(bundleStats).reduce((sum, stat) => sum + stat.uncompressed, 0) - totalSize) / Object.values(bundleStats).reduce((sum, stat) => sum + stat.uncompressed, 0)) * 100).toFixed(1)}%
|
||||
// - Generated on: ${new Date().toISOString()}
|
||||
|
||||
class WWWData {
|
||||
${INDENT}public:
|
||||
${INDENT.repeat(2)}static void registerRoutes(RouteRegistrationHandler handler) {
|
||||
${fileInfo.map((f) => `${INDENT.repeat(3)}handler("${f.uri}", "${f.mimeType}", ${f.variable}, ${f.size}, ${f.hash});`).join('\n')}
|
||||
${INDENT.repeat(2)}}
|
||||
struct WWWAsset {
|
||||
${INDENT}const char * uri;
|
||||
${INDENT}const char * contentType;
|
||||
${INDENT}const uint8_t * content;
|
||||
${INDENT}size_t len;
|
||||
${INDENT}const char * etag; // already includes enclosing double quotes
|
||||
};
|
||||
|
||||
static const WWWAsset WWW_ASSETS[] = {
|
||||
${fileInfo.map((f) => `${INDENT}{"${f.uri}", "${f.mimeType}", ${f.variable}, ${f.size}, "\\"${f.rawHash}\\""},`).join('\n')}
|
||||
};
|
||||
|
||||
static constexpr size_t WWW_ASSETS_COUNT = sizeof(WWW_ASSETS) / sizeof(WWW_ASSETS[0]);
|
||||
`;
|
||||
|
||||
// Optional locale allow-list, shared with the Vite build via VITE_APP_LOCALES
|
||||
// (e.g. "en,de,nl"). When set, locale chunks outside the list are NOT embedded
|
||||
// into firmware flash. `en` is always kept as the fallback. Unset => embed all.
|
||||
const ALL_LOCALES = [
|
||||
'cs',
|
||||
'de',
|
||||
'en',
|
||||
'fr',
|
||||
'it',
|
||||
'nl',
|
||||
'no',
|
||||
'pl',
|
||||
'sk',
|
||||
'sv',
|
||||
'tr'
|
||||
];
|
||||
const localeAllowList = (process.env.VITE_APP_LOCALES || '')
|
||||
.split(',')
|
||||
.map((locale) => locale.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const isExcludedLocaleChunk = (relativeFilePath) => {
|
||||
if (localeAllowList.length === 0) return false;
|
||||
const base = relativeFilePath.split(sep).pop();
|
||||
const match = /^([a-z]{2})-[A-Za-z0-9_-]+\.js$/.exec(base);
|
||||
if (!match) return false;
|
||||
const code = match[1];
|
||||
// Only treat known locale codes as locale chunks; never drop the en fallback.
|
||||
if (!ALL_LOCALES.includes(code) || code === 'en') return false;
|
||||
return !localeAllowList.includes(code);
|
||||
};
|
||||
|
||||
const getFilesSync = (dir, files = []) => {
|
||||
readdirSync(dir, { withFileTypes: true }).forEach((entry) => {
|
||||
const entryPath = resolve(dir, entry.name);
|
||||
@@ -72,6 +110,7 @@ const writeFile = (relativeFilePath, buffer) => {
|
||||
const zipBuffer = zlib.gzipSync(buffer, { level: 9 });
|
||||
// const hash = crypto.createHash('sha256').update(zipBuffer).digest('hex');
|
||||
const hash = etag(zipBuffer); // use smaller md5 instead of sha256
|
||||
const rawHash = hash.replace(/^"|"$/g, '');
|
||||
|
||||
zipBuffer.forEach((b) => {
|
||||
if (!(size % bytesPerLine)) {
|
||||
@@ -94,7 +133,8 @@ const writeFile = (relativeFilePath, buffer) => {
|
||||
mimeType,
|
||||
variable,
|
||||
size,
|
||||
hash
|
||||
hash,
|
||||
rawHash
|
||||
});
|
||||
|
||||
totalSize += size;
|
||||
@@ -108,7 +148,12 @@ writeStream.write(ARDUINO_INCLUDES);
|
||||
|
||||
const buildPath = resolve(sourcePath);
|
||||
for (const filePath of getFilesSync(buildPath)) {
|
||||
writeFile(relative(buildPath, filePath), readFileSync(filePath));
|
||||
const relativeFilePath = relative(buildPath, filePath);
|
||||
if (isExcludedLocaleChunk(relativeFilePath)) {
|
||||
console.log(`Skipping locale (not in VITE_APP_LOCALES): ${relativeFilePath}`);
|
||||
continue;
|
||||
}
|
||||
writeFile(relativeFilePath, readFileSync(filePath));
|
||||
}
|
||||
|
||||
writeStream.write(generateWWWClass());
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 2.4 KiB |
+37
-37
@@ -1,14 +1,15 @@
|
||||
import { memo, useCallback, useEffect, useState } from 'react';
|
||||
import { ToastContainer, Zoom } from 'react-toastify';
|
||||
import { memo, useEffect, useState } from 'react';
|
||||
import { Outlet } from 'react-router';
|
||||
|
||||
import AppRouting from 'AppRouting';
|
||||
import CustomTheme from 'CustomTheme';
|
||||
import { Toaster } from 'components/toast';
|
||||
import { Authentication } from 'contexts/authentication';
|
||||
import TypesafeI18n from 'i18n/i18n-react';
|
||||
import type { Locales } from 'i18n/i18n-types';
|
||||
import { loadLocaleAsync } from 'i18n/i18n-util.async';
|
||||
import { detectLocale, navigatorDetector } from 'typesafe-i18n/detectors';
|
||||
|
||||
const AVAILABLE_LOCALES = [
|
||||
const ALL_LOCALES = [
|
||||
'de',
|
||||
'en',
|
||||
'it',
|
||||
@@ -19,54 +20,53 @@ const AVAILABLE_LOCALES = [
|
||||
'sk',
|
||||
'sv',
|
||||
'tr',
|
||||
'cz'
|
||||
'cs'
|
||||
] 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'
|
||||
}
|
||||
};
|
||||
// Optional build-time allow-list (e.g. VITE_APP_LOCALES="en,de,nl"). When unset,
|
||||
// every locale is available. `en` is always kept as the fallback locale, and the
|
||||
// progmem generator embeds the matching subset into firmware flash.
|
||||
const localeAllowList = (import.meta.env.VITE_APP_LOCALES ?? '')
|
||||
.split(',')
|
||||
.map((locale) => locale.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const AVAILABLE_LOCALES: Locales[] = localeAllowList.length
|
||||
? ALL_LOCALES.filter(
|
||||
(locale) => locale === 'en' || localeAllowList.includes(locale)
|
||||
)
|
||||
: ALL_LOCALES;
|
||||
|
||||
const App = memo(() => {
|
||||
const [wasLoaded, setWasLoaded] = useState(false);
|
||||
const [locale, setLocale] = useState<Locales>('en');
|
||||
|
||||
// Memoize locale initialization to prevent unnecessary re-runs
|
||||
const initializeLocale = useCallback(async () => {
|
||||
const browserLocale = detectLocale('en', AVAILABLE_LOCALES, navigatorDetector);
|
||||
const newLocale = (localStorage.getItem('lang') || browserLocale) as Locales;
|
||||
localStorage.setItem('lang', newLocale);
|
||||
setLocale(newLocale);
|
||||
await loadLocaleAsync(newLocale);
|
||||
setWasLoaded(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const initializeLocale = async () => {
|
||||
const browserLocale = detectLocale('en', AVAILABLE_LOCALES, navigatorDetector);
|
||||
const stored = localStorage.getItem('lang');
|
||||
// Ignore a stored locale that isn't available (e.g. trimmed from this build).
|
||||
const newLocale =
|
||||
stored && AVAILABLE_LOCALES.includes(stored as Locales)
|
||||
? (stored as Locales)
|
||||
: browserLocale;
|
||||
localStorage.setItem('lang', newLocale);
|
||||
setLocale(newLocale);
|
||||
await loadLocaleAsync(newLocale);
|
||||
setWasLoaded(true);
|
||||
};
|
||||
void initializeLocale();
|
||||
}, [initializeLocale]);
|
||||
}, []);
|
||||
|
||||
if (!wasLoaded) return null;
|
||||
|
||||
return (
|
||||
<TypesafeI18n locale={locale}>
|
||||
<CustomTheme>
|
||||
<AppRouting />
|
||||
<ToastContainer {...TOAST_CONTAINER_PROPS} />
|
||||
<Authentication>
|
||||
<Outlet />
|
||||
</Authentication>
|
||||
<Toaster />
|
||||
</CustomTheme>
|
||||
</TypesafeI18n>
|
||||
);
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
import { type FC, Suspense, lazy, memo, useContext, useEffect, useRef } from 'react';
|
||||
import { Navigate, Route, Routes } from 'react-router';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
import {
|
||||
LoadingSpinner,
|
||||
RequireAuthenticated,
|
||||
RequireUnauthenticated
|
||||
} from 'components';
|
||||
import { Authentication, AuthenticationContext } from 'contexts/authentication';
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
|
||||
// Lazy load route components for better code splitting
|
||||
const SignIn = lazy(() => import('SignIn'));
|
||||
const AuthenticatedRouting = lazy(() => import('AuthenticatedRouting'));
|
||||
|
||||
interface SecurityRedirectProps {
|
||||
readonly message: string;
|
||||
readonly signOut?: boolean;
|
||||
}
|
||||
|
||||
const RootRedirect: FC<SecurityRedirectProps> = memo(
|
||||
({ message, signOut = false }) => {
|
||||
const { signOut: contextSignOut } = useContext(AuthenticationContext);
|
||||
const hasShownToast = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Prevent duplicate toasts on strict mode or re-renders
|
||||
if (!hasShownToast.current) {
|
||||
hasShownToast.current = true;
|
||||
if (signOut) {
|
||||
contextSignOut(false);
|
||||
}
|
||||
toast.success(message);
|
||||
}
|
||||
// Only run once on mount - using ref to track execution
|
||||
}, []);
|
||||
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
);
|
||||
|
||||
const AppRouting: FC = memo(() => {
|
||||
const { LL } = useI18nContext();
|
||||
|
||||
return (
|
||||
<Authentication>
|
||||
<Suspense fallback={<LoadingSpinner />}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/unauthorized"
|
||||
element={<RootRedirect message={LL.PLEASE_SIGNIN()} signOut />}
|
||||
/>
|
||||
<Route
|
||||
path="/fileUpdated"
|
||||
element={<RootRedirect message={LL.UPLOAD_SUCCESSFUL()} />}
|
||||
/>
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<RequireUnauthenticated>
|
||||
<SignIn />
|
||||
</RequireUnauthenticated>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/*"
|
||||
element={
|
||||
<RequireAuthenticated>
|
||||
<AuthenticatedRouting />
|
||||
</RequireAuthenticated>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</Authentication>
|
||||
);
|
||||
});
|
||||
|
||||
export default AppRouting;
|
||||
@@ -1,88 +0,0 @@
|
||||
import { Suspense, lazy, memo, useContext } from 'react';
|
||||
import { Navigate, Route, Routes } from 'react-router';
|
||||
|
||||
import { Layout, LoadingSpinner } from 'components';
|
||||
import { AuthenticatedContext } from 'contexts/authentication';
|
||||
|
||||
// Lazy load all route components for better code splitting
|
||||
const Dashboard = lazy(() => import('app/main/Dashboard'));
|
||||
const Devices = lazy(() => import('app/main/Devices'));
|
||||
const Sensors = lazy(() => import('app/main/Sensors'));
|
||||
const Help = lazy(() => import('app/main/Help'));
|
||||
const Customizations = lazy(() => import('app/main/Customizations'));
|
||||
const Scheduler = lazy(() => import('app/main/Scheduler'));
|
||||
const CustomEntities = lazy(() => import('app/main/CustomEntities'));
|
||||
const Modules = lazy(() => import('app/main/Modules'));
|
||||
const UserProfile = lazy(() => import('app/main/UserProfile'));
|
||||
|
||||
const Status = lazy(() => import('app/status/Status'));
|
||||
const HardwareStatus = lazy(() => import('app/status/HardwareStatus'));
|
||||
const Activity = lazy(() => import('app/status/Activity'));
|
||||
const SystemLog = lazy(() => import('app/status/SystemLog'));
|
||||
const MqttStatus = lazy(() => import('app/status/MqttStatus'));
|
||||
const NTPStatus = lazy(() => import('app/status/NTPStatus'));
|
||||
const APStatus = lazy(() => import('app/status/APStatus'));
|
||||
const NetworkStatus = lazy(() => import('app/status/NetworkStatus'));
|
||||
const Version = lazy(() => import('app/status/Version'));
|
||||
|
||||
const Settings = lazy(() => import('app/settings/Settings'));
|
||||
const ApplicationSettings = lazy(() => import('app/settings/ApplicationSettings'));
|
||||
const MqttSettings = lazy(() => import('app/settings/MqttSettings'));
|
||||
const NTPSettings = lazy(() => import('app/settings/NTPSettings'));
|
||||
const APSettings = lazy(() => import('app/settings/APSettings'));
|
||||
const DownloadUpload = lazy(() => import('app/settings/DownloadUpload'));
|
||||
const Network = lazy(() => import('app/settings/network/Network'));
|
||||
const Security = lazy(() => import('app/settings/security/Security'));
|
||||
|
||||
const AuthenticatedRouting = memo(() => {
|
||||
const { me } = useContext(AuthenticatedContext);
|
||||
return (
|
||||
<Layout>
|
||||
<Suspense fallback={<LoadingSpinner />}>
|
||||
<Routes>
|
||||
<Route path="/dashboard/*" element={<Dashboard />} />
|
||||
<Route path="/devices/*" element={<Devices />} />
|
||||
<Route path="/sensors/*" element={<Sensors />} />
|
||||
<Route path="/help/*" element={<Help />} />
|
||||
<Route path="/user/*" element={<UserProfile />} />
|
||||
|
||||
<Route path="/status/*" element={<Status />} />
|
||||
<Route path="/status/hardwarestatus/*" element={<HardwareStatus />} />
|
||||
<Route path="/status/activity" element={<Activity />} />
|
||||
<Route path="/status/log" element={<SystemLog />} />
|
||||
<Route path="/status/mqtt" element={<MqttStatus />} />
|
||||
<Route path="/status/ntp" element={<NTPStatus />} />
|
||||
<Route path="/status/ap" element={<APStatus />} />
|
||||
<Route path="/status/network" element={<NetworkStatus />} />
|
||||
<Route path="/status/version" element={<Version />} />
|
||||
|
||||
{me.admin && (
|
||||
<>
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route
|
||||
path="/settings/application"
|
||||
element={<ApplicationSettings />}
|
||||
/>
|
||||
<Route path="/settings/mqtt" element={<MqttSettings />} />
|
||||
<Route path="/settings/ntp" element={<NTPSettings />} />
|
||||
<Route path="/settings/ap" element={<APSettings />} />
|
||||
<Route path="/settings/modules" element={<Modules />} />
|
||||
<Route path="/settings/downloadUpload" element={<DownloadUpload />} />
|
||||
|
||||
<Route path="/settings/network/*" element={<Network />} />
|
||||
<Route path="/settings/security/*" element={<Security />} />
|
||||
|
||||
<Route path="/customizations" element={<Customizations />} />
|
||||
<Route path="/scheduler" element={<Scheduler />} />
|
||||
<Route path="/customentities" element={<CustomEntities />} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Route path="/*" element={<Navigate to="/" />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</Layout>
|
||||
);
|
||||
});
|
||||
|
||||
export default AuthenticatedRouting;
|
||||
+25
-22
@@ -1,23 +1,24 @@
|
||||
import { memo, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
import ForwardIcon from '@mui/icons-material/Forward';
|
||||
import { Box, Button, Paper, Typography } from '@mui/material';
|
||||
import type { Theme } from '@mui/material/styles';
|
||||
|
||||
import * as AuthenticationApi from 'components/routing/authentication';
|
||||
import { useRequest } from 'alova/client';
|
||||
import type { ValidateFieldsError } from 'async-validator';
|
||||
import {
|
||||
LanguageSelector,
|
||||
ValidatedPasswordField,
|
||||
ValidatedTextField
|
||||
} from 'components';
|
||||
import { toast } from 'components/toast';
|
||||
import { AuthenticationContext } from 'contexts/authentication';
|
||||
import { PROJECT_NAME } from 'env';
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
import type { SignInRequest } from 'types';
|
||||
import { onEnterCallback, updateValue } from 'utils';
|
||||
import { SIGN_IN_REQUEST_VALIDATOR, validate } from 'validators';
|
||||
import { SIGN_IN_REQUEST_VALIDATOR, ValidationError, validate } from 'validators';
|
||||
import type { ValidateFieldsError } from 'validators/schema';
|
||||
|
||||
const SignIn = memo(() => {
|
||||
const authenticationContext = useContext(AuthenticationContext);
|
||||
@@ -36,13 +37,12 @@ const SignIn = memo(() => {
|
||||
{
|
||||
immediate: false
|
||||
}
|
||||
).onSuccess((response) => {
|
||||
).onSuccess((response: { data: { access_token: string } }) => {
|
||||
if (response.data) {
|
||||
authenticationContext.signIn(response.data.access_token);
|
||||
}
|
||||
});
|
||||
|
||||
// Memoize callback to prevent recreation on every render
|
||||
const updateLoginRequestValue = useMemo(
|
||||
() =>
|
||||
updateValue((updater) =>
|
||||
@@ -64,7 +64,7 @@ const SignIn = memo(() => {
|
||||
});
|
||||
}, [callSignIn, signInRequest, LL]);
|
||||
|
||||
const validateAndSignIn = useCallback(async () => {
|
||||
const validateAndSignIn = async () => {
|
||||
setProcessing(true);
|
||||
SIGN_IN_REQUEST_VALIDATOR.messages({
|
||||
required: LL.IS_REQUIRED('%s')
|
||||
@@ -73,12 +73,11 @@ const SignIn = memo(() => {
|
||||
await validate(SIGN_IN_REQUEST_VALIDATOR, signInRequest);
|
||||
await signIn();
|
||||
} catch (error) {
|
||||
setFieldErrors(error as ValidateFieldsError);
|
||||
setFieldErrors((error as ValidationError).fieldErrors);
|
||||
setProcessing(false);
|
||||
}
|
||||
}, [signInRequest, signIn, LL]);
|
||||
};
|
||||
|
||||
// Memoize callback to prevent recreation on every render
|
||||
const submitOnEnter = useMemo(() => onEnterCallback(signIn), [signIn]);
|
||||
|
||||
// get rid of scrollbar
|
||||
@@ -92,13 +91,15 @@ const SignIn = memo(() => {
|
||||
|
||||
return (
|
||||
<Box
|
||||
display="flex"
|
||||
height="100vh"
|
||||
margin="auto"
|
||||
padding={2}
|
||||
justifyContent="center"
|
||||
flexDirection="column"
|
||||
maxWidth={(theme) => theme.breakpoints.values.sm}
|
||||
sx={(theme: Theme) => ({
|
||||
display: 'flex',
|
||||
height: '100vh',
|
||||
margin: 'auto',
|
||||
padding: 2,
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'column',
|
||||
maxWidth: theme.breakpoints.values.sm
|
||||
})}
|
||||
>
|
||||
<Paper
|
||||
sx={(theme) => ({
|
||||
@@ -111,16 +112,18 @@ const SignIn = memo(() => {
|
||||
width: '100%'
|
||||
})}
|
||||
>
|
||||
<Typography mb={1} variant="h4">
|
||||
<Typography sx={{ mb: 1 }} variant="h4">
|
||||
{PROJECT_NAME}
|
||||
</Typography>
|
||||
<LanguageSelector />
|
||||
<Box
|
||||
mt={1}
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
gap={1}
|
||||
alignItems="center"
|
||||
sx={{
|
||||
mt: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1,
|
||||
alignItems: 'center'
|
||||
}}
|
||||
>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
|
||||
@@ -4,6 +4,8 @@ import type {
|
||||
APIcall,
|
||||
Action,
|
||||
Activity,
|
||||
CommandItem,
|
||||
Commands,
|
||||
CoreData,
|
||||
DashboardData,
|
||||
DeviceData,
|
||||
@@ -102,8 +104,7 @@ export const readSchedule = () =>
|
||||
o_deleted: si.deleted,
|
||||
o_flags: si.flags,
|
||||
o_time: si.time,
|
||||
o_cmd: si.cmd,
|
||||
o_value: si.value,
|
||||
o_cmd_name: si.cmd_name,
|
||||
o_name: si.name
|
||||
}));
|
||||
}
|
||||
@@ -111,6 +112,24 @@ export const readSchedule = () =>
|
||||
export const writeSchedule = (data: Schedule) =>
|
||||
alovaInstance.Post('/rest/schedule', data);
|
||||
|
||||
// Commands
|
||||
export const readCommands = () =>
|
||||
alovaInstance.Get<CommandItem[]>('/rest/commands', {
|
||||
// @ts-expect-error - exactOptionalPropertyTypes compatibility issue
|
||||
transform(data) {
|
||||
const commands = (data as Commands).commands;
|
||||
return commands.map((ci) => ({
|
||||
...ci,
|
||||
o_id: ci.id,
|
||||
o_cmd: ci.cmd,
|
||||
o_value: ci.value,
|
||||
o_name: ci.name
|
||||
}));
|
||||
}
|
||||
});
|
||||
export const writeCommands = (data: Commands) =>
|
||||
alovaInstance.Post('/rest/commands', data);
|
||||
|
||||
// Modules
|
||||
export const readModules = () =>
|
||||
alovaInstance.Get<ModuleItem[]>('/rest/modules', {
|
||||
|
||||
@@ -57,12 +57,3 @@ export const alovaInstance = createAlova({
|
||||
onSuccess: handleResponse
|
||||
}
|
||||
});
|
||||
|
||||
export const alovaInstanceGH = createAlova({
|
||||
baseURL:
|
||||
process.env.NODE_ENV === 'development'
|
||||
? '/gh'
|
||||
: 'https://api.github.com/repos/emsesp/EMS-ESP32/releases',
|
||||
statesHook: ReactHook,
|
||||
requestAdapter: xhrRequestAdapter()
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { LogSettings, SystemStatus } from 'types';
|
||||
|
||||
import { alovaInstance, alovaInstanceGH } from './endpoints';
|
||||
import { alovaInstance } from './endpoints';
|
||||
|
||||
// systemStatus - also used to ping in System Monitor for pinging
|
||||
export const readSystemStatus = () =>
|
||||
@@ -13,29 +13,6 @@ export const updateLogSettings = (data: LogSettings) =>
|
||||
alovaInstance.Post('/rest/logSettings', data);
|
||||
export const fetchLogES = () => alovaInstance.Get('/es/log');
|
||||
|
||||
// Get versions from GitHub
|
||||
// cache for 10 minutes to stop getting the IP blocked by GitHub
|
||||
export const getStableVersion = () =>
|
||||
alovaInstanceGH.Get('latest', {
|
||||
cacheFor: 60 * 10 * 1000,
|
||||
transform(response: { data: { name: string; published_at: string } }) {
|
||||
return {
|
||||
name: response.data.name.substring(1),
|
||||
published_at: response.data.published_at
|
||||
};
|
||||
}
|
||||
});
|
||||
export const getDevVersion = () =>
|
||||
alovaInstanceGH.Get('tags/latest', {
|
||||
cacheFor: 60 * 10 * 1000,
|
||||
transform(response: { data: { name: string; published_at: string } }) {
|
||||
return {
|
||||
name: response.data.name.split(/\s+/).splice(-1)[0]?.substring(1) || '',
|
||||
published_at: response.data.published_at
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const UPLOAD_TIMEOUT = 60000; // 1 minute
|
||||
|
||||
export const uploadFile = (file: File) => {
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
import { useState } from 'react';
|
||||
import { useBlocker } from 'react-router';
|
||||
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import WarningIcon from '@mui/icons-material/Warning';
|
||||
import { Box, Button, Typography } from '@mui/material';
|
||||
|
||||
import {
|
||||
Body,
|
||||
Cell,
|
||||
Header,
|
||||
HeaderCell,
|
||||
HeaderRow,
|
||||
Row,
|
||||
Table
|
||||
} from '@table-library/react-table-library/table';
|
||||
import { useTheme } from '@table-library/react-table-library/theme';
|
||||
import { updateState, useRequest } from 'alova/client';
|
||||
import {
|
||||
BlockNavigation,
|
||||
ButtonRow,
|
||||
FormLoader,
|
||||
SectionContent,
|
||||
useLayoutTitle
|
||||
} from 'components';
|
||||
import { toast } from 'components/toast';
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
import { useInterval } from 'utils';
|
||||
|
||||
import { readCommands, writeCommands } from '../../api/app';
|
||||
import CommandsDialog from './CommandsDialog';
|
||||
import type { CommandItem, Commands as CommandsType } from './types';
|
||||
import { commandItemValidation } from './validators';
|
||||
|
||||
const INTERVAL_DELAY = 30000;
|
||||
const MIN_ID = -100;
|
||||
const MAX_ID = 100;
|
||||
|
||||
const DEFAULT_COMMAND_ITEM: Omit<CommandItem, 'id'> = {
|
||||
cmd: '',
|
||||
value: '',
|
||||
name: '',
|
||||
deleted: false
|
||||
};
|
||||
|
||||
const commandsTheme = {
|
||||
Table: `
|
||||
--data-table-library_grid-template-columns: repeat(1, minmax(100px, 1fr)) repeat(1, minmax(100px, 1fr)) 160px;
|
||||
`,
|
||||
BaseRow: `
|
||||
font-size: 14px;
|
||||
.td {
|
||||
height: 32px;
|
||||
}
|
||||
`,
|
||||
BaseCell: `
|
||||
&:nth-of-type(1) {
|
||||
padding: 8px;
|
||||
}
|
||||
`,
|
||||
HeaderRow: `
|
||||
text-transform: uppercase;
|
||||
background-color: black;
|
||||
color: #90CAF9;
|
||||
.th {
|
||||
border-bottom: 1px solid #565656;
|
||||
height: 36px;
|
||||
}
|
||||
`,
|
||||
Row: `
|
||||
background-color: #1e1e1e;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
.td {
|
||||
border-bottom: 1px solid #565656;
|
||||
}
|
||||
&:hover .td {
|
||||
background-color: #177ac9;
|
||||
}
|
||||
`
|
||||
};
|
||||
|
||||
const Commands = () => {
|
||||
const { LL } = useI18nContext();
|
||||
const [numChanges, setNumChanges] = useState<number>(0);
|
||||
const blocker = useBlocker(numChanges !== 0);
|
||||
const [selectedItem, setSelectedItem] = useState<CommandItem>();
|
||||
const [creating, setCreating] = useState<boolean>(false);
|
||||
const [dialogOpen, setDialogOpen] = useState<boolean>(false);
|
||||
|
||||
useLayoutTitle(LL.COMMANDS());
|
||||
|
||||
const {
|
||||
data: commands,
|
||||
send: fetchCommands,
|
||||
error
|
||||
} = useRequest(readCommands, {
|
||||
initialData: []
|
||||
});
|
||||
|
||||
const { send: updateCommands } = useRequest(
|
||||
(data: CommandsType) => writeCommands(data),
|
||||
{ immediate: false }
|
||||
);
|
||||
|
||||
const hasChanged = (ci: CommandItem) =>
|
||||
ci.id !== ci.o_id ||
|
||||
(ci.name || '') !== (ci.o_name || '') ||
|
||||
ci.cmd !== ci.o_cmd ||
|
||||
ci.value !== ci.o_value ||
|
||||
ci.deleted !== ci.o_deleted;
|
||||
|
||||
useInterval(() => {
|
||||
if (numChanges === 0) {
|
||||
void fetchCommands();
|
||||
}
|
||||
}, INTERVAL_DELAY);
|
||||
|
||||
const theme = useTheme(commandsTheme);
|
||||
|
||||
const saveCommands = async () => {
|
||||
try {
|
||||
await updateCommands({
|
||||
commands: commands
|
||||
.filter((ci: CommandItem) => !ci.deleted)
|
||||
.map((ci: CommandItem) => ({
|
||||
id: ci.id,
|
||||
cmd: ci.cmd,
|
||||
value: ci.value,
|
||||
name: ci.name
|
||||
}))
|
||||
});
|
||||
toast.success(LL.UPDATED_OF(LL.COMMANDS()));
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
toast.error(message);
|
||||
} finally {
|
||||
await fetchCommands();
|
||||
setNumChanges(0);
|
||||
}
|
||||
};
|
||||
|
||||
const editItem = (ci: CommandItem) => {
|
||||
setCreating(false);
|
||||
setSelectedItem(ci);
|
||||
setDialogOpen(true);
|
||||
if (ci.o_name === undefined) {
|
||||
ci.o_name = ci.name;
|
||||
}
|
||||
};
|
||||
|
||||
const onDialogClose = () => {
|
||||
setDialogOpen(false);
|
||||
};
|
||||
|
||||
const onDialogCancel = async () => {
|
||||
await fetchCommands().then(() => {
|
||||
setNumChanges(0);
|
||||
});
|
||||
};
|
||||
|
||||
const onDialogSave = (updatedItem: CommandItem) => {
|
||||
setDialogOpen(false);
|
||||
void updateState(readCommands(), (data: CommandItem[]) => {
|
||||
const new_data = creating
|
||||
? [...data, updatedItem]
|
||||
: data.map((ci) =>
|
||||
ci.id === updatedItem.id ? { ...ci, ...updatedItem } : ci
|
||||
);
|
||||
setNumChanges(new_data.filter((ci) => hasChanged(ci)).length);
|
||||
return new_data;
|
||||
});
|
||||
};
|
||||
|
||||
const addItem = () => {
|
||||
setCreating(true);
|
||||
const newItem: CommandItem = {
|
||||
id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID),
|
||||
...DEFAULT_COMMAND_ITEM
|
||||
};
|
||||
setSelectedItem(newItem);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const filteredCommands = commands.filter((ci: CommandItem) => !ci.deleted);
|
||||
|
||||
const renderCommands = () => {
|
||||
if (!commands) {
|
||||
return (
|
||||
<FormLoader onRetry={fetchCommands} errorMessage={error?.message || ''} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Table
|
||||
data={{ nodes: filteredCommands }}
|
||||
theme={theme}
|
||||
layout={{ custom: true }}
|
||||
>
|
||||
{(tableList: CommandItem[]) => (
|
||||
<>
|
||||
<Header>
|
||||
<HeaderRow>
|
||||
<HeaderCell stiff>{LL.COMMAND(0)}</HeaderCell>
|
||||
<HeaderCell stiff>{LL.VALUE(0)}</HeaderCell>
|
||||
<HeaderCell stiff>{LL.NAME(0)}</HeaderCell>
|
||||
</HeaderRow>
|
||||
</Header>
|
||||
<Body>
|
||||
{tableList.map((ci: CommandItem) => (
|
||||
<Row key={ci.id} item={ci} onClick={() => editItem(ci)}>
|
||||
<Cell>{ci.cmd}</Cell>
|
||||
<Cell>{ci.value}</Cell>
|
||||
<Cell>{ci.name}</Cell>
|
||||
</Row>
|
||||
))}
|
||||
</Body>
|
||||
</>
|
||||
)}
|
||||
</Table>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionContent>
|
||||
{blocker ? <BlockNavigation blocker={blocker} /> : null}
|
||||
<Typography sx={{ mb: 2 }} color="warning" variant="body1">
|
||||
{LL.COMMANDS_HELP_1()}.
|
||||
</Typography>
|
||||
{renderCommands()}
|
||||
|
||||
{selectedItem && (
|
||||
<CommandsDialog
|
||||
open={dialogOpen}
|
||||
creating={creating}
|
||||
onClose={onDialogClose}
|
||||
onSave={onDialogSave}
|
||||
selectedItem={selectedItem}
|
||||
validator={commandItemValidation(commands, selectedItem)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap' }}>
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
{numChanges !== 0 && (
|
||||
<ButtonRow>
|
||||
<Button
|
||||
startIcon={<CancelIcon />}
|
||||
variant="outlined"
|
||||
onClick={onDialogCancel}
|
||||
color="secondary"
|
||||
>
|
||||
{LL.CANCEL()}
|
||||
</Button>
|
||||
<Button
|
||||
startIcon={<WarningIcon color="warning" />}
|
||||
variant="contained"
|
||||
color="info"
|
||||
onClick={saveCommands}
|
||||
>
|
||||
{LL.APPLY_CHANGES(numChanges)}
|
||||
</Button>
|
||||
</ButtonRow>
|
||||
)}
|
||||
</Box>
|
||||
<Box sx={{ flexWrap: 'nowrap', whiteSpace: 'nowrap' }}>
|
||||
<ButtonRow>
|
||||
<Button
|
||||
startIcon={<AddIcon />}
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
onClick={addItem}
|
||||
>
|
||||
{LL.ADD(0)}
|
||||
</Button>
|
||||
</ButtonRow>
|
||||
</Box>
|
||||
</Box>
|
||||
</SectionContent>
|
||||
);
|
||||
};
|
||||
|
||||
export default Commands;
|
||||
@@ -0,0 +1,206 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import DoneIcon from '@mui/icons-material/Done';
|
||||
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
||||
import RemoveIcon from '@mui/icons-material/RemoveCircleOutlined';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
TextField
|
||||
} from '@mui/material';
|
||||
|
||||
import { callAction } from '@/api/app';
|
||||
import { dialogStyle } from 'CustomTheme';
|
||||
import { useRequest } from 'alova/client';
|
||||
import { ValidatedTextField } from 'components';
|
||||
import { toast } from 'components/toast';
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
import { updateValue } from 'utils';
|
||||
import { ValidationError, validate } from 'validators';
|
||||
import type Schema from 'validators/schema';
|
||||
import type { ValidateFieldsError } from 'validators/schema';
|
||||
|
||||
import type { CommandItem } from './types';
|
||||
|
||||
interface CommandsDialogProps {
|
||||
open: boolean;
|
||||
creating: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (ci: CommandItem) => void;
|
||||
selectedItem: CommandItem;
|
||||
validator: Schema;
|
||||
}
|
||||
|
||||
const CommandsDialog = ({
|
||||
open,
|
||||
creating,
|
||||
onClose,
|
||||
onSave,
|
||||
selectedItem,
|
||||
validator
|
||||
}: CommandsDialogProps) => {
|
||||
const { LL } = useI18nContext();
|
||||
const [hasChanges, setHasChanges] = useState<boolean>(false);
|
||||
const [editItem, setEditItem] = useState<CommandItem>(selectedItem);
|
||||
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
||||
|
||||
const updateFormValue = updateValue(
|
||||
setEditItem as unknown as React.Dispatch<
|
||||
React.SetStateAction<Record<string, unknown>>
|
||||
>
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setFieldErrors(undefined);
|
||||
setEditItem(selectedItem);
|
||||
}
|
||||
}, [open, selectedItem]);
|
||||
|
||||
const hasChanged = (ci: CommandItem) =>
|
||||
ci.id !== ci.o_id ||
|
||||
(ci.name || '') !== (ci.o_name || '') ||
|
||||
ci.cmd !== ci.o_cmd ||
|
||||
ci.value !== ci.o_value ||
|
||||
ci.deleted !== ci.o_deleted;
|
||||
|
||||
useEffect(() => {
|
||||
setHasChanges(hasChanged(editItem));
|
||||
}, [editItem]);
|
||||
|
||||
const handleSave = async (itemToSave: CommandItem) => {
|
||||
try {
|
||||
setFieldErrors(undefined);
|
||||
await validate(validator, itemToSave);
|
||||
onSave(itemToSave);
|
||||
} catch (error) {
|
||||
setFieldErrors((error as ValidationError).fieldErrors);
|
||||
} finally {
|
||||
// setHasChanges(false);
|
||||
}
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
await handleSave(editItem);
|
||||
};
|
||||
|
||||
const { send: executeCommand } = useRequest(
|
||||
(id: string) => callAction({ action: 'executeCommand', param: id }),
|
||||
{ immediate: false }
|
||||
)
|
||||
.onSuccess(() => {
|
||||
toast.success(LL.COMMAND_SENT());
|
||||
})
|
||||
.onError((error) => {
|
||||
toast.error(String(error.error?.message || 'An error occurred'));
|
||||
});
|
||||
|
||||
const execute = async () => {
|
||||
await executeCommand(editItem.name);
|
||||
};
|
||||
|
||||
const remove = () => {
|
||||
onSave({ ...editItem, deleted: true });
|
||||
};
|
||||
|
||||
const handleClose = (
|
||||
_event: React.SyntheticEvent,
|
||||
reason: 'backdropClick' | 'escapeKeyDown'
|
||||
) => {
|
||||
if (reason !== 'backdropClick') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
|
||||
<DialogTitle>
|
||||
{creating ? `${LL.ADD(1)} ` : LL.UPDATE() + '/' + LL.RUN()}
|
||||
{LL.COMMAND(0)}
|
||||
</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
name="cmd"
|
||||
label={LL.COMMAND(0)}
|
||||
multiline
|
||||
fullWidth
|
||||
value={editItem.cmd}
|
||||
margin="normal"
|
||||
onChange={updateFormValue}
|
||||
/>
|
||||
<TextField
|
||||
name="value"
|
||||
label={LL.VALUE(0)}
|
||||
multiline
|
||||
margin="normal"
|
||||
fullWidth
|
||||
value={editItem.value}
|
||||
onChange={updateFormValue}
|
||||
/>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
name="name"
|
||||
label={LL.NAME(0)}
|
||||
value={editItem.name}
|
||||
fullWidth
|
||||
margin="normal"
|
||||
onChange={updateFormValue}
|
||||
/>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
{!creating && (
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Button
|
||||
startIcon={<RemoveIcon />}
|
||||
variant="outlined"
|
||||
color="warning"
|
||||
onClick={remove}
|
||||
>
|
||||
{LL.REMOVE()}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
<Button
|
||||
startIcon={<CancelIcon />}
|
||||
variant="outlined"
|
||||
onClick={onClose}
|
||||
color="secondary"
|
||||
>
|
||||
{LL.CANCEL()}
|
||||
</Button>
|
||||
|
||||
{hasChanges && (
|
||||
<Button
|
||||
startIcon={creating ? <AddIcon /> : <DoneIcon />}
|
||||
variant="outlined"
|
||||
onClick={save}
|
||||
color="primary"
|
||||
>
|
||||
{creating ? LL.ADD(0) : LL.UPDATE()}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!creating && !hasChanges && editItem.cmd !== '' && (
|
||||
<Button
|
||||
startIcon={<PlayArrowIcon />}
|
||||
variant="outlined"
|
||||
onClick={execute}
|
||||
color="success"
|
||||
>
|
||||
{LL.RUN()}
|
||||
</Button>
|
||||
)}
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default CommandsDialog;
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useBlocker } from 'react-router';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
@@ -26,6 +25,7 @@ import {
|
||||
SectionContent,
|
||||
useLayoutTitle
|
||||
} from 'components';
|
||||
import { toast } from 'components/toast';
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
import { useInterval } from 'utils';
|
||||
|
||||
@@ -57,20 +57,18 @@ const CustomEntities = () => {
|
||||
initialData: []
|
||||
});
|
||||
|
||||
const intervalCallback = useCallback(() => {
|
||||
useInterval(() => {
|
||||
if (!dialogOpen && !numChanges) {
|
||||
void fetchEntities();
|
||||
}
|
||||
}, [dialogOpen, numChanges, fetchEntities]);
|
||||
|
||||
useInterval(intervalCallback);
|
||||
});
|
||||
|
||||
const { send: writeEntities } = useRequest(
|
||||
(data: Entities) => writeCustomEntities(data),
|
||||
{ immediate: false }
|
||||
);
|
||||
|
||||
const hasEntityChanged = useCallback((ei: EntityItem) => {
|
||||
const hasEntityChanged = (ei: EntityItem) => {
|
||||
return (
|
||||
ei.id !== ei.o_id ||
|
||||
ei.ram !== ei.o_ram ||
|
||||
@@ -86,21 +84,19 @@ const CustomEntities = () => {
|
||||
ei.deleted !== ei.o_deleted ||
|
||||
(ei.value || '') !== (ei.o_value || '')
|
||||
);
|
||||
}, []);
|
||||
};
|
||||
|
||||
const entity_theme = useMemo(
|
||||
() =>
|
||||
useTheme({
|
||||
Table: `
|
||||
const entity_theme = useTheme({
|
||||
Table: `
|
||||
--data-table-library_grid-template-columns: repeat(1, minmax(60px, 1fr)) minmax(80px, auto) 80px 80px 80px 120px;
|
||||
`,
|
||||
BaseRow: `
|
||||
BaseRow: `
|
||||
font-size: 14px;
|
||||
.td {
|
||||
height: 32px;
|
||||
}
|
||||
`,
|
||||
BaseCell: `
|
||||
BaseCell: `
|
||||
&:nth-of-type(1) {
|
||||
padding: 8px;
|
||||
}
|
||||
@@ -120,7 +116,7 @@ const CustomEntities = () => {
|
||||
text-align: center;
|
||||
}
|
||||
`,
|
||||
HeaderRow: `
|
||||
HeaderRow: `
|
||||
text-transform: uppercase;
|
||||
background-color: black;
|
||||
color: #90CAF9;
|
||||
@@ -129,7 +125,7 @@ const CustomEntities = () => {
|
||||
height: 36px;
|
||||
}
|
||||
`,
|
||||
Row: `
|
||||
Row: `
|
||||
background-color: #1e1e1e;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
@@ -140,11 +136,9 @@ const CustomEntities = () => {
|
||||
background-color: #177ac9;
|
||||
}
|
||||
`
|
||||
}),
|
||||
[]
|
||||
);
|
||||
});
|
||||
|
||||
const saveEntities = useCallback(async () => {
|
||||
const saveEntities = async () => {
|
||||
await writeEntities({
|
||||
entities: entities
|
||||
.filter((ei: EntityItem) => !ei.deleted)
|
||||
@@ -173,44 +167,41 @@ const CustomEntities = () => {
|
||||
await fetchEntities();
|
||||
setNumChanges(0);
|
||||
});
|
||||
}, [entities, writeEntities, LL, fetchEntities]);
|
||||
};
|
||||
|
||||
const editEntityItem = useCallback((ei: EntityItem) => {
|
||||
const editEntityItem = (ei: EntityItem) => {
|
||||
setCreating(false);
|
||||
setSelectedEntityItem(ei);
|
||||
setDialogOpen(true);
|
||||
}, []);
|
||||
};
|
||||
|
||||
const onDialogClose = useCallback(() => {
|
||||
const onDialogClose = () => {
|
||||
setDialogOpen(false);
|
||||
}, []);
|
||||
};
|
||||
|
||||
const onDialogCancel = useCallback(async () => {
|
||||
const onDialogCancel = async () => {
|
||||
await fetchEntities().then(() => {
|
||||
setNumChanges(0);
|
||||
});
|
||||
}, [fetchEntities]);
|
||||
};
|
||||
|
||||
const onDialogSave = useCallback(
|
||||
(updatedItem: EntityItem) => {
|
||||
setDialogOpen(false);
|
||||
void updateState(readCustomEntities(), (data: EntityItem[]) => {
|
||||
const new_data = creating
|
||||
? [
|
||||
...data.filter((ei) => creating || ei.o_id !== updatedItem.o_id),
|
||||
updatedItem
|
||||
]
|
||||
: data.map((ei) =>
|
||||
ei.id === updatedItem.id ? { ...ei, ...updatedItem } : ei
|
||||
);
|
||||
setNumChanges(new_data.filter((ei) => hasEntityChanged(ei)).length);
|
||||
return new_data;
|
||||
});
|
||||
},
|
||||
[creating, hasEntityChanged]
|
||||
);
|
||||
const onDialogSave = (updatedItem: EntityItem) => {
|
||||
setDialogOpen(false);
|
||||
void updateState(readCustomEntities(), (data: EntityItem[]) => {
|
||||
const new_data = creating
|
||||
? [
|
||||
...data.filter((ei) => creating || ei.o_id !== updatedItem.o_id),
|
||||
updatedItem
|
||||
]
|
||||
: data.map((ei) =>
|
||||
ei.id === updatedItem.id ? { ...ei, ...updatedItem } : ei
|
||||
);
|
||||
setNumChanges(new_data.filter((ei) => hasEntityChanged(ei)).length);
|
||||
return new_data;
|
||||
});
|
||||
};
|
||||
|
||||
const onDialogDup = useCallback((item: EntityItem) => {
|
||||
const onDialogDup = (item: EntityItem) => {
|
||||
setCreating(true);
|
||||
setSelectedEntityItem({
|
||||
id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID),
|
||||
@@ -228,9 +219,9 @@ const CustomEntities = () => {
|
||||
value: item.value
|
||||
});
|
||||
setDialogOpen(true);
|
||||
}, []);
|
||||
};
|
||||
|
||||
const addEntityItem = useCallback(() => {
|
||||
const addEntityItem = () => {
|
||||
setCreating(true);
|
||||
setSelectedEntityItem({
|
||||
id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID),
|
||||
@@ -248,30 +239,27 @@ const CustomEntities = () => {
|
||||
value: ''
|
||||
});
|
||||
setDialogOpen(true);
|
||||
}, []);
|
||||
};
|
||||
|
||||
const formatValue = useCallback((value: unknown, uom: number) => {
|
||||
const formatValue = (value: unknown, uom: number) => {
|
||||
return value === undefined
|
||||
? ''
|
||||
: typeof value === 'number'
|
||||
? new Intl.NumberFormat().format(value) +
|
||||
(uom === 0 ? '' : ` ${DeviceValueUOM_s[uom]}`)
|
||||
: `${value as string}${uom === 0 ? '' : ` ${DeviceValueUOM_s[uom]}`}`;
|
||||
}, []);
|
||||
};
|
||||
|
||||
const showHex = useCallback((value: number, digit: number) => {
|
||||
const showHex = (value: number, digit: number) => {
|
||||
return `0x${value.toString(16).toUpperCase().padStart(digit, '0')}`;
|
||||
}, []);
|
||||
};
|
||||
|
||||
const filteredAndSortedEntities = useMemo(
|
||||
() =>
|
||||
entities
|
||||
?.filter((ei: EntityItem) => !ei.deleted)
|
||||
.sort((a: EntityItem, b: EntityItem) => a.name.localeCompare(b.name)) ?? [],
|
||||
[entities]
|
||||
);
|
||||
const filteredAndSortedEntities =
|
||||
entities
|
||||
?.filter((ei: EntityItem) => !ei.deleted)
|
||||
.sort((a: EntityItem, b: EntityItem) => a.name.localeCompare(b.name)) ?? [];
|
||||
|
||||
const renderEntity = useCallback(() => {
|
||||
const renderEntity = () => {
|
||||
if (!entities) {
|
||||
return (
|
||||
<FormLoader onRetry={fetchEntities} errorMessage={error?.message || ''} />
|
||||
@@ -310,13 +298,15 @@ const CustomEntities = () => {
|
||||
/>
|
||||
)}
|
||||
</Cell>
|
||||
<Cell>{ei.ram > 0 ? '' : showHex(ei.device_id as number, 2)}</Cell>
|
||||
<Cell>{ei.ram > 0 ? '' : showHex(ei.type_id as number, 3)}</Cell>
|
||||
<Cell>{ei.ram > 0 ? '' : ei.offset}</Cell>
|
||||
<Cell>
|
||||
{ei.ram === 1 ? '' : showHex(ei.device_id as number, 2)}
|
||||
</Cell>
|
||||
<Cell>{ei.ram === 1 ? '' : showHex(ei.type_id as number, 3)}</Cell>
|
||||
<Cell>{ei.ram === 1 ? '' : ei.offset}</Cell>
|
||||
<Cell>
|
||||
{ei.ram === 1 ? 'RAM' : DeviceValueTypeNames[ei.value_type]}
|
||||
{ei.ram === 1
|
||||
? 'RAM'
|
||||
: ei.ram === 2
|
||||
? 'NVS'
|
||||
: DeviceValueTypeNames[ei.value_type]}
|
||||
</Cell>
|
||||
<Cell>{formatValue(ei.value, ei.uom)}</Cell>
|
||||
</Row>
|
||||
@@ -326,24 +316,14 @@ const CustomEntities = () => {
|
||||
)}
|
||||
</Table>
|
||||
);
|
||||
}, [
|
||||
entities,
|
||||
error,
|
||||
fetchEntities,
|
||||
entity_theme,
|
||||
editEntityItem,
|
||||
LL,
|
||||
filteredAndSortedEntities,
|
||||
showHex,
|
||||
formatValue
|
||||
]);
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionContent>
|
||||
{blocker ? <BlockNavigation blocker={blocker} /> : null}
|
||||
<Box mb={2} color="warning.main">
|
||||
<Typography variant="body1">{LL.ENTITIES_HELP_1()}.</Typography>
|
||||
</Box>
|
||||
<Typography sx={{ mb: 2 }} color="warning" variant="body1">
|
||||
{LL.ENTITIES_HELP_1()}.
|
||||
</Typography>
|
||||
|
||||
{renderEntity()}
|
||||
|
||||
@@ -359,8 +339,8 @@ const CustomEntities = () => {
|
||||
/>
|
||||
)}
|
||||
|
||||
<Box mt={2} display="flex" flexWrap="wrap">
|
||||
<Box flexGrow={1}>
|
||||
<Box sx={{ mt: 2, display: 'flex', flexWrap: 'wrap' }}>
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
{numChanges > 0 && (
|
||||
<ButtonRow>
|
||||
<Button
|
||||
@@ -382,7 +362,7 @@ const CustomEntities = () => {
|
||||
</ButtonRow>
|
||||
)}
|
||||
</Box>
|
||||
<Box flexWrap="nowrap" whiteSpace="nowrap">
|
||||
<Box sx={{ flexWrap: 'nowrap', whiteSpace: 'nowrap' }}>
|
||||
<Button
|
||||
startIcon={<AddIcon />}
|
||||
variant="outlined"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
@@ -7,7 +7,7 @@ import DoneIcon from '@mui/icons-material/Done';
|
||||
import EditOffOutlinedIcon from '@mui/icons-material/EditOffOutlined';
|
||||
import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
|
||||
import InsertCommentOutlinedIcon from '@mui/icons-material/InsertCommentOutlined';
|
||||
import RemoveIcon from '@mui/icons-material/RemoveCircleOutline';
|
||||
import RemoveIcon from '@mui/icons-material/RemoveCircleOutlined';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
@@ -23,12 +23,12 @@ import {
|
||||
} from '@mui/material';
|
||||
|
||||
import { dialogStyle } from 'CustomTheme';
|
||||
import type Schema from 'async-validator';
|
||||
import type { ValidateFieldsError } from 'async-validator';
|
||||
import { BlockFormControlLabel, ValidatedTextField } from 'components';
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
import { numberValue, updateValue } from 'utils';
|
||||
import { validate } from 'validators';
|
||||
import { ValidationError, validate } from 'validators';
|
||||
import type Schema from 'validators/schema';
|
||||
import type { ValidateFieldsError } from 'validators/schema';
|
||||
|
||||
import { DeviceValueType, DeviceValueTypeNames, DeviceValueUOM_s } from './types';
|
||||
import type { EntityItem } from './types';
|
||||
@@ -68,14 +68,10 @@ const CustomEntitiesDialog = ({
|
||||
const { LL } = useI18nContext();
|
||||
const [editItem, setEditItem] = useState<EntityItem>(selectedItem);
|
||||
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
||||
const updateFormValue = useMemo(
|
||||
() =>
|
||||
updateValue(
|
||||
setEditItem as unknown as React.Dispatch<
|
||||
React.SetStateAction<Record<string, unknown>>
|
||||
>
|
||||
),
|
||||
[]
|
||||
const updateFormValue = updateValue(
|
||||
setEditItem as unknown as React.Dispatch<
|
||||
React.SetStateAction<Record<string, unknown>>
|
||||
>
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -105,16 +101,16 @@ const CustomEntitiesDialog = ({
|
||||
}
|
||||
}, [open, selectedItem]);
|
||||
|
||||
const handleClose = useCallback(
|
||||
(_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => {
|
||||
if (reason !== 'backdropClick') {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[onClose]
|
||||
);
|
||||
const handleClose = (
|
||||
_event: React.SyntheticEvent,
|
||||
reason: 'backdropClick' | 'escapeKeyDown'
|
||||
) => {
|
||||
if (reason !== 'backdropClick') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const save = useCallback(async () => {
|
||||
const save = async () => {
|
||||
try {
|
||||
setFieldErrors(undefined);
|
||||
await validate(validator, editItem);
|
||||
@@ -136,29 +132,23 @@ const CustomEntitiesDialog = ({
|
||||
}
|
||||
onSave(processedItem);
|
||||
} catch (error) {
|
||||
setFieldErrors(error as ValidateFieldsError);
|
||||
setFieldErrors((error as ValidationError).fieldErrors);
|
||||
}
|
||||
}, [validator, editItem, onSave]);
|
||||
};
|
||||
|
||||
const remove = useCallback(() => {
|
||||
const itemWithDeleted = { ...editItem, deleted: true };
|
||||
onSave(itemWithDeleted);
|
||||
}, [editItem, onSave]);
|
||||
const remove = () => {
|
||||
onSave({ ...editItem, deleted: true });
|
||||
};
|
||||
|
||||
const dup = useCallback(() => {
|
||||
const dup = () => {
|
||||
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>
|
||||
)),
|
||||
[]
|
||||
);
|
||||
const uomMenuItems = DeviceValueUOM_s.map((val, i) => (
|
||||
<MenuItem key={val} value={i}>
|
||||
{val}
|
||||
</MenuItem>
|
||||
));
|
||||
|
||||
return (
|
||||
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
|
||||
@@ -178,7 +168,7 @@ const CustomEntitiesDialog = ({
|
||||
onChange={updateFormValue}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid mt={3}>
|
||||
<Grid sx={{ mt: 3 }}>
|
||||
<BlockFormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
@@ -205,16 +195,17 @@ const CustomEntitiesDialog = ({
|
||||
>
|
||||
<MenuItem value={0}>EMS-{LL.VALUE(1)}</MenuItem>
|
||||
<MenuItem value={1}>RAM-{LL.VALUE(1)}</MenuItem>
|
||||
<MenuItem value={2}>NVS-{LL.VALUE(1)}</MenuItem>
|
||||
</TextField>
|
||||
</Grid>
|
||||
{editItem.ram === 1 && (
|
||||
{editItem.ram > 0 && (
|
||||
<>
|
||||
<Grid>
|
||||
<TextField
|
||||
name="value"
|
||||
label={LL.DEFAULT(0) + ' ' + LL.VALUE(0)}
|
||||
type="string"
|
||||
value={editItem.value as string}
|
||||
value={editItem.value}
|
||||
variant="outlined"
|
||||
onChange={updateFormValue}
|
||||
fullWidth
|
||||
@@ -237,7 +228,7 @@ const CustomEntitiesDialog = ({
|
||||
)}
|
||||
{editItem.ram === 0 && (
|
||||
<>
|
||||
<Grid mt={3}>
|
||||
<Grid sx={{ mt: 3 }}>
|
||||
<BlockFormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
@@ -259,7 +250,7 @@ const CustomEntitiesDialog = ({
|
||||
margin="normal"
|
||||
sx={{ width: '11ch' }}
|
||||
type="string"
|
||||
value={editItem.device_id as string}
|
||||
value={editItem.device_id}
|
||||
onChange={updateFormValue}
|
||||
slotProps={{
|
||||
input: {
|
||||
@@ -279,7 +270,7 @@ const CustomEntitiesDialog = ({
|
||||
margin="normal"
|
||||
sx={{ width: '11ch' }}
|
||||
type="string"
|
||||
value={editItem.type_id as string}
|
||||
value={editItem.type_id}
|
||||
onChange={updateFormValue}
|
||||
slotProps={{
|
||||
input: {
|
||||
@@ -380,7 +371,7 @@ const CustomEntitiesDialog = ({
|
||||
fieldErrors={fieldErrors || {}}
|
||||
name="factor"
|
||||
label={LL.BITMASK()}
|
||||
value={editItem.factor as string}
|
||||
value={editItem.factor}
|
||||
sx={{ width: '11ch' }}
|
||||
variant="outlined"
|
||||
onChange={updateFormValue}
|
||||
@@ -403,7 +394,7 @@ const CustomEntitiesDialog = ({
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
{!creating && (
|
||||
<Box flexGrow={1}>
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Button
|
||||
startIcon={<RemoveIcon />}
|
||||
variant="outlined"
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useBlocker, useLocation } from 'react-router';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
@@ -46,6 +45,7 @@ import {
|
||||
SectionContent,
|
||||
useLayoutTitle
|
||||
} from 'components';
|
||||
import { toast } from 'components/toast';
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
|
||||
import {
|
||||
@@ -111,13 +111,14 @@ const Customizations = () => {
|
||||
const [selectedDeviceTypeNameURL, setSelectedDeviceTypeNameURL] =
|
||||
useState<string>(''); // needed for API URL
|
||||
const [selectedDeviceName, setSelectedDeviceName] = useState<string>('');
|
||||
const [selectedDeviceBrand, setSelectedDeviceBrand] = useState<string>('');
|
||||
|
||||
const { send: sendResetCustomizations } = useRequest(resetCustomizations(), {
|
||||
immediate: false
|
||||
});
|
||||
|
||||
const { send: sendDeviceName } = useRequest(
|
||||
(data: { id: number; name: string }) => writeDeviceName(data),
|
||||
(data: { id: number; name: string; brand: string }) => writeDeviceName(data),
|
||||
{
|
||||
immediate: false
|
||||
}
|
||||
@@ -170,19 +171,17 @@ const Customizations = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const entities_theme = useMemo(
|
||||
() =>
|
||||
useTheme({
|
||||
Table: `
|
||||
const entities_theme = useTheme({
|
||||
Table: `
|
||||
--data-table-library_grid-template-columns: 156px repeat(1, minmax(80px, 1fr)) 45px minmax(45px, auto) minmax(120px, auto);
|
||||
`,
|
||||
BaseRow: `
|
||||
BaseRow: `
|
||||
font-size: 14px;
|
||||
.td {
|
||||
height: 32px;
|
||||
}
|
||||
`,
|
||||
BaseCell: `
|
||||
BaseCell: `
|
||||
&:nth-of-type(3) {
|
||||
text-align: right;
|
||||
}
|
||||
@@ -193,7 +192,7 @@ const Customizations = () => {
|
||||
text-align: right;
|
||||
}
|
||||
`,
|
||||
HeaderRow: `
|
||||
HeaderRow: `
|
||||
text-transform: uppercase;
|
||||
background-color: black;
|
||||
color: #90CAF9;
|
||||
@@ -205,7 +204,7 @@ const Customizations = () => {
|
||||
text-align: center;
|
||||
}
|
||||
`,
|
||||
Row: `
|
||||
Row: `
|
||||
background-color: #1e1e1e;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
@@ -221,7 +220,7 @@ const Customizations = () => {
|
||||
background-color: #177ac9;
|
||||
}
|
||||
`,
|
||||
Cell: `
|
||||
Cell: `
|
||||
&:nth-of-type(2) {
|
||||
padding: 8px;
|
||||
}
|
||||
@@ -235,9 +234,7 @@ const Customizations = () => {
|
||||
padding-right: 8px;
|
||||
}
|
||||
`
|
||||
}),
|
||||
[]
|
||||
);
|
||||
});
|
||||
|
||||
function hasEntityChanged(de: DeviceEntity) {
|
||||
return (
|
||||
@@ -267,6 +264,7 @@ const Customizations = () => {
|
||||
if (device) {
|
||||
setSelectedDeviceTypeNameURL(device.url || '');
|
||||
setSelectedDeviceName(device.n);
|
||||
setSelectedDeviceBrand(device.b);
|
||||
}
|
||||
setNumChanges(0);
|
||||
setRestartNeeded(false);
|
||||
@@ -285,26 +283,23 @@ const Customizations = () => {
|
||||
return value as string;
|
||||
}
|
||||
|
||||
const isCommand = useCallback((de: DeviceEntity) => {
|
||||
const isCommand = (de: DeviceEntity) => {
|
||||
return de.n && de.n[0] === '!';
|
||||
}, []);
|
||||
};
|
||||
|
||||
const formatName = useCallback(
|
||||
(de: DeviceEntity, withShortname: boolean) => {
|
||||
let name: string;
|
||||
if (isCommand(de)) {
|
||||
name = de.t
|
||||
? `${LL.COMMAND(1)}: ${de.t} ${de.n?.slice(1)}`
|
||||
: `${LL.COMMAND(1)}: ${de.n?.slice(1)}`;
|
||||
} else if (de.cn && de.cn !== '') {
|
||||
name = de.t ? `${de.t} ${de.cn}` : de.cn;
|
||||
} else {
|
||||
name = de.t ? `${de.t} ${de.n}` : de.n || '';
|
||||
}
|
||||
return withShortname ? `${name} ${de.id}` : name;
|
||||
},
|
||||
[LL]
|
||||
);
|
||||
const formatName = (de: DeviceEntity, withShortname: boolean) => {
|
||||
let name: string;
|
||||
if (isCommand(de)) {
|
||||
name = de.t
|
||||
? `${LL.COMMAND(1)}: ${de.t} ${de.n?.slice(1)}`
|
||||
: `${LL.COMMAND(1)}: ${de.n?.slice(1)}`;
|
||||
} else if (de.cn && de.cn !== '') {
|
||||
name = de.t ? `${de.t} ${de.cn}` : de.cn;
|
||||
} else {
|
||||
name = de.t ? `${de.t} ${de.n}` : de.n || '';
|
||||
}
|
||||
return withShortname ? `${name} ${de.id}` : name;
|
||||
};
|
||||
|
||||
const getMaskNumber = (newMask: string[]) => {
|
||||
let new_mask = 0;
|
||||
@@ -334,33 +329,27 @@ const Customizations = () => {
|
||||
return new_masks;
|
||||
};
|
||||
|
||||
const filter_entity = useCallback(
|
||||
(de: DeviceEntity) =>
|
||||
(de.m & selectedFilters || !selectedFilters) &&
|
||||
formatName(de, true).toLowerCase().includes(search.toLowerCase()),
|
||||
[selectedFilters, search, formatName]
|
||||
);
|
||||
const filter_entity = (de: DeviceEntity) =>
|
||||
(de.m & selectedFilters || !selectedFilters) &&
|
||||
formatName(de, true).toLowerCase().includes(search.toLowerCase());
|
||||
|
||||
const maskDisabled = useCallback(
|
||||
(set: boolean) => {
|
||||
setDeviceEntities((prev) =>
|
||||
prev.map((de) => {
|
||||
if (filter_entity(de)) {
|
||||
const excludeMask =
|
||||
DeviceEntityMask.DV_API_MQTT_EXCLUDE | DeviceEntityMask.DV_WEB_EXCLUDE;
|
||||
return {
|
||||
...de,
|
||||
m: set ? de.m | excludeMask : de.m & ~excludeMask
|
||||
};
|
||||
}
|
||||
return de;
|
||||
})
|
||||
);
|
||||
},
|
||||
[filter_entity]
|
||||
);
|
||||
const maskDisabled = (set: boolean) => {
|
||||
setDeviceEntities((prev) =>
|
||||
prev.map((de) => {
|
||||
if (filter_entity(de)) {
|
||||
const excludeMask =
|
||||
DeviceEntityMask.DV_API_MQTT_EXCLUDE | DeviceEntityMask.DV_WEB_EXCLUDE;
|
||||
return {
|
||||
...de,
|
||||
m: set ? de.m | excludeMask : de.m & ~excludeMask
|
||||
};
|
||||
}
|
||||
return de;
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const resetCustomization = useCallback(async () => {
|
||||
const resetCustomization = async () => {
|
||||
try {
|
||||
await sendResetCustomizations();
|
||||
toast.info(LL.CUSTOMIZATIONS_RESTART());
|
||||
@@ -370,30 +359,27 @@ const Customizations = () => {
|
||||
setConfirmReset(false);
|
||||
setRestarting(true);
|
||||
}
|
||||
}, [sendResetCustomizations, LL]);
|
||||
};
|
||||
|
||||
const onDialogClose = () => {
|
||||
setDialogOpen(false);
|
||||
};
|
||||
|
||||
const updateDeviceEntity = useCallback((updatedItem: DeviceEntity) => {
|
||||
const updateDeviceEntity = (updatedItem: DeviceEntity) => {
|
||||
setDeviceEntities(
|
||||
(prev) =>
|
||||
prev?.map((de) =>
|
||||
de.id === updatedItem.id ? { ...de, ...updatedItem } : de
|
||||
) ?? []
|
||||
);
|
||||
}, []);
|
||||
};
|
||||
|
||||
const onDialogSave = useCallback(
|
||||
(updatedItem: DeviceEntity) => {
|
||||
setDialogOpen(false);
|
||||
updateDeviceEntity(updatedItem);
|
||||
},
|
||||
[updateDeviceEntity]
|
||||
);
|
||||
const onDialogSave = (updatedItem: DeviceEntity) => {
|
||||
setDialogOpen(false);
|
||||
updateDeviceEntity(updatedItem);
|
||||
};
|
||||
|
||||
const editDeviceEntity = useCallback((de: DeviceEntity) => {
|
||||
const editDeviceEntity = (de: DeviceEntity) => {
|
||||
if (de.n === undefined || (de.n && de.n[0] === '!')) {
|
||||
return;
|
||||
}
|
||||
@@ -404,9 +390,9 @@ const Customizations = () => {
|
||||
|
||||
setSelectedDeviceEntity(de);
|
||||
setDialogOpen(true);
|
||||
}, []);
|
||||
};
|
||||
|
||||
const saveCustomization = useCallback(async () => {
|
||||
const saveCustomization = async () => {
|
||||
if (!devices || !deviceEntities || selectedDevice === -1) {
|
||||
return;
|
||||
}
|
||||
@@ -439,10 +425,14 @@ const Customizations = () => {
|
||||
.finally(() => {
|
||||
setOriginalSettings(deviceEntities);
|
||||
});
|
||||
}, [devices, deviceEntities, selectedDevice, sendCustomizationEntities, LL]);
|
||||
};
|
||||
|
||||
const renameDevice = useCallback(async () => {
|
||||
await sendDeviceName({ id: selectedDevice, name: selectedDeviceName })
|
||||
const renameDevice = async () => {
|
||||
await sendDeviceName({
|
||||
id: selectedDevice,
|
||||
name: selectedDeviceName,
|
||||
brand: selectedDeviceBrand
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(LL.UPDATED_OF(LL.NAME(1)));
|
||||
})
|
||||
@@ -453,24 +443,35 @@ const Customizations = () => {
|
||||
setRename(false);
|
||||
await fetchCoreData();
|
||||
});
|
||||
}, [selectedDevice, selectedDeviceName, sendDeviceName, LL, fetchCoreData]);
|
||||
};
|
||||
|
||||
const renderDeviceList = () => (
|
||||
<>
|
||||
<Box mb={1} color="warning.main">
|
||||
<Typography variant="body1">{LL.CUSTOMIZATIONS_HELP_1()}.</Typography>
|
||||
</Box>
|
||||
<Box display="flex" flexWrap="wrap" alignItems="center" gap={2}>
|
||||
<Typography sx={{ mb: 1 }} color="warning" variant="body1">
|
||||
{LL.CUSTOMIZATIONS_HELP_1()}.
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 2 }}>
|
||||
{rename ? (
|
||||
<TextField
|
||||
name="device"
|
||||
label={LL.EMS_DEVICE()}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={selectedDeviceName}
|
||||
onChange={(e) => setSelectedDeviceName(e.target.value)}
|
||||
margin="normal"
|
||||
/>
|
||||
<>
|
||||
<TextField
|
||||
name="device"
|
||||
label={LL.EMS_DEVICE()}
|
||||
style={{ minWidth: '48%' }}
|
||||
variant="outlined"
|
||||
value={selectedDeviceName}
|
||||
onChange={(e) => setSelectedDeviceName(e.target.value)}
|
||||
margin="normal"
|
||||
/>
|
||||
<TextField
|
||||
name="brand"
|
||||
label={LL.BRAND()}
|
||||
style={{ minWidth: '48%' }}
|
||||
variant="outlined"
|
||||
value={selectedDeviceBrand}
|
||||
onChange={(e) => setSelectedDeviceBrand(e.target.value)}
|
||||
margin="normal"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<TextField
|
||||
name="device"
|
||||
@@ -488,7 +489,8 @@ const Customizations = () => {
|
||||
</MenuItem>
|
||||
{devices.devices.map(
|
||||
(device: Device) =>
|
||||
device.id < 90 && (
|
||||
device.id < 90 &&
|
||||
device.e !== 0 && (
|
||||
<MenuItem key={device.id} value={device.id}>
|
||||
{device.n} ({device.tn})
|
||||
</MenuItem>
|
||||
@@ -538,35 +540,27 @@ const Customizations = () => {
|
||||
</>
|
||||
);
|
||||
|
||||
const filteredEntities = useMemo(
|
||||
() => deviceEntities.filter((de) => filter_entity(de)),
|
||||
[deviceEntities, filter_entity]
|
||||
);
|
||||
const filteredEntities = deviceEntities.filter((de) => filter_entity(de));
|
||||
|
||||
const renderDeviceData = () => {
|
||||
return (
|
||||
<>
|
||||
<Box color="warning.main">
|
||||
<Typography variant="body2" mt={1} mb={1}>
|
||||
<OptionIcon type="favorite" isSet={true} />={LL.CUSTOMIZATIONS_HELP_2()}
|
||||
|
||||
<OptionIcon type="readonly" isSet={true} />={LL.CUSTOMIZATIONS_HELP_3()}
|
||||
|
||||
<OptionIcon type="api_mqtt_exclude" isSet={true} />=
|
||||
{LL.CUSTOMIZATIONS_HELP_4()}
|
||||
<OptionIcon type="web_exclude" isSet={true} />=
|
||||
{LL.CUSTOMIZATIONS_HELP_5()}
|
||||
<OptionIcon type="deleted" isSet={true} />={LL.CUSTOMIZATIONS_HELP_6()}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography sx={{ mt: 1, mb: 1 }} color="warning" variant="body2">
|
||||
<OptionIcon type="favorite" isSet={true} />={LL.CUSTOMIZATIONS_HELP_2()}
|
||||
|
||||
<OptionIcon type="readonly" isSet={true} />={LL.CUSTOMIZATIONS_HELP_3()}
|
||||
|
||||
<OptionIcon type="api_mqtt_exclude" isSet={true} />=
|
||||
{LL.CUSTOMIZATIONS_HELP_4()}
|
||||
<OptionIcon type="web_exclude" isSet={true} />={LL.CUSTOMIZATIONS_HELP_5()}
|
||||
|
||||
<OptionIcon type="deleted" isSet={true} />={LL.CUSTOMIZATIONS_HELP_6()}
|
||||
</Typography>
|
||||
<Grid
|
||||
container
|
||||
mb={1}
|
||||
mt={0}
|
||||
spacing={2}
|
||||
direction="row"
|
||||
justifyContent="flex-start"
|
||||
alignItems="center"
|
||||
sx={{ mb: 1, mt: 0, justifyContent: 'flex-start', alignItems: 'center' }}
|
||||
>
|
||||
<Grid>
|
||||
<TextField
|
||||
@@ -755,8 +749,8 @@ const Customizations = () => {
|
||||
</Button>
|
||||
</MessageBox>
|
||||
) : (
|
||||
<Box display="flex" flexWrap="wrap">
|
||||
<Box flexGrow={1}>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap' }}>
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
{numChanges !== 0 && (
|
||||
<ButtonRow>
|
||||
<Button
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { memo, useEffect, useState } from 'react';
|
||||
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
@@ -37,7 +37,7 @@ interface LabelValueProps {
|
||||
|
||||
const LabelValue = memo(({ label, value }: LabelValueProps) => (
|
||||
<Grid container direction="row">
|
||||
<Typography variant="body2" color="warning.main">
|
||||
<Typography variant="body2" color="warning">
|
||||
{label}:
|
||||
</Typography>
|
||||
<Typography variant="body2">{value}</Typography>
|
||||
@@ -57,23 +57,16 @@ const CustomizationsDialog = ({
|
||||
const [editItem, setEditItem] = useState<DeviceEntity>(selectedItem);
|
||||
const [error, setError] = useState<boolean>(false);
|
||||
|
||||
const updateFormValue = useMemo(
|
||||
() =>
|
||||
updateValue(
|
||||
setEditItem as unknown as React.Dispatch<
|
||||
React.SetStateAction<Record<string, unknown>>
|
||||
>
|
||||
),
|
||||
[]
|
||||
const updateFormValue = updateValue(
|
||||
setEditItem as unknown as React.Dispatch<
|
||||
React.SetStateAction<Record<string, unknown>>
|
||||
>
|
||||
);
|
||||
|
||||
const isWriteableNumber = useMemo(
|
||||
() =>
|
||||
typeof editItem.v === 'number' &&
|
||||
editItem.w &&
|
||||
!(editItem.m & DeviceEntityMask.DV_READONLY),
|
||||
[editItem.v, editItem.w, editItem.m]
|
||||
);
|
||||
const isWriteableNumber =
|
||||
typeof editItem.v === 'number' &&
|
||||
editItem.w &&
|
||||
!(editItem.m & DeviceEntityMask.DV_READONLY);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
@@ -82,16 +75,16 @@ const CustomizationsDialog = ({
|
||||
}
|
||||
}, [open, selectedItem]);
|
||||
|
||||
const handleClose = useCallback(
|
||||
(_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => {
|
||||
if (reason !== 'backdropClick') {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[onClose]
|
||||
);
|
||||
const handleClose = (
|
||||
_event: React.SyntheticEvent,
|
||||
reason: 'backdropClick' | 'escapeKeyDown'
|
||||
) => {
|
||||
if (reason !== 'backdropClick') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const save = useCallback(() => {
|
||||
const save = () => {
|
||||
if (
|
||||
isWriteableNumber &&
|
||||
editItem.mi &&
|
||||
@@ -102,36 +95,33 @@ const CustomizationsDialog = ({
|
||||
} else {
|
||||
onSave(editItem);
|
||||
}
|
||||
}, [isWriteableNumber, editItem, onSave]);
|
||||
};
|
||||
|
||||
const updateDeviceEntity = useCallback((updatedItem: DeviceEntity) => {
|
||||
const updateDeviceEntity = (updatedItem: DeviceEntity) => {
|
||||
setEditItem((prev) => ({ ...prev, m: updatedItem.m }));
|
||||
}, []);
|
||||
|
||||
const dialogTitle = useMemo(() => `${LL.EDIT()} ${LL.ENTITY()}`, [LL]);
|
||||
|
||||
const writeableIcon = useMemo(
|
||||
() =>
|
||||
editItem.w ? (
|
||||
<DoneIcon color="success" sx={{ fontSize: ICON_SIZE }} />
|
||||
) : (
|
||||
<CloseIcon color="error" sx={{ fontSize: ICON_SIZE }} />
|
||||
),
|
||||
[editItem.w]
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
|
||||
<DialogTitle>{dialogTitle}</DialogTitle>
|
||||
<DialogTitle>{`${LL.EDIT()} ${LL.ENTITY()}`}</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<LabelValue label={LL.ID_OF(LL.ENTITY())} value={editItem.id} />
|
||||
<LabelValue
|
||||
label={`${LL.DEFAULT(1)} ${LL.ENTITY_NAME(1)}`}
|
||||
value={editItem.n}
|
||||
/>
|
||||
<LabelValue label={LL.WRITEABLE()} value={writeableIcon} />
|
||||
<LabelValue
|
||||
label={LL.WRITEABLE()}
|
||||
value={
|
||||
editItem.w ? (
|
||||
<DoneIcon color="success" sx={{ fontSize: ICON_SIZE }} />
|
||||
) : (
|
||||
<CloseIcon color="error" sx={{ fontSize: ICON_SIZE }} />
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<Box mt={1} mb={2}>
|
||||
<Box sx={{ mt: 1, mb: 2 }}>
|
||||
<EntityMaskToggle onUpdate={updateDeviceEntity} de={editItem} />
|
||||
</Box>
|
||||
|
||||
@@ -172,7 +162,7 @@ const CustomizationsDialog = ({
|
||||
</Grid>
|
||||
|
||||
{error && (
|
||||
<Typography variant="body2" color="error" mt={2}>
|
||||
<Typography sx={{ mt: 2 }} variant="body2" color="error">
|
||||
Error: Check min and max values
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { memo, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import { memo, useContext, useEffect, useState } from 'react';
|
||||
import { IconContext } from 'react-icons/lib';
|
||||
import { Link } from 'react-router';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import HelpOutlineIcon from '@mui/icons-material/HelpOutline';
|
||||
import HelpOutlineIcon from '@mui/icons-material/HelpOutlined';
|
||||
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
||||
import UnfoldLessIcon from '@mui/icons-material/UnfoldLess';
|
||||
import UnfoldMoreIcon from '@mui/icons-material/UnfoldMore';
|
||||
import {
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
SectionContent,
|
||||
useLayoutTitle
|
||||
} from 'components';
|
||||
import { toast } from 'components/toast';
|
||||
import { AuthenticatedContext } from 'contexts/authentication';
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
import { useInterval, usePersistState } from 'utils';
|
||||
@@ -75,42 +76,38 @@ const Dashboard = memo(() => {
|
||||
{
|
||||
immediate: false
|
||||
}
|
||||
);
|
||||
)
|
||||
.onSuccess(() => {
|
||||
toast.success(LL.WRITE_CMD_SENT());
|
||||
})
|
||||
.onError((error) => {
|
||||
toast.error(String(error.error?.message || 'An error occurred'));
|
||||
});
|
||||
|
||||
const deviceValueDialogSave = useCallback(
|
||||
async (devicevalue: DeviceValue) => {
|
||||
if (!selectedDashboardItem) {
|
||||
return;
|
||||
}
|
||||
const deviceValueDialogSave = async (devicevalue: DeviceValue) => {
|
||||
if (!selectedDashboardItem) {
|
||||
return;
|
||||
}
|
||||
// skip if we're executing an immediate schedule as this is handled in DevicesDialog::doAction()
|
||||
if (devicevalue.v !== undefined) {
|
||||
const id = selectedDashboardItem.parentNode.id; // this is the parent ID
|
||||
await sendDeviceValue({ id, c: devicevalue.c ?? '', v: devicevalue.v })
|
||||
.then(() => {
|
||||
toast.success(LL.WRITE_CMD_SENT());
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
toast.error(error.message);
|
||||
})
|
||||
.finally(() => {
|
||||
setDeviceValueDialogOpen(false);
|
||||
setSelectedDashboardItem(undefined);
|
||||
});
|
||||
},
|
||||
[selectedDashboardItem, sendDeviceValue, LL]
|
||||
);
|
||||
await sendDeviceValue({ id, c: devicevalue.c ?? '', v: devicevalue.v });
|
||||
}
|
||||
setDeviceValueDialogOpen(false);
|
||||
setSelectedDashboardItem(undefined);
|
||||
};
|
||||
|
||||
const dashboard_theme = useMemo(
|
||||
() =>
|
||||
useTheme({
|
||||
Table: `
|
||||
const dashboard_theme = useTheme({
|
||||
Table: `
|
||||
--data-table-library_grid-template-columns: minmax(80px, auto) 120px 32px;
|
||||
`,
|
||||
BaseRow: `
|
||||
BaseRow: `
|
||||
font-size: 14px;
|
||||
.td {
|
||||
height: 28px;
|
||||
}
|
||||
`,
|
||||
Row: `
|
||||
Row: `
|
||||
cursor: pointer;
|
||||
background-color: #1e1e1e;
|
||||
&:nth-of-type(odd) .td {
|
||||
@@ -120,7 +117,7 @@ const Dashboard = memo(() => {
|
||||
background-color: #177ac9;
|
||||
},
|
||||
`,
|
||||
BaseCell: `
|
||||
BaseCell: `
|
||||
&:nth-of-type(2) {
|
||||
text-align: right;
|
||||
}
|
||||
@@ -128,9 +125,7 @@ const Dashboard = memo(() => {
|
||||
text-align: right;
|
||||
}
|
||||
`
|
||||
}),
|
||||
[]
|
||||
);
|
||||
});
|
||||
|
||||
const tree = useTree(
|
||||
{ nodes: [...data.nodes] },
|
||||
@@ -164,79 +159,71 @@ const Dashboard = memo(() => {
|
||||
}
|
||||
});
|
||||
|
||||
const nodeIds = useMemo(
|
||||
() => data.nodes.map((item: DashboardItem) => item.id),
|
||||
[data.nodes]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const nodeIds = data.nodes.map((item: DashboardItem) => item.id);
|
||||
showAll
|
||||
? tree.fns.onAddAll(nodeIds) // expand tree
|
||||
: tree.fns.onRemoveAll(); // collapse tree
|
||||
}, [parentNodes]);
|
||||
|
||||
const showType = useCallback(
|
||||
(n?: string, t?: number) => {
|
||||
// if we have a name show it
|
||||
if (n) {
|
||||
return n;
|
||||
const showType = (n?: string, t?: number) => {
|
||||
// if we have a name show it
|
||||
if (n) {
|
||||
return n;
|
||||
}
|
||||
if (t) {
|
||||
// otherwise pick translation based on type
|
||||
switch (t) {
|
||||
case DeviceType.CUSTOM:
|
||||
return LL.CUSTOM_ENTITIES(0);
|
||||
case DeviceType.ANALOGSENSOR:
|
||||
return LL.ANALOG_SENSORS();
|
||||
case DeviceType.TEMPERATURESENSOR:
|
||||
return LL.TEMP_SENSORS();
|
||||
case DeviceType.COMMAND:
|
||||
return LL.COMMANDS();
|
||||
case DeviceType.SCHEDULER:
|
||||
return LL.SCHEDULER();
|
||||
default:
|
||||
break;
|
||||
}
|
||||
if (t) {
|
||||
// otherwise pick translation based on type
|
||||
switch (t) {
|
||||
case DeviceType.CUSTOM:
|
||||
return LL.CUSTOM_ENTITIES(0);
|
||||
case DeviceType.ANALOGSENSOR:
|
||||
return LL.ANALOG_SENSORS();
|
||||
case DeviceType.TEMPERATURESENSOR:
|
||||
return LL.TEMP_SENSORS();
|
||||
case DeviceType.SCHEDULER:
|
||||
return LL.SCHEDULER();
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
},
|
||||
[LL]
|
||||
);
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const showName = useCallback(
|
||||
(di: DashboardItem) => {
|
||||
if (di.id < 100) {
|
||||
// if its a device (parent node) and has entities
|
||||
if (di.nodes?.length) {
|
||||
return (
|
||||
<span style={{ fontSize: '15px' }}>
|
||||
<DeviceIcon type_id={di.t ?? 0} />
|
||||
{showType(di.n, di.t)}
|
||||
<span style={{ color: 'lightblue' }}> ({di.nodes?.length})</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const showName = (di: DashboardItem) => {
|
||||
if (di.id < 100) {
|
||||
// if its a device (parent node) and has entities
|
||||
if (di.nodes?.length) {
|
||||
return (
|
||||
<span style={{ fontSize: '15px' }}>
|
||||
<DeviceIcon type_id={di.t ?? 0} />
|
||||
{showType(di.n, di.t)}
|
||||
<span style={{ color: 'lightblue' }}> ({di.nodes?.length})</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (di.dv) {
|
||||
return <span>{di.dv.id.slice(2)}</span>;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
[showType]
|
||||
);
|
||||
}
|
||||
if (di.dv) {
|
||||
return <span>{di.dv.id.slice(2)}</span>;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const hasMask = useCallback(
|
||||
(id: string, mask: number) => (parseInt(id.slice(0, 2), 16) & mask) === mask,
|
||||
[]
|
||||
);
|
||||
const hasMask = (id: string, mask: number) =>
|
||||
(parseInt(id.slice(0, 2), 16) & mask) === mask;
|
||||
|
||||
const editDashboardValue = useCallback(
|
||||
(di: DashboardItem) => {
|
||||
if (me.admin && di.dv?.c) {
|
||||
setSelectedDashboardItem(di);
|
||||
setDeviceValueDialogOpen(true);
|
||||
}
|
||||
},
|
||||
[me.admin]
|
||||
);
|
||||
const editDashboardValue = (di: DashboardItem) => {
|
||||
// don't execute on parent nodes
|
||||
if (!me.admin || di.id <= 99) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (di.dv?.c) {
|
||||
setSelectedDashboardItem(di);
|
||||
setDeviceValueDialogOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleShowAll = (
|
||||
_event: React.MouseEvent<HTMLElement>,
|
||||
@@ -248,10 +235,9 @@ const Dashboard = memo(() => {
|
||||
}
|
||||
};
|
||||
|
||||
const hasFavEntities = useMemo(
|
||||
() => data.nodes.filter((item: DashboardItem) => item.id <= 90).length,
|
||||
[data.nodes]
|
||||
);
|
||||
const hasFavEntities = data.nodes.filter(
|
||||
(item: DashboardItem) => item.id <= 90
|
||||
).length;
|
||||
|
||||
const renderContent = () => {
|
||||
if (!data) {
|
||||
@@ -262,12 +248,8 @@ const Dashboard = memo(() => {
|
||||
|
||||
return (
|
||||
<>
|
||||
{!data.connected && (
|
||||
<MessageBox level="error" message={LL.EMS_BUS_WARNING()} />
|
||||
)}
|
||||
|
||||
{data.connected && data.nodes.length > 0 && !hasFavEntities && (
|
||||
<MessageBox mb={2} level="warning">
|
||||
<MessageBox sx={{ mb: 2 }} level="warning">
|
||||
<Typography>
|
||||
{LL.NO_DATA_1()}
|
||||
<Link to="/customizations" style={{ color: 'white' }}>
|
||||
@@ -284,10 +266,12 @@ const Dashboard = memo(() => {
|
||||
)}
|
||||
|
||||
<Box
|
||||
display="flex"
|
||||
justifyContent="flex-end"
|
||||
flexWrap="nowrap"
|
||||
whiteSpace="nowrap"
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
flexWrap: 'nowrap',
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
>
|
||||
<ToggleButtonGroup
|
||||
size="small"
|
||||
@@ -310,7 +294,7 @@ const Dashboard = memo(() => {
|
||||
</Box>
|
||||
|
||||
{data.nodes.length > 0 ? (
|
||||
<Box mt={1} justifyContent="center" flexDirection="column">
|
||||
<Box sx={{ mt: 1, justifyContent: 'center', flexDirection: 'column' }}>
|
||||
<IconContext.Provider
|
||||
value={{
|
||||
color: 'lightblue',
|
||||
@@ -346,20 +330,34 @@ const Dashboard = memo(() => {
|
||||
<Cell>
|
||||
{me.admin &&
|
||||
di.dv?.c &&
|
||||
!hasMask(di.dv.id, DeviceEntityMask.DV_READONLY) && (
|
||||
!hasMask(di.dv.id, DeviceEntityMask.DV_READONLY) &&
|
||||
(di.dv.v === '' || di.dv.v === undefined ? (
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label={LL.RUN()}
|
||||
onClick={() => editDashboardValue(di)}
|
||||
style={{ backgroundColor: 'transparent' }}
|
||||
>
|
||||
<PlayArrowIcon
|
||||
color="primary"
|
||||
sx={{ fontSize: 16 }}
|
||||
/>
|
||||
</IconButton>
|
||||
) : (
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label={
|
||||
LL.CHANGE_VALUE() + ' ' + LL.VALUE(0)
|
||||
}
|
||||
onClick={() => editDashboardValue(di)}
|
||||
style={{ backgroundColor: 'transparent' }}
|
||||
>
|
||||
<EditIcon
|
||||
color="primary"
|
||||
sx={{ fontSize: 16 }}
|
||||
/>
|
||||
</IconButton>
|
||||
)}
|
||||
))}
|
||||
</Cell>
|
||||
</>
|
||||
) : (
|
||||
@@ -377,13 +375,8 @@ const Dashboard = memo(() => {
|
||||
</IconContext.Provider>
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
display="flex"
|
||||
// justifyContent="flex-end"
|
||||
// flexWrap="nowrap"
|
||||
// whiteSpace="nowrap"
|
||||
>
|
||||
<Typography mt={1} color="warning.main" variant="body1">
|
||||
<Box sx={{ display: 'flex' }}>
|
||||
<Typography sx={{ mt: 1 }} color="warning" variant="body1">
|
||||
no data
|
||||
</Typography>
|
||||
<Tooltip title={LL.DASHBOARD_1()}>
|
||||
@@ -409,7 +402,10 @@ const Dashboard = memo(() => {
|
||||
{selectedDashboardItem && selectedDashboardItem.dv && (
|
||||
<DevicesDialog
|
||||
open={deviceValueDialogOpen}
|
||||
onClose={() => setDeviceValueDialogOpen(false)}
|
||||
onClose={() => {
|
||||
setDeviceValueDialogOpen(false);
|
||||
setSelectedDashboardItem(undefined);
|
||||
}}
|
||||
onSave={deviceValueDialogSave}
|
||||
selectedItem={selectedDashboardItem.dv}
|
||||
writeable={true}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { memo } from 'react';
|
||||
import type { IconType } from 'react-icons';
|
||||
import { AiOutlineAlert, AiOutlineControl, AiOutlineGateway } from 'react-icons/ai';
|
||||
import { CgSmartHomeBoiler } from 'react-icons/cg';
|
||||
import { FaSolarPanel } from 'react-icons/fa';
|
||||
@@ -15,14 +16,9 @@ import { PiFan, PiGauge } from 'react-icons/pi';
|
||||
import { TiFlowSwitch, TiThermometer } from 'react-icons/ti';
|
||||
import { VscVmConnect } from 'react-icons/vsc';
|
||||
|
||||
import type { SvgIconProps } from '@mui/material';
|
||||
|
||||
import { DeviceType } from './types';
|
||||
|
||||
const deviceIconLookup: Record<
|
||||
DeviceType,
|
||||
React.ComponentType<SvgIconProps> | null
|
||||
> = {
|
||||
const deviceIconLookup: Record<DeviceType, IconType | null> = {
|
||||
[DeviceType.TEMPERATURESENSOR]: TiThermometer,
|
||||
[DeviceType.ANALOGSENSOR]: PiGauge,
|
||||
[DeviceType.BOILER]: CgSmartHomeBoiler,
|
||||
@@ -42,6 +38,7 @@ const deviceIconLookup: Record<
|
||||
[DeviceType.CUSTOM]: MdPlaylistAdd,
|
||||
[DeviceType.UNKNOWN]: MdOutlineSensors,
|
||||
[DeviceType.SYSTEM]: null,
|
||||
[DeviceType.COMMAND]: MdPlaylistAdd,
|
||||
[DeviceType.SCHEDULER]: MdMoreTime,
|
||||
[DeviceType.GENERIC]: MdOutlineSensors,
|
||||
[DeviceType.VENTILATION]: PiFan
|
||||
|
||||
+129
-128
@@ -4,12 +4,10 @@ import {
|
||||
useContext,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useState
|
||||
} from 'react';
|
||||
import { IconContext } from 'react-icons';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Link, useNavigate } from 'react-router';
|
||||
|
||||
import CommentsDisabledOutlinedIcon from '@mui/icons-material/CommentsDisabledOutlined';
|
||||
import ConstructionIcon from '@mui/icons-material/Construction';
|
||||
@@ -65,6 +63,7 @@ import {
|
||||
SectionContent,
|
||||
useLayoutTitle
|
||||
} from 'components';
|
||||
import { toast } from 'components/toast';
|
||||
import { AuthenticatedContext } from 'contexts/authentication';
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
import { useInterval } from 'utils';
|
||||
@@ -133,21 +132,19 @@ const Devices = memo(() => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const leftOffset = useCallback(() => {
|
||||
const leftOffset = () => {
|
||||
const devicesWindow = document.getElementById('devices-window');
|
||||
if (!devicesWindow) return 0;
|
||||
const { left, right } = devicesWindow.getBoundingClientRect();
|
||||
if (!left || !right) return 0;
|
||||
return left + (right - left < 400 ? 0 : 200);
|
||||
}, []);
|
||||
};
|
||||
|
||||
const common_theme = useMemo(
|
||||
() =>
|
||||
useTheme({
|
||||
BaseRow: `
|
||||
const common_theme = useTheme({
|
||||
BaseRow: `
|
||||
font-size: 14px;
|
||||
`,
|
||||
HeaderRow: `
|
||||
HeaderRow: `
|
||||
text-transform: uppercase;
|
||||
background-color: black;
|
||||
color: #90CAF9;
|
||||
@@ -155,7 +152,7 @@ const Devices = memo(() => {
|
||||
border-bottom: 1px solid #565656;
|
||||
}
|
||||
`,
|
||||
Row: `
|
||||
Row: `
|
||||
cursor: pointer;
|
||||
background-color: #1E1E1E;
|
||||
.td {
|
||||
@@ -165,88 +162,78 @@ const Devices = memo(() => {
|
||||
background-color: #177ac9;
|
||||
}
|
||||
`
|
||||
}),
|
||||
[]
|
||||
);
|
||||
});
|
||||
|
||||
const device_theme = useMemo(
|
||||
() =>
|
||||
useTheme([
|
||||
common_theme,
|
||||
{
|
||||
BaseRow: `
|
||||
font-size: 15px;
|
||||
.td {
|
||||
height: 28px;
|
||||
}
|
||||
`,
|
||||
Table: `
|
||||
--data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) 130px;
|
||||
`,
|
||||
HeaderRow: `
|
||||
.th {
|
||||
padding: 8px;
|
||||
`,
|
||||
Row: `
|
||||
&:nth-of-type(odd) .td {
|
||||
background-color: #303030;
|
||||
},
|
||||
&:hover .td {
|
||||
background-color: #177ac9;
|
||||
},
|
||||
`
|
||||
}
|
||||
]),
|
||||
[common_theme]
|
||||
);
|
||||
|
||||
const data_theme = useMemo(
|
||||
() =>
|
||||
useTheme([
|
||||
common_theme,
|
||||
{
|
||||
Table: `
|
||||
--data-table-library_grid-template-columns: minmax(200px, auto) minmax(150px, auto) 40px;
|
||||
height: auto;
|
||||
max-height: 100%;
|
||||
overflow-y: scroll;
|
||||
::-webkit-scrollbar {
|
||||
display:none;
|
||||
const device_theme = useTheme([
|
||||
common_theme,
|
||||
{
|
||||
BaseRow: `
|
||||
font-size: 15px;
|
||||
.td {
|
||||
height: 28px;
|
||||
}
|
||||
`,
|
||||
BaseRow: `
|
||||
.td {
|
||||
height: 32px;
|
||||
}
|
||||
`,
|
||||
BaseCell: `
|
||||
&:nth-of-type(1) {
|
||||
border-left: 1px solid #177ac9;
|
||||
},
|
||||
&:nth-of-type(2) {
|
||||
text-align: right;
|
||||
},
|
||||
&:nth-of-type(3) {
|
||||
border-right: 1px solid #177ac9;
|
||||
}
|
||||
`,
|
||||
HeaderRow: `
|
||||
.th {
|
||||
border-top: 1px solid #565656;
|
||||
}
|
||||
`,
|
||||
Row: `
|
||||
&:nth-of-type(odd) .td {
|
||||
Table: `
|
||||
--data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) 130px;
|
||||
`,
|
||||
HeaderRow: `
|
||||
.th {
|
||||
padding: 8px;
|
||||
`,
|
||||
Row: `
|
||||
&:nth-of-type(odd) .td {
|
||||
background-color: #303030;
|
||||
},
|
||||
&:hover .td {
|
||||
background-color: #177ac9;
|
||||
},
|
||||
&:hover .td {
|
||||
background-color: #177ac9;
|
||||
},
|
||||
`
|
||||
}
|
||||
]);
|
||||
|
||||
const data_theme = useTheme([
|
||||
common_theme,
|
||||
{
|
||||
Table: `
|
||||
--data-table-library_grid-template-columns: minmax(200px, auto) minmax(150px, auto) 40px;
|
||||
height: auto;
|
||||
max-height: 100%;
|
||||
overflow-y: scroll;
|
||||
::-webkit-scrollbar {
|
||||
display:none;
|
||||
}
|
||||
`
|
||||
}
|
||||
]),
|
||||
[common_theme]
|
||||
);
|
||||
`,
|
||||
BaseRow: `
|
||||
.td {
|
||||
height: 32px;
|
||||
}
|
||||
`,
|
||||
BaseCell: `
|
||||
&:nth-of-type(1) {
|
||||
border-left: 1px solid #177ac9;
|
||||
},
|
||||
&:nth-of-type(2) {
|
||||
text-align: right;
|
||||
},
|
||||
&:nth-of-type(3) {
|
||||
border-right: 1px solid #177ac9;
|
||||
}
|
||||
`,
|
||||
HeaderRow: `
|
||||
.th {
|
||||
border-top: 1px solid #565656;
|
||||
}
|
||||
`,
|
||||
Row: `
|
||||
&:nth-of-type(odd) .td {
|
||||
background-color: #303030;
|
||||
},
|
||||
&:hover .td {
|
||||
background-color: #177ac9;
|
||||
}
|
||||
`
|
||||
}
|
||||
]);
|
||||
|
||||
const getSortIcon = (state: State, sortKey: unknown) => {
|
||||
if (state.sortKey === sortKey && state.reverse) {
|
||||
@@ -345,10 +332,8 @@ const Devices = memo(() => {
|
||||
return sc;
|
||||
};
|
||||
|
||||
const hasMask = useCallback(
|
||||
(id: string, mask: number) => (parseInt(id.slice(0, 2), 16) & mask) === mask,
|
||||
[]
|
||||
);
|
||||
const hasMask = (id: string, mask: number) =>
|
||||
(parseInt(id.slice(0, 2), 16) & mask) === mask;
|
||||
|
||||
const handleDownloadCsv = () => {
|
||||
const deviceIndex = coreData.devices.findIndex(
|
||||
@@ -534,9 +519,19 @@ const Devices = memo(() => {
|
||||
const renderCoreData = () => (
|
||||
<>
|
||||
{!coreData.connected ? (
|
||||
<MessageBox level="error" message={LL.EMS_BUS_WARNING()} />
|
||||
<MessageBox level="error" message={LL.EMS_BUS_WARNING() + '.'}>
|
||||
(
|
||||
<Link
|
||||
target="_blank"
|
||||
to="https://docs.emsesp.org/Troubleshooting#ems-bus-is-not-connecting"
|
||||
style={{ color: 'white' }}
|
||||
>
|
||||
{LL.ONLINE_HELP()}
|
||||
</Link>
|
||||
)
|
||||
</MessageBox>
|
||||
) : (
|
||||
<Box justifyContent="center" flexDirection="column">
|
||||
<Box sx={{ justifyContent: 'center', flexDirection: 'column' }}>
|
||||
<IconContext.Provider
|
||||
value={{
|
||||
color: 'lightblue',
|
||||
@@ -563,16 +558,26 @@ const Devices = memo(() => {
|
||||
<CircularProgress sx={{ margin: 1 }} size={18} />
|
||||
)}
|
||||
{tableList.map((device: Device) => (
|
||||
<Row key={device.id} item={device}>
|
||||
<Row key={device.id} item={device} disabled={device.e === 0}>
|
||||
<Cell>
|
||||
<DeviceIcon type_id={device.t} />
|
||||
|
||||
{device.n}
|
||||
<span style={{ color: 'lightblue' }}>
|
||||
({device.e})
|
||||
<span style={{ color: device.e === 0 ? 'grey' : 'white' }}>
|
||||
{device.n}
|
||||
</span>
|
||||
{device.e !== 0 && (
|
||||
<span style={{ color: 'lightblue' }}>
|
||||
({device.e})
|
||||
</span>
|
||||
)}
|
||||
</Cell>
|
||||
<Cell stiff>
|
||||
<ButtonTooltip
|
||||
title={`DeviceID: 0x${('00' + device.d.toString(16).toUpperCase()).slice(-2)}, ProductID: ${device.p}`}
|
||||
>
|
||||
<span>{device.tn}</span>
|
||||
</ButtonTooltip>
|
||||
</Cell>
|
||||
<Cell stiff>{device.tn}</Cell>
|
||||
</Row>
|
||||
))}
|
||||
</Body>
|
||||
@@ -587,6 +592,7 @@ const Devices = memo(() => {
|
||||
|
||||
const deviceValueDialogClose = () => {
|
||||
setDeviceValueDialogOpen(false);
|
||||
setSelectedDeviceValue(undefined);
|
||||
if (selectedDevice !== undefined) {
|
||||
void sendDeviceData(selectedDevice);
|
||||
}
|
||||
@@ -597,41 +603,35 @@ const Devices = memo(() => {
|
||||
return;
|
||||
}
|
||||
|
||||
const showDeviceValue = useCallback((dv: DeviceValue) => {
|
||||
const showDeviceValue = (dv: DeviceValue) => {
|
||||
setSelectedDeviceValue(dv);
|
||||
setDeviceValueDialogOpen(true);
|
||||
}, []);
|
||||
};
|
||||
|
||||
const renderNameCell = useCallback(
|
||||
(dv: DeviceValue) => (
|
||||
<>
|
||||
{dv.id.slice(2)}
|
||||
{hasMask(dv.id, DeviceEntityMask.DV_FAVORITE) && (
|
||||
<StarIcon color="primary" sx={{ fontSize: 12 }} />
|
||||
)}
|
||||
{hasMask(dv.id, DeviceEntityMask.DV_READONLY) && (
|
||||
<EditOffOutlinedIcon color="primary" sx={{ fontSize: 12 }} />
|
||||
)}
|
||||
{hasMask(dv.id, DeviceEntityMask.DV_API_MQTT_EXCLUDE) && (
|
||||
<CommentsDisabledOutlinedIcon color="primary" sx={{ fontSize: 12 }} />
|
||||
)}
|
||||
</>
|
||||
),
|
||||
[hasMask]
|
||||
const renderNameCell = (dv: DeviceValue) => (
|
||||
<>
|
||||
{dv.id.slice(2)}
|
||||
{hasMask(dv.id, DeviceEntityMask.DV_FAVORITE) && (
|
||||
<StarIcon color="primary" sx={{ fontSize: 12 }} />
|
||||
)}
|
||||
{hasMask(dv.id, DeviceEntityMask.DV_READONLY) && (
|
||||
<EditOffOutlinedIcon color="primary" sx={{ fontSize: 12 }} />
|
||||
)}
|
||||
{hasMask(dv.id, DeviceEntityMask.DV_API_MQTT_EXCLUDE) && (
|
||||
<CommentsDisabledOutlinedIcon color="primary" sx={{ fontSize: 12 }} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const shown_data = useMemo(() => {
|
||||
if (onlyFav) {
|
||||
return deviceData.nodes.filter(
|
||||
const shown_data = onlyFav
|
||||
? deviceData.nodes.filter(
|
||||
(dv: DeviceValue) =>
|
||||
hasMask(dv.id, DeviceEntityMask.DV_FAVORITE) &&
|
||||
dv.id.slice(2).toLowerCase().includes(search.toLowerCase())
|
||||
)
|
||||
: deviceData.nodes.filter((dv: DeviceValue) =>
|
||||
dv.id.slice(2).toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
}
|
||||
return deviceData.nodes.filter((dv: DeviceValue) =>
|
||||
dv.id.slice(2).toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
}, [deviceData.nodes, onlyFav, search]);
|
||||
|
||||
const deviceIndex = coreData.devices.findIndex(
|
||||
(d: Device) => d.id === device_select.state.id
|
||||
@@ -660,12 +660,12 @@ const Devices = memo(() => {
|
||||
}}
|
||||
>
|
||||
<Box sx={{ p: 1 }}>
|
||||
<Grid container justifyContent="space-between">
|
||||
<Typography noWrap variant="subtitle1" color="warning.main">
|
||||
<Grid container sx={{ justifyContent: 'space-between' }}>
|
||||
<Typography noWrap variant="subtitle1" color="warning">
|
||||
{deviceInfo.n} (
|
||||
{deviceInfo.tn})
|
||||
</Typography>
|
||||
<Grid justifyContent="flex-end">
|
||||
<Grid sx={{ justifyContent: 'flex-end' }}>
|
||||
<ButtonTooltip title={LL.CLOSE()}>
|
||||
<IconButton onClick={resetDeviceSelect} aria-label={LL.CLOSE()}>
|
||||
<HighlightOffIcon color="primary" sx={{ fontSize: 18 }} />
|
||||
@@ -788,6 +788,7 @@ const Devices = memo(() => {
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => showDeviceValue(dv)}
|
||||
style={{ backgroundColor: 'transparent' }}
|
||||
>
|
||||
{dv.v === '' ? (
|
||||
<PlayArrowIcon color="primary" sx={{ fontSize: 16 }} />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
||||
import WarningIcon from '@mui/icons-material/Warning';
|
||||
import {
|
||||
Box,
|
||||
@@ -18,15 +19,18 @@ import {
|
||||
Typography
|
||||
} from '@mui/material';
|
||||
|
||||
import { callAction } from '@/api/app';
|
||||
import { dialogStyle } from 'CustomTheme';
|
||||
import type Schema from 'async-validator';
|
||||
import type { ValidateFieldsError } from 'async-validator';
|
||||
import { useRequest } from 'alova/client';
|
||||
import { ValidatedTextField } from 'components';
|
||||
import { toast } from 'components/toast';
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
import { numberValue, updateValue } from 'utils';
|
||||
import { validate } from 'validators';
|
||||
import { ValidationError, validate } from 'validators';
|
||||
import type Schema from 'validators/schema';
|
||||
import type { ValidateFieldsError } from 'validators/schema';
|
||||
|
||||
import { DeviceValueUOM, DeviceValueUOM_s } from './types';
|
||||
import { DeviceValueUOM, DeviceValueUOM_s, getDeviceValueKind } from './types';
|
||||
import type { DeviceValue } from './types';
|
||||
|
||||
interface DevicesDialogProps {
|
||||
@@ -52,7 +56,7 @@ const DevicesDialog = ({
|
||||
const [editItem, setEditItem] = useState<DeviceValue>(selectedItem);
|
||||
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
||||
|
||||
const updateFormValue = useMemo(() => updateValue(setEditItem), [setEditItem]);
|
||||
const updateFormValue = updateValue(setEditItem);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
@@ -61,36 +65,48 @@ const DevicesDialog = ({
|
||||
}
|
||||
}, [open, selectedItem]);
|
||||
|
||||
const save = useCallback(async () => {
|
||||
const { send: executeCommand } = useRequest(
|
||||
(id: string) => callAction({ action: 'executeCommand', param: id }),
|
||||
{ immediate: false }
|
||||
)
|
||||
.onSuccess(() => {
|
||||
toast.success(LL.COMMAND_SENT());
|
||||
})
|
||||
.onError((error) => {
|
||||
toast.error(String(error.error?.message || 'An error occurred'));
|
||||
});
|
||||
|
||||
const doAction = async () => {
|
||||
try {
|
||||
setFieldErrors(undefined);
|
||||
await validate(validator, editItem);
|
||||
if (editItem.v === undefined && editItem.c !== undefined) {
|
||||
await executeCommand(editItem.c);
|
||||
} else {
|
||||
await validate(validator, editItem);
|
||||
}
|
||||
onSave(editItem);
|
||||
} catch (error) {
|
||||
setFieldErrors(error as ValidateFieldsError);
|
||||
setFieldErrors((error as ValidationError).fieldErrors);
|
||||
}
|
||||
}, [validator, editItem, onSave]);
|
||||
};
|
||||
|
||||
const setUom = useCallback(
|
||||
(uom?: DeviceValueUOM) => {
|
||||
if (uom === undefined) {
|
||||
return;
|
||||
}
|
||||
switch (uom) {
|
||||
case DeviceValueUOM.HOURS:
|
||||
return LL.HOURS();
|
||||
case DeviceValueUOM.MINUTES:
|
||||
return LL.MINUTES();
|
||||
case DeviceValueUOM.SECONDS:
|
||||
return LL.SECONDS();
|
||||
default:
|
||||
return DeviceValueUOM_s[uom];
|
||||
}
|
||||
},
|
||||
[LL]
|
||||
);
|
||||
const setUom = (uom?: DeviceValueUOM) => {
|
||||
if (uom === undefined) {
|
||||
return;
|
||||
}
|
||||
switch (uom) {
|
||||
case DeviceValueUOM.HOURS:
|
||||
return LL.HOURS();
|
||||
case DeviceValueUOM.MINUTES:
|
||||
return LL.MINUTES();
|
||||
case DeviceValueUOM.SECONDS:
|
||||
return LL.SECONDS();
|
||||
default:
|
||||
return DeviceValueUOM_s[uom];
|
||||
}
|
||||
};
|
||||
|
||||
const showHelperText = useCallback((dv: DeviceValue) => {
|
||||
const showHelperText = (dv: DeviceValue) => {
|
||||
if (dv.h) return dv.h;
|
||||
if (dv.l) return dv.l.join(' | ');
|
||||
if (dv.m !== undefined && dv.x !== undefined) {
|
||||
@@ -101,26 +117,23 @@ const DevicesDialog = ({
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}, []);
|
||||
};
|
||||
|
||||
const isCommand = useMemo(
|
||||
() => selectedItem.v === '' && selectedItem.c,
|
||||
[selectedItem.v, selectedItem.c]
|
||||
);
|
||||
const kind = getDeviceValueKind(selectedItem);
|
||||
|
||||
const dialogTitle = useMemo(() => {
|
||||
if (isCommand) return LL.RUN_COMMAND();
|
||||
return writeable ? LL.CHANGE_VALUE() : LL.VALUE(0);
|
||||
}, [isCommand, writeable, LL]);
|
||||
const dialogTitle =
|
||||
kind === 'command' || kind === 'deviceCommand'
|
||||
? LL.RUN() + ' ' + LL.COMMAND(0)
|
||||
: kind === 'scheduler'
|
||||
? LL.UPDATE() + ' ' + LL.SCHEDULE(0)
|
||||
: writeable
|
||||
? LL.CHANGE_VALUE()
|
||||
: LL.VALUE(0);
|
||||
|
||||
const buttonLabel = useMemo(() => {
|
||||
return isCommand ? LL.EXECUTE() : LL.UPDATE();
|
||||
}, [isCommand, LL]);
|
||||
const buttonLabel =
|
||||
kind === 'command' || kind === 'deviceCommand' ? LL.RUN() : LL.UPDATE();
|
||||
|
||||
const helperText = useMemo(
|
||||
() => showHelperText(editItem),
|
||||
[editItem, showHelperText]
|
||||
);
|
||||
const helperText = showHelperText(editItem);
|
||||
|
||||
const valueLabel = LL.VALUE(0);
|
||||
|
||||
@@ -128,70 +141,72 @@ const DevicesDialog = ({
|
||||
<Dialog sx={dialogStyle} open={open} onClose={onClose}>
|
||||
<DialogTitle>{dialogTitle}</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<Box color="warning.main" mb={2}>
|
||||
<Typography variant="body2">{editItem.id.slice(2)}</Typography>
|
||||
</Box>
|
||||
<Grid container>
|
||||
<Grid size={12}>
|
||||
{editItem.l ? (
|
||||
<TextField
|
||||
name="v"
|
||||
value={editItem.v}
|
||||
aria-label={valueLabel}
|
||||
disabled={!writeable}
|
||||
sx={{ width: '30ch' }}
|
||||
select
|
||||
onChange={updateFormValue}
|
||||
>
|
||||
{editItem.l.map((val) => (
|
||||
<MenuItem value={val} key={val}>
|
||||
{val}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
) : editItem.s || editItem.u !== DeviceValueUOM.NONE ? (
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
name="v"
|
||||
label={valueLabel}
|
||||
value={numberValue(Math.round((editItem.v as number) * 10) / 10)}
|
||||
autoFocus
|
||||
disabled={!writeable}
|
||||
type="number"
|
||||
sx={{ width: '30ch' }}
|
||||
onChange={updateFormValue}
|
||||
slotProps={{
|
||||
htmlInput: editItem.s
|
||||
? { min: editItem.m, max: editItem.x, step: editItem.s }
|
||||
: {},
|
||||
input: {
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
{setUom(editItem.u)}
|
||||
</InputAdornment>
|
||||
)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
name="v"
|
||||
label={valueLabel}
|
||||
value={editItem.v}
|
||||
disabled={!writeable}
|
||||
sx={{ width: '30ch' }}
|
||||
multiline={!editItem.u}
|
||||
onChange={updateFormValue}
|
||||
/>
|
||||
<Typography sx={{ mb: 2 }} color="warning" variant="body2">
|
||||
{editItem.id.slice(2)}
|
||||
</Typography>
|
||||
{kind !== 'command' && (
|
||||
<Grid container>
|
||||
<Grid size={12}>
|
||||
{editItem.l ? (
|
||||
<TextField
|
||||
name="v"
|
||||
value={editItem.v}
|
||||
aria-label={valueLabel}
|
||||
disabled={!writeable}
|
||||
sx={{ width: '30ch' }}
|
||||
select
|
||||
onChange={updateFormValue}
|
||||
>
|
||||
{editItem.l.map((val) => (
|
||||
<MenuItem value={val} key={val}>
|
||||
{val}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
) : editItem.s || editItem.u !== DeviceValueUOM.NONE ? (
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
name="v"
|
||||
label={valueLabel}
|
||||
value={numberValue(Math.round((editItem.v as number) * 10) / 10)}
|
||||
autoFocus
|
||||
disabled={!writeable}
|
||||
type="number"
|
||||
sx={{ width: '30ch' }}
|
||||
onChange={updateFormValue}
|
||||
slotProps={{
|
||||
htmlInput: editItem.s
|
||||
? { min: editItem.m, max: editItem.x, step: editItem.s }
|
||||
: {},
|
||||
input: {
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
{setUom(editItem.u)}
|
||||
</InputAdornment>
|
||||
)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
name="v"
|
||||
label={valueLabel}
|
||||
value={editItem.v}
|
||||
disabled={!writeable}
|
||||
sx={{ width: '30ch' }}
|
||||
multiline={!editItem.u}
|
||||
onChange={updateFormValue}
|
||||
/>
|
||||
)}
|
||||
</Grid>
|
||||
{writeable && helperText && (
|
||||
<Grid>
|
||||
<FormHelperText>{helperText}</FormHelperText>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
{writeable && helperText && (
|
||||
<Grid>
|
||||
<FormHelperText>{helperText}</FormHelperText>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
)}
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
@@ -213,10 +228,16 @@ const DevicesDialog = ({
|
||||
{LL.CANCEL()}
|
||||
</Button>
|
||||
<Button
|
||||
startIcon={<WarningIcon color="warning" />}
|
||||
startIcon={
|
||||
kind === 'command' ? (
|
||||
<PlayArrowIcon />
|
||||
) : kind === 'deviceEntity' || kind === 'deviceCommand' ? (
|
||||
<WarningIcon color="warning" />
|
||||
) : undefined
|
||||
}
|
||||
variant="outlined"
|
||||
onClick={save}
|
||||
color="primary"
|
||||
onClick={doAction}
|
||||
color={kind !== 'value' ? 'success' : 'primary'}
|
||||
>
|
||||
{buttonLabel}
|
||||
</Button>
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import { ToggleButton, ToggleButtonGroup } from '@mui/material';
|
||||
|
||||
import OptionIcon from './OptionIcon';
|
||||
@@ -11,7 +9,6 @@ interface EntityMaskToggleProps {
|
||||
de: DeviceEntity;
|
||||
}
|
||||
|
||||
// Available mask values
|
||||
const MASK_VALUES = [
|
||||
DeviceEntityMask.DV_WEB_EXCLUDE, // 1
|
||||
DeviceEntityMask.DV_API_MQTT_EXCLUDE, // 2
|
||||
@@ -20,123 +17,95 @@ const MASK_VALUES = [
|
||||
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);
|
||||
};
|
||||
const getMaskNumber = (newMask: string[]): number =>
|
||||
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) =>
|
||||
const getMaskString = (mask: number): string[] =>
|
||||
MASK_VALUES.filter((value) => (mask & value) === value).map((value) =>
|
||||
String(value)
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if a specific mask bit is set
|
||||
*/
|
||||
const hasMask = (mask: number, flag: number): boolean => (mask & flag) === flag;
|
||||
|
||||
const EntityMaskToggle = ({ onUpdate, de }: EntityMaskToggleProps) => {
|
||||
const handleChange = useCallback(
|
||||
(_event: unknown, mask: string[]) => {
|
||||
// Convert selected masks to a number
|
||||
const newMask = getMaskNumber(mask);
|
||||
const updatedDe = { ...de };
|
||||
const handleChange = (_event: unknown, mask: string[]) => {
|
||||
const newMask = getMaskNumber(mask);
|
||||
const updatedDe = { ...de };
|
||||
|
||||
// Apply business logic for mask interactions
|
||||
// If entity has no name and is set to readonly, also exclude from web
|
||||
if (updatedDe.n === '' && hasMask(newMask, DeviceEntityMask.DV_READONLY)) {
|
||||
updatedDe.m = newMask | DeviceEntityMask.DV_WEB_EXCLUDE;
|
||||
} else {
|
||||
updatedDe.m = newMask;
|
||||
}
|
||||
// If entity has no name and is set to readonly, also exclude from web
|
||||
if (updatedDe.n === '' && hasMask(newMask, DeviceEntityMask.DV_READONLY)) {
|
||||
updatedDe.m = newMask | DeviceEntityMask.DV_WEB_EXCLUDE;
|
||||
} else {
|
||||
updatedDe.m = newMask;
|
||||
}
|
||||
|
||||
// If excluded from web, cannot be favorite
|
||||
if (hasMask(updatedDe.m, DeviceEntityMask.DV_WEB_EXCLUDE)) {
|
||||
updatedDe.m = updatedDe.m & ~DeviceEntityMask.DV_FAVORITE;
|
||||
}
|
||||
// If excluded from web, cannot be favorite
|
||||
if (hasMask(updatedDe.m, DeviceEntityMask.DV_WEB_EXCLUDE)) {
|
||||
updatedDe.m = updatedDe.m & ~DeviceEntityMask.DV_FAVORITE;
|
||||
}
|
||||
|
||||
onUpdate(updatedDe);
|
||||
},
|
||||
[de, onUpdate]
|
||||
);
|
||||
|
||||
// Memoize mask string value
|
||||
const maskStringValue = useMemo(() => getMaskString(de.m), [de.m]);
|
||||
|
||||
// Memoize disabled states
|
||||
const isFavoriteDisabled = useMemo(
|
||||
() =>
|
||||
hasMask(de.m, DeviceEntityMask.DV_WEB_EXCLUDE | DeviceEntityMask.DV_DELETED) ||
|
||||
de.n === undefined,
|
||||
[de.m, de.n]
|
||||
);
|
||||
|
||||
const isReadonlyDisabled = useMemo(
|
||||
() =>
|
||||
!de.w ||
|
||||
hasMask(de.m, DeviceEntityMask.DV_WEB_EXCLUDE | DeviceEntityMask.DV_FAVORITE),
|
||||
[de.w, de.m]
|
||||
);
|
||||
|
||||
const isApiMqttExcludeDisabled = useMemo(
|
||||
() => de.n === '' || hasMask(de.m, DeviceEntityMask.DV_DELETED),
|
||||
[de.n, de.m]
|
||||
);
|
||||
|
||||
const isWebExcludeDisabled = useMemo(
|
||||
() => de.n === undefined || hasMask(de.m, DeviceEntityMask.DV_DELETED),
|
||||
[de.n, de.m]
|
||||
);
|
||||
|
||||
// Memoize mask flag checks
|
||||
const isFavoriteSet = useMemo(
|
||||
() => hasMask(de.m, DeviceEntityMask.DV_FAVORITE),
|
||||
[de.m]
|
||||
);
|
||||
const isReadonlySet = useMemo(
|
||||
() => hasMask(de.m, DeviceEntityMask.DV_READONLY),
|
||||
[de.m]
|
||||
);
|
||||
const isApiMqttExcludeSet = useMemo(
|
||||
() => hasMask(de.m, DeviceEntityMask.DV_API_MQTT_EXCLUDE),
|
||||
[de.m]
|
||||
);
|
||||
const isWebExcludeSet = useMemo(
|
||||
() => hasMask(de.m, DeviceEntityMask.DV_WEB_EXCLUDE),
|
||||
[de.m]
|
||||
);
|
||||
const isDeletedSet = useMemo(
|
||||
() => hasMask(de.m, DeviceEntityMask.DV_DELETED),
|
||||
[de.m]
|
||||
);
|
||||
onUpdate(updatedDe);
|
||||
};
|
||||
|
||||
return (
|
||||
<ToggleButtonGroup
|
||||
size="small"
|
||||
color="secondary"
|
||||
value={maskStringValue}
|
||||
value={getMaskString(de.m)}
|
||||
onChange={handleChange}
|
||||
>
|
||||
<ToggleButton value="8" disabled={isFavoriteDisabled}>
|
||||
<OptionIcon type="favorite" isSet={isFavoriteSet} />
|
||||
<ToggleButton
|
||||
value="8"
|
||||
disabled={
|
||||
hasMask(
|
||||
de.m,
|
||||
DeviceEntityMask.DV_WEB_EXCLUDE | DeviceEntityMask.DV_DELETED
|
||||
) || de.n === undefined
|
||||
}
|
||||
>
|
||||
<OptionIcon
|
||||
type="favorite"
|
||||
isSet={hasMask(de.m, DeviceEntityMask.DV_FAVORITE)}
|
||||
/>
|
||||
</ToggleButton>
|
||||
<ToggleButton value="4" disabled={isReadonlyDisabled}>
|
||||
<OptionIcon type="readonly" isSet={isReadonlySet} />
|
||||
<ToggleButton
|
||||
value="4"
|
||||
disabled={
|
||||
!de.w ||
|
||||
hasMask(
|
||||
de.m,
|
||||
DeviceEntityMask.DV_WEB_EXCLUDE | DeviceEntityMask.DV_FAVORITE
|
||||
)
|
||||
}
|
||||
>
|
||||
<OptionIcon
|
||||
type="readonly"
|
||||
isSet={hasMask(de.m, DeviceEntityMask.DV_READONLY)}
|
||||
/>
|
||||
</ToggleButton>
|
||||
<ToggleButton value="2" disabled={isApiMqttExcludeDisabled}>
|
||||
<OptionIcon type="api_mqtt_exclude" isSet={isApiMqttExcludeSet} />
|
||||
<ToggleButton
|
||||
value="2"
|
||||
disabled={de.n === '' || hasMask(de.m, DeviceEntityMask.DV_DELETED)}
|
||||
>
|
||||
<OptionIcon
|
||||
type="api_mqtt_exclude"
|
||||
isSet={hasMask(de.m, DeviceEntityMask.DV_API_MQTT_EXCLUDE)}
|
||||
/>
|
||||
</ToggleButton>
|
||||
<ToggleButton value="1" disabled={isWebExcludeDisabled}>
|
||||
<OptionIcon type="web_exclude" isSet={isWebExcludeSet} />
|
||||
<ToggleButton
|
||||
value="1"
|
||||
disabled={de.n === undefined || hasMask(de.m, DeviceEntityMask.DV_DELETED)}
|
||||
>
|
||||
<OptionIcon
|
||||
type="web_exclude"
|
||||
isSet={hasMask(de.m, DeviceEntityMask.DV_WEB_EXCLUDE)}
|
||||
/>
|
||||
</ToggleButton>
|
||||
<ToggleButton value="128">
|
||||
<OptionIcon type="deleted" isSet={isDeletedSet} />
|
||||
<OptionIcon
|
||||
type="deleted"
|
||||
isSet={hasMask(de.m, DeviceEntityMask.DV_DELETED)}
|
||||
/>
|
||||
</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { memo, useCallback, useContext, useMemo, useState } from 'react';
|
||||
import { memo, useContext, useState } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
import CommentIcon from '@mui/icons-material/CommentTwoTone';
|
||||
import DownloadIcon from '@mui/icons-material/GetApp';
|
||||
@@ -11,6 +10,7 @@ import {
|
||||
Box,
|
||||
Button,
|
||||
Divider,
|
||||
Grid,
|
||||
Link,
|
||||
List,
|
||||
ListItem,
|
||||
@@ -24,6 +24,7 @@ import type { SxProps, Theme } from '@mui/material/styles';
|
||||
|
||||
import { useRequest } from 'alova/client';
|
||||
import { SectionContent, useLayoutTitle } from 'components';
|
||||
import { toast } from 'components/toast';
|
||||
import { AuthenticatedContext } from 'contexts/authentication';
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
import { saveFile } from 'utils';
|
||||
@@ -42,7 +43,7 @@ interface CustomSupport {
|
||||
html: string | null;
|
||||
}
|
||||
|
||||
const DEFAULT_IMAGE_URL = 'https://emsesp.org/_media/images/installer.jpeg';
|
||||
const DEFAULT_IMAGE_URL = 'https://emsesp.org/media/images/installer.jpeg';
|
||||
|
||||
const SUPPORT_BOX_STYLES: SxProps<Theme> = {
|
||||
borderRadius: 3,
|
||||
@@ -59,6 +60,8 @@ const AVATAR_STYLES: SxProps<Theme> = {
|
||||
bgcolor: '#72caf9'
|
||||
};
|
||||
|
||||
const SYSTEM_INFO_API: APIcall = { device: 'system', cmd: 'info', id: 0 };
|
||||
|
||||
const HelpComponent = () => {
|
||||
const { LL } = useI18nContext();
|
||||
useLayoutTitle(LL.HELP());
|
||||
@@ -71,13 +74,7 @@ const HelpComponent = () => {
|
||||
});
|
||||
const [imgError, setImgError] = useState<boolean>(false);
|
||||
|
||||
// Memoize the request method to prevent re-creation on every render
|
||||
const getCustomSupportMethod = useMemo(
|
||||
() => callAction({ action: 'getCustomSupport' }),
|
||||
[]
|
||||
);
|
||||
|
||||
useRequest(getCustomSupportMethod).onSuccess((event) => {
|
||||
useRequest(callAction({ action: 'getCustomSupport' })).onSuccess((event) => {
|
||||
if (event?.data && Object.keys(event.data).length !== 0) {
|
||||
const { Support } = event.data as {
|
||||
Support: { img_url?: string; html?: string[] };
|
||||
@@ -100,57 +97,34 @@ const HelpComponent = () => {
|
||||
toast.error(String(error.error?.message || 'An error occurred'));
|
||||
});
|
||||
|
||||
// Optimize API call memoization
|
||||
const apiCall = useMemo(() => ({ device: 'system', cmd: 'info', id: 0 }), []);
|
||||
const helpLinks: HelpLink[] = [
|
||||
{
|
||||
href: 'https://emsesp.org',
|
||||
icon: <MenuBookIcon />,
|
||||
label: () => LL.HELP_INFORMATION_1()
|
||||
},
|
||||
{
|
||||
href: 'https://discord.gg/GP9DPSgeJq',
|
||||
icon: <CommentIcon />,
|
||||
label: () => LL.HELP_INFORMATION_2()
|
||||
},
|
||||
{
|
||||
href: 'https://github.com/emsesp/EMS-ESP32/issues/new/choose',
|
||||
icon: <GitHubIcon />,
|
||||
label: () => LL.HELP_INFORMATION_3()
|
||||
}
|
||||
];
|
||||
|
||||
const 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]
|
||||
);
|
||||
const imageSrc =
|
||||
imgError || !customSupport.img_url ? DEFAULT_IMAGE_URL : customSupport.img_url;
|
||||
|
||||
return (
|
||||
<SectionContent>
|
||||
{customSupport.html && (
|
||||
<Stack
|
||||
padding={1}
|
||||
mb={2}
|
||||
direction="row"
|
||||
divider={<Divider orientation="vertical" flexItem />}
|
||||
sx={SUPPORT_BOX_STYLES}
|
||||
sx={{ padding: 1, mb: 2, ...SUPPORT_BOX_STYLES }}
|
||||
>
|
||||
<Typography variant="subtitle1">
|
||||
<div dangerouslySetInnerHTML={{ __html: customSupport.html }} />
|
||||
@@ -159,13 +133,13 @@ const HelpComponent = () => {
|
||||
component="img"
|
||||
referrerPolicy="no-referrer"
|
||||
sx={IMAGE_STYLES}
|
||||
onError={handleImageError}
|
||||
onError={() => setImgError(true)}
|
||||
src={imageSrc}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{isAdmin && (
|
||||
{me?.admin && (
|
||||
<List>
|
||||
{helpLinks.map(({ href, icon, label }) => (
|
||||
<ListItem key={href}>
|
||||
@@ -185,23 +159,23 @@ const HelpComponent = () => {
|
||||
</List>
|
||||
)}
|
||||
|
||||
<Box p={2} color="warning.main">
|
||||
<Typography mb={1} variant="body1">
|
||||
{LL.HELP_INFORMATION_4()}.
|
||||
<Grid container spacing={2} sx={{ mt: 2, alignItems: 'center' }}>
|
||||
<Typography sx={{ mb: 1 }} color="warning" variant="body1">
|
||||
{LL.HELP_INFORMATION_4()}:
|
||||
</Typography>
|
||||
<Button
|
||||
startIcon={<DownloadIcon />}
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
onClick={handleDownloadSystemInfo}
|
||||
onClick={() => void sendAPI(SYSTEM_INFO_API)}
|
||||
>
|
||||
{LL.SUPPORT_INFORMATION(0)}
|
||||
</Button>
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
<Divider sx={{ mt: 4 }} />
|
||||
|
||||
<Typography color="white" variant="subtitle1" align="center" mt={1}>
|
||||
<Typography color="white" variant="subtitle1" align="center" sx={{ mt: 1 }}>
|
||||
©
|
||||
<Link
|
||||
target="_blank"
|
||||
@@ -216,7 +190,6 @@ const HelpComponent = () => {
|
||||
);
|
||||
};
|
||||
|
||||
// Memoize the component to prevent unnecessary re-renders
|
||||
const Help = memo(HelpComponent);
|
||||
|
||||
export default Help;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { memo, useCallback, useMemo, useState } from 'react';
|
||||
import { memo, useState } from 'react';
|
||||
import { useBlocker } from 'react-router';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import CircleIcon from '@mui/icons-material/Circle';
|
||||
@@ -25,6 +24,7 @@ import {
|
||||
SectionContent,
|
||||
useLayoutTitle
|
||||
} from 'components';
|
||||
import { toast } from 'components/toast';
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
|
||||
import { readModules, writeModules } from '../../api/app';
|
||||
@@ -69,58 +69,53 @@ const Modules = () => {
|
||||
}
|
||||
);
|
||||
|
||||
const modules_theme = useTheme(
|
||||
useMemo(
|
||||
() => ({
|
||||
Table: `
|
||||
--data-table-library_grid-template-columns: 48px 180px 120px 100px repeat(1, minmax(160px, 1fr)) 180px;
|
||||
`,
|
||||
BaseRow: `
|
||||
font-size: 14px;
|
||||
.td {
|
||||
height: 32px;
|
||||
}
|
||||
`,
|
||||
BaseCell: `
|
||||
&:nth-of-type(1) {
|
||||
text-align: center;
|
||||
}
|
||||
`,
|
||||
HeaderRow: `
|
||||
text-transform: uppercase;
|
||||
background-color: black;
|
||||
color: #90CAF9;
|
||||
.th {
|
||||
border-bottom: 1px solid #565656;
|
||||
height: 36px;
|
||||
}
|
||||
`,
|
||||
Row: `
|
||||
background-color: #1e1e1e;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
.td {
|
||||
border-top: 1px solid #565656;
|
||||
border-bottom: 1px solid #565656;
|
||||
}
|
||||
&:hover .td {
|
||||
border-top: 1px solid #177ac9;
|
||||
border-bottom: 1px solid #177ac9;
|
||||
}
|
||||
&:nth-of-type(odd) .td {
|
||||
background-color: #303030;
|
||||
}
|
||||
`
|
||||
}),
|
||||
[]
|
||||
)
|
||||
);
|
||||
const modules_theme = useTheme({
|
||||
Table: `
|
||||
--data-table-library_grid-template-columns: 48px 180px 120px 100px repeat(1, minmax(160px, 1fr)) 180px;
|
||||
`,
|
||||
BaseRow: `
|
||||
font-size: 14px;
|
||||
.td {
|
||||
height: 32px;
|
||||
}
|
||||
`,
|
||||
BaseCell: `
|
||||
&:nth-of-type(1) {
|
||||
text-align: center;
|
||||
}
|
||||
`,
|
||||
HeaderRow: `
|
||||
text-transform: uppercase;
|
||||
background-color: black;
|
||||
color: #90CAF9;
|
||||
.th {
|
||||
border-bottom: 1px solid #565656;
|
||||
height: 36px;
|
||||
}
|
||||
`,
|
||||
Row: `
|
||||
background-color: #1e1e1e;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
.td {
|
||||
border-top: 1px solid #565656;
|
||||
border-bottom: 1px solid #565656;
|
||||
}
|
||||
&:hover .td {
|
||||
border-top: 1px solid #177ac9;
|
||||
border-bottom: 1px solid #177ac9;
|
||||
}
|
||||
&:nth-of-type(odd) .td {
|
||||
background-color: #303030;
|
||||
}
|
||||
`
|
||||
});
|
||||
|
||||
const onDialogClose = useCallback(() => {
|
||||
const onDialogClose = () => {
|
||||
setDialogOpen(false);
|
||||
}, []);
|
||||
};
|
||||
|
||||
const updateModuleItem = useCallback((updatedItem: ModuleItem) => {
|
||||
const updateModuleItem = (updatedItem: ModuleItem) => {
|
||||
void updateState(readModules(), (data: ModuleItem[]) => {
|
||||
const new_data = data.map((mi) =>
|
||||
mi.id === updatedItem.id ? { ...mi, ...updatedItem } : mi
|
||||
@@ -128,28 +123,25 @@ const Modules = () => {
|
||||
setNumChanges(new_data.filter(hasModulesChanged).length);
|
||||
return new_data;
|
||||
});
|
||||
}, []);
|
||||
};
|
||||
|
||||
const onDialogSave = useCallback(
|
||||
(updatedItem: ModuleItem) => {
|
||||
setDialogOpen(false);
|
||||
updateModuleItem(updatedItem);
|
||||
},
|
||||
[updateModuleItem]
|
||||
);
|
||||
const onDialogSave = (updatedItem: ModuleItem) => {
|
||||
setDialogOpen(false);
|
||||
updateModuleItem(updatedItem);
|
||||
};
|
||||
|
||||
const editModuleItem = useCallback((mi: ModuleItem) => {
|
||||
const editModuleItem = (mi: ModuleItem) => {
|
||||
setSelectedModuleItem(mi);
|
||||
setDialogOpen(true);
|
||||
}, []);
|
||||
};
|
||||
|
||||
const onCancel = useCallback(async () => {
|
||||
const onCancel = async () => {
|
||||
await fetchModules().then(() => {
|
||||
setNumChanges(0);
|
||||
});
|
||||
}, [fetchModules]);
|
||||
};
|
||||
|
||||
const saveModules = useCallback(async () => {
|
||||
const saveModules = async () => {
|
||||
try {
|
||||
await Promise.all(
|
||||
modules.map((condensed_mi: ModuleItem) =>
|
||||
@@ -167,9 +159,9 @@ const Modules = () => {
|
||||
await fetchModules();
|
||||
setNumChanges(0);
|
||||
}
|
||||
}, [modules, updateModules, LL, fetchModules]);
|
||||
};
|
||||
|
||||
const content = useMemo(() => {
|
||||
const renderContent = () => {
|
||||
if (!modules) {
|
||||
return (
|
||||
<FormLoader onRetry={fetchModules} errorMessage={error?.message || ''} />
|
||||
@@ -186,9 +178,9 @@ const Modules = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box mb={2} color="warning.main">
|
||||
<Typography variant="body1">{LL.MODULES_DESCRIPTION()}.</Typography>
|
||||
</Box>
|
||||
<Typography sx={{ mb: 2 }} color="warning" variant="body1">
|
||||
{LL.MODULES_DESCRIPTION()}.
|
||||
</Typography>
|
||||
<Table
|
||||
data={{ nodes: modules }}
|
||||
theme={modules_theme}
|
||||
@@ -236,8 +228,8 @@ const Modules = () => {
|
||||
)}
|
||||
</Table>
|
||||
|
||||
<Box mt={1} display="flex" flexWrap="wrap">
|
||||
<Box flexGrow={1}>
|
||||
<Box sx={{ mt: 1, display: 'flex', flexWrap: 'wrap' }}>
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
{numChanges !== 0 && (
|
||||
<ButtonRow>
|
||||
<Button
|
||||
@@ -262,22 +254,12 @@ const Modules = () => {
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}, [
|
||||
modules,
|
||||
fetchModules,
|
||||
error,
|
||||
modules_theme,
|
||||
editModuleItem,
|
||||
LL,
|
||||
numChanges,
|
||||
onCancel,
|
||||
saveModules
|
||||
]);
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionContent>
|
||||
{blocker ? <BlockNavigation blocker={blocker} /> : null}
|
||||
{content}
|
||||
{renderContent()}
|
||||
{selectedModuleItem && (
|
||||
<ModulesDialog
|
||||
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 DoneIcon from '@mui/icons-material/Done';
|
||||
@@ -37,14 +37,10 @@ const ModulesDialog = ({
|
||||
const { LL } = useI18nContext();
|
||||
const [editItem, setEditItem] = useState<ModuleItem>(selectedItem);
|
||||
|
||||
const updateFormValue = useMemo(
|
||||
() =>
|
||||
updateValue(
|
||||
setEditItem as unknown as React.Dispatch<
|
||||
React.SetStateAction<Record<string, unknown>>
|
||||
>
|
||||
),
|
||||
[]
|
||||
const updateFormValue = updateValue(
|
||||
setEditItem as unknown as React.Dispatch<
|
||||
React.SetStateAction<Record<string, unknown>>
|
||||
>
|
||||
);
|
||||
|
||||
// Sync form state when dialog opens or selected item changes
|
||||
@@ -54,18 +50,13 @@ const ModulesDialog = ({
|
||||
}
|
||||
}, [open, selectedItem]);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
const handleSave = () => {
|
||||
onSave(editItem);
|
||||
}, [editItem, onSave]);
|
||||
|
||||
const dialogTitle = useMemo(
|
||||
() => `${LL.EDIT()} ${editItem.key}`,
|
||||
[LL, editItem.key]
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog sx={dialogStyle} fullWidth maxWidth="xs" open={open} onClose={onClose}>
|
||||
<DialogTitle>{dialogTitle}</DialogTitle>
|
||||
<DialogTitle>{`${LL.EDIT()} ${editItem.key}`}</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<Grid container>
|
||||
<BlockFormControlLabel
|
||||
@@ -79,7 +70,7 @@ const ModulesDialog = ({
|
||||
label="Enabled"
|
||||
/>
|
||||
</Grid>
|
||||
<Box mt={2} mb={1}>
|
||||
<Box sx={{ mt: 2, mb: 1 }}>
|
||||
<TextField
|
||||
name="license"
|
||||
label="License Key"
|
||||
|
||||
@@ -2,22 +2,18 @@ import { memo } from 'react';
|
||||
|
||||
import CommentsDisabledOutlinedIcon from '@mui/icons-material/CommentsDisabledOutlined';
|
||||
import DeleteForeverIcon from '@mui/icons-material/DeleteForever';
|
||||
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
||||
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutlined';
|
||||
import EditOffOutlinedIcon from '@mui/icons-material/EditOffOutlined';
|
||||
import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
|
||||
import InsertCommentOutlinedIcon from '@mui/icons-material/InsertCommentOutlined';
|
||||
import StarIcon from '@mui/icons-material/Star';
|
||||
import StarOutlineIcon from '@mui/icons-material/StarOutline';
|
||||
import StarOutlineIcon from '@mui/icons-material/StarOutlined';
|
||||
import VisibilityOffOutlinedIcon from '@mui/icons-material/VisibilityOffOutlined';
|
||||
import VisibilityOutlinedIcon from '@mui/icons-material/VisibilityOutlined';
|
||||
import type { SvgIconProps } from '@mui/material';
|
||||
|
||||
export type OptionType =
|
||||
| 'deleted'
|
||||
| 'readonly'
|
||||
| 'web_exclude'
|
||||
| 'api_mqtt_exclude'
|
||||
| 'favorite';
|
||||
'deleted' | 'readonly' | 'web_exclude' | 'api_mqtt_exclude' | 'favorite';
|
||||
|
||||
type IconPair = [
|
||||
React.ComponentType<SvgIconProps>,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useBlocker } from 'react-router';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
@@ -26,10 +25,11 @@ import {
|
||||
SectionContent,
|
||||
useLayoutTitle
|
||||
} from 'components';
|
||||
import { toast } from 'components/toast';
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
import { useInterval } from 'utils';
|
||||
|
||||
import { readSchedule, writeSchedule } from '../../api/app';
|
||||
import { readCommands, readSchedule, writeSchedule } from '../../api/app';
|
||||
import SettingsSchedulerDialog from './SchedulerDialog';
|
||||
import { ScheduleFlag } from './types';
|
||||
import type { Schedule, ScheduleItem } from './types';
|
||||
@@ -54,14 +54,13 @@ const DEFAULT_SCHEDULE_ITEM: Omit<ScheduleItem, 'id' | 'o_id'> = {
|
||||
deleted: false,
|
||||
flags: FLAG_ALL_DAYS,
|
||||
time: '',
|
||||
cmd: '',
|
||||
value: '',
|
||||
cmd_name: '',
|
||||
name: ''
|
||||
};
|
||||
|
||||
const scheduleTheme = {
|
||||
Table: `
|
||||
--data-table-library_grid-template-columns: 36px 210px 100px 192px repeat(1, minmax(100px, 1fr)) 160px;
|
||||
--data-table-library_grid-template-columns: 36px 220px repeat(1, minmax(20px, 1fr)) 192px 160px;
|
||||
`,
|
||||
BaseRow: `
|
||||
font-size: 14px;
|
||||
@@ -70,11 +69,8 @@ const scheduleTheme = {
|
||||
}
|
||||
`,
|
||||
BaseCell: `
|
||||
&:nth-of-type(2) {
|
||||
text-align: center;
|
||||
}
|
||||
&:nth-of-type(1) {
|
||||
text-align: center;
|
||||
text-align: 8px;
|
||||
}
|
||||
`,
|
||||
HeaderRow: `
|
||||
@@ -100,7 +96,6 @@ const scheduleTheme = {
|
||||
};
|
||||
|
||||
const scheduleTypeLabels: Record<number, string> = {
|
||||
[ScheduleFlag.SCHEDULE_IMMEDIATE]: 'Immediate',
|
||||
[ScheduleFlag.SCHEDULE_TIMER]: 'Timer',
|
||||
[ScheduleFlag.SCHEDULE_CONDITION]: 'Condition',
|
||||
[ScheduleFlag.SCHEDULE_ONCHANGE]: 'On Change'
|
||||
@@ -125,6 +120,11 @@ const Scheduler = () => {
|
||||
initialData: []
|
||||
});
|
||||
|
||||
const { data: commandNames } = useRequest(readCommands, {
|
||||
initialData: [],
|
||||
initializing: true
|
||||
});
|
||||
|
||||
const { send: updateSchedule } = useRequest(
|
||||
(data: Schedule) => writeSchedule(data),
|
||||
{
|
||||
@@ -132,7 +132,7 @@ const Scheduler = () => {
|
||||
}
|
||||
);
|
||||
|
||||
const hasScheduleChanged = useCallback((si: ScheduleItem) => {
|
||||
const hasScheduleChanged = (si: ScheduleItem) => {
|
||||
return (
|
||||
si.id !== si.o_id ||
|
||||
(si.name || '') !== (si.o_name || '') ||
|
||||
@@ -140,18 +140,15 @@ const Scheduler = () => {
|
||||
si.deleted !== si.o_deleted ||
|
||||
si.flags !== si.o_flags ||
|
||||
si.time !== si.o_time ||
|
||||
si.cmd !== si.o_cmd ||
|
||||
si.value !== si.o_value
|
||||
si.cmd_name !== si.o_cmd_name
|
||||
);
|
||||
}, []);
|
||||
};
|
||||
|
||||
const intervalCallback = useCallback(() => {
|
||||
useInterval(() => {
|
||||
if (numChanges === 0) {
|
||||
void fetchSchedule();
|
||||
}
|
||||
}, [numChanges, fetchSchedule]);
|
||||
|
||||
useInterval(intervalCallback, INTERVAL_DELAY);
|
||||
}, INTERVAL_DELAY);
|
||||
|
||||
useEffect(() => {
|
||||
const formatter = new Intl.DateTimeFormat(locale, {
|
||||
@@ -169,7 +166,7 @@ const Scheduler = () => {
|
||||
|
||||
const schedule_theme = useTheme(scheduleTheme);
|
||||
|
||||
const saveSchedule = useCallback(async () => {
|
||||
const saveSchedule = async () => {
|
||||
try {
|
||||
await updateSchedule({
|
||||
schedule: schedule
|
||||
@@ -179,8 +176,7 @@ const Scheduler = () => {
|
||||
active: condensed_si.active,
|
||||
flags: condensed_si.flags,
|
||||
time: condensed_si.time,
|
||||
cmd: condensed_si.cmd,
|
||||
value: condensed_si.value,
|
||||
cmd_name: condensed_si.cmd_name,
|
||||
name: condensed_si.name
|
||||
}))
|
||||
});
|
||||
@@ -192,46 +188,43 @@ const Scheduler = () => {
|
||||
await fetchSchedule();
|
||||
setNumChanges(0);
|
||||
}
|
||||
}, [LL, schedule, updateSchedule, fetchSchedule]);
|
||||
};
|
||||
|
||||
const editScheduleItem = useCallback((si: ScheduleItem) => {
|
||||
const editScheduleItem = (si: ScheduleItem) => {
|
||||
setCreating(false);
|
||||
setSelectedScheduleItem(si);
|
||||
setDialogOpen(true);
|
||||
if (si.o_name === undefined) {
|
||||
si.o_name = si.name;
|
||||
}
|
||||
}, []);
|
||||
};
|
||||
|
||||
const onDialogClose = useCallback(() => {
|
||||
const onDialogClose = () => {
|
||||
setDialogOpen(false);
|
||||
}, []);
|
||||
};
|
||||
|
||||
const onDialogCancel = useCallback(async () => {
|
||||
const onDialogCancel = async () => {
|
||||
await fetchSchedule().then(() => {
|
||||
setNumChanges(0);
|
||||
});
|
||||
}, [fetchSchedule]);
|
||||
};
|
||||
|
||||
const onDialogSave = useCallback(
|
||||
(updatedItem: ScheduleItem) => {
|
||||
setDialogOpen(false);
|
||||
void updateState(readSchedule(), (data: ScheduleItem[]) => {
|
||||
const new_data = creating
|
||||
? [...data, updatedItem]
|
||||
: data.map((si) =>
|
||||
si.id === updatedItem.id ? { ...si, ...updatedItem } : si
|
||||
);
|
||||
const onDialogSave = (updatedItem: ScheduleItem) => {
|
||||
setDialogOpen(false);
|
||||
void updateState(readSchedule(), (data: ScheduleItem[]) => {
|
||||
const new_data = creating
|
||||
? [...data, updatedItem]
|
||||
: data.map((si) =>
|
||||
si.id === updatedItem.id ? { ...si, ...updatedItem } : si
|
||||
);
|
||||
|
||||
setNumChanges(new_data.filter((si) => hasScheduleChanged(si)).length);
|
||||
setNumChanges(new_data.filter((si) => hasScheduleChanged(si)).length);
|
||||
|
||||
return new_data;
|
||||
});
|
||||
},
|
||||
[creating, hasScheduleChanged]
|
||||
);
|
||||
return new_data;
|
||||
});
|
||||
};
|
||||
|
||||
const addScheduleItem = useCallback(() => {
|
||||
const addScheduleItem = () => {
|
||||
setCreating(true);
|
||||
const newItem: ScheduleItem = {
|
||||
id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID),
|
||||
@@ -239,36 +232,29 @@ const Scheduler = () => {
|
||||
};
|
||||
setSelectedScheduleItem(newItem);
|
||||
setDialogOpen(true);
|
||||
}, []);
|
||||
};
|
||||
|
||||
const filteredAndSortedSchedule = useMemo(
|
||||
() =>
|
||||
schedule
|
||||
.filter((si: ScheduleItem) => !si.deleted)
|
||||
.sort((a: ScheduleItem, b: ScheduleItem) => a.flags - b.flags),
|
||||
[schedule]
|
||||
);
|
||||
const filteredAndSortedSchedule = schedule
|
||||
.filter((si: ScheduleItem) => !si.deleted)
|
||||
.sort((a: ScheduleItem, b: ScheduleItem) => a.flags - b.flags);
|
||||
|
||||
const dayBox = useCallback(
|
||||
(si: ScheduleItem, flag: number) => {
|
||||
const dayIndex = Math.log(flag) / LOG_2;
|
||||
const isActive = (si.flags & flag) === flag;
|
||||
const dayBox = (si: ScheduleItem, flag: number, isLast = false) => {
|
||||
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]
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<Box>
|
||||
<Typography sx={{ fontSize: 11 }} color={isActive ? 'primary' : 'grey'}>
|
||||
{dow[dayIndex]}
|
||||
</Typography>
|
||||
</Box>
|
||||
{!isLast && <Divider orientation="vertical" flexItem />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const scheduleType = useCallback((si: ScheduleItem) => {
|
||||
const scheduleType = (si: ScheduleItem) => {
|
||||
const label = scheduleTypeLabels[si.flags];
|
||||
|
||||
return (
|
||||
@@ -278,9 +264,9 @@ const Scheduler = () => {
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}, []);
|
||||
};
|
||||
|
||||
const renderSchedule = useCallback(() => {
|
||||
const renderSchedule = () => {
|
||||
if (!schedule) {
|
||||
return (
|
||||
<FormLoader onRetry={fetchSchedule} errorMessage={error?.message || ''} />
|
||||
@@ -299,9 +285,10 @@ const Scheduler = () => {
|
||||
<HeaderRow>
|
||||
<HeaderCell />
|
||||
<HeaderCell stiff>{LL.SCHEDULE(0)}</HeaderCell>
|
||||
<HeaderCell stiff>{LL.TIME(0)}/Cond.</HeaderCell>
|
||||
<HeaderCell stiff>
|
||||
{LL.TIME(0)}/{LL.CONDITION()}
|
||||
</HeaderCell>
|
||||
<HeaderCell stiff>{LL.COMMAND(0)}</HeaderCell>
|
||||
<HeaderCell stiff>{LL.VALUE(0)}</HeaderCell>
|
||||
<HeaderCell stiff>{LL.NAME(0)}</HeaderCell>
|
||||
</HeaderRow>
|
||||
</Header>
|
||||
@@ -311,12 +298,16 @@ const Scheduler = () => {
|
||||
<Cell stiff>
|
||||
<CircleIcon
|
||||
color={si.active ? 'success' : 'error'}
|
||||
sx={{ fontSize: ICON_SIZE, verticalAlign: 'middle' }}
|
||||
sx={{
|
||||
fontSize: ICON_SIZE,
|
||||
ml: '4px',
|
||||
position: 'relative',
|
||||
top: '2px'
|
||||
}}
|
||||
/>
|
||||
</Cell>
|
||||
<Cell stiff>
|
||||
<Stack spacing={0.5} direction="row">
|
||||
<Divider orientation="vertical" flexItem />
|
||||
{si.flags > SCHEDULE_FLAG_THRESHOLD ? (
|
||||
scheduleType(si)
|
||||
) : (
|
||||
@@ -327,14 +318,13 @@ const Scheduler = () => {
|
||||
{dayBox(si, ScheduleFlag.SCHEDULE_THU)}
|
||||
{dayBox(si, ScheduleFlag.SCHEDULE_FRI)}
|
||||
{dayBox(si, ScheduleFlag.SCHEDULE_SAT)}
|
||||
{dayBox(si, ScheduleFlag.SCHEDULE_SUN)}
|
||||
{dayBox(si, ScheduleFlag.SCHEDULE_SUN, true)}
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Cell>
|
||||
<Cell>{si.time}</Cell>
|
||||
<Cell>{si.cmd}</Cell>
|
||||
<Cell>{si.value}</Cell>
|
||||
<Cell>{si.time === '' ? LL.SCHEDULER_HELP_2() : si.time}</Cell>
|
||||
<Cell>{si.cmd_name}</Cell>
|
||||
<Cell>{si.name}</Cell>
|
||||
</Row>
|
||||
))}
|
||||
@@ -343,24 +333,14 @@ const Scheduler = () => {
|
||||
)}
|
||||
</Table>
|
||||
);
|
||||
}, [
|
||||
schedule,
|
||||
error,
|
||||
fetchSchedule,
|
||||
filteredAndSortedSchedule,
|
||||
schedule_theme,
|
||||
editScheduleItem,
|
||||
LL,
|
||||
dayBox,
|
||||
scheduleType
|
||||
]);
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionContent>
|
||||
{blocker ? <BlockNavigation blocker={blocker} /> : null}
|
||||
<Box mb={2} color="warning.main">
|
||||
<Typography variant="body1">{LL.SCHEDULER_HELP_1()}.</Typography>
|
||||
</Box>
|
||||
<Typography sx={{ mb: 2 }} color="warning" variant="body1">
|
||||
{LL.SCHEDULER_HELP_1()}.
|
||||
</Typography>
|
||||
{renderSchedule()}
|
||||
|
||||
{selectedScheduleItem && (
|
||||
@@ -372,11 +352,12 @@ const Scheduler = () => {
|
||||
selectedItem={selectedScheduleItem}
|
||||
validator={schedulerItemValidation(schedule, selectedScheduleItem)}
|
||||
dow={dow}
|
||||
commandNames={commandNames.map((ci) => ci.name)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Box display="flex" flexWrap="wrap">
|
||||
<Box flexGrow={1}>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap' }}>
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
{numChanges !== 0 && (
|
||||
<ButtonRow>
|
||||
<Button
|
||||
@@ -398,7 +379,7 @@ const Scheduler = () => {
|
||||
</ButtonRow>
|
||||
)}
|
||||
</Box>
|
||||
<Box flexWrap="nowrap" whiteSpace="nowrap">
|
||||
<Box sx={{ flexWrap: 'nowrap', whiteSpace: 'nowrap' }}>
|
||||
<ButtonRow>
|
||||
<Button
|
||||
startIcon={<AddIcon />}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import CircleIcon from '@mui/icons-material/Circle';
|
||||
import DoneIcon from '@mui/icons-material/Done';
|
||||
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
||||
import RemoveIcon from '@mui/icons-material/RemoveCircleOutline';
|
||||
import RemoveIcon from '@mui/icons-material/RemoveCircleOutlined';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Grid,
|
||||
MenuItem,
|
||||
TextField,
|
||||
ToggleButton,
|
||||
ToggleButtonGroup,
|
||||
@@ -21,12 +22,12 @@ import {
|
||||
} from '@mui/material';
|
||||
|
||||
import { dialogStyle } from 'CustomTheme';
|
||||
import type Schema from 'async-validator';
|
||||
import type { ValidateFieldsError } from 'async-validator';
|
||||
import { BlockFormControlLabel, ValidatedTextField } from 'components';
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
import { updateValue } from 'utils';
|
||||
import { validate } from 'validators';
|
||||
import { ValidationError, validate } from 'validators';
|
||||
import type Schema from 'validators/schema';
|
||||
import type { ValidateFieldsError } from 'validators/schema';
|
||||
|
||||
import { ScheduleFlag } from './types';
|
||||
import type { ScheduleItem } from './types';
|
||||
@@ -60,6 +61,12 @@ const FLAG_VALUES = [
|
||||
ScheduleFlag.SCHEDULE_SAT
|
||||
] as const;
|
||||
|
||||
const getFlagDOWnumber = (flags: string[]) =>
|
||||
flags.reduce((acc, flag) => acc | Number(flag), 0) & FLAG_MASK_127;
|
||||
|
||||
const getFlagDOWstring = (f: number) =>
|
||||
FLAG_VALUES.filter((flag) => (f & flag) === flag).map((flag) => String(flag));
|
||||
|
||||
interface SchedulerDialogProps {
|
||||
open: boolean;
|
||||
creating: boolean;
|
||||
@@ -68,6 +75,7 @@ interface SchedulerDialogProps {
|
||||
selectedItem: ScheduleItem;
|
||||
validator: Schema;
|
||||
dow: string[];
|
||||
commandNames: string[];
|
||||
}
|
||||
|
||||
const SchedulerDialog = ({
|
||||
@@ -77,33 +85,24 @@ const SchedulerDialog = ({
|
||||
onSave,
|
||||
selectedItem,
|
||||
validator,
|
||||
dow
|
||||
dow,
|
||||
commandNames
|
||||
}: SchedulerDialogProps) => {
|
||||
const { LL } = useI18nContext();
|
||||
const [editItem, setEditItem] = useState<ScheduleItem>(selectedItem);
|
||||
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
||||
const [scheduleType, setScheduleType] = useState<ScheduleFlag>();
|
||||
|
||||
const updateFormValue = useMemo(
|
||||
() =>
|
||||
updateValue(
|
||||
setEditItem as unknown as React.Dispatch<
|
||||
React.SetStateAction<Record<string, unknown>>
|
||||
>
|
||||
),
|
||||
[]
|
||||
const updateFormValue = updateValue(
|
||||
setEditItem as unknown as React.Dispatch<
|
||||
React.SetStateAction<Record<string, unknown>>
|
||||
>
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setFieldErrors(undefined);
|
||||
setEditItem(selectedItem);
|
||||
// Set the flags based on type when page is loaded:
|
||||
// 0-127 is day schedule
|
||||
// 128 is timer
|
||||
// 129 is on change
|
||||
// 130 is on condition
|
||||
// 132 is immediate
|
||||
setScheduleType(
|
||||
selectedItem.flags <= SCHEDULE_TYPE_THRESHOLD
|
||||
? ScheduleFlag.SCHEDULE_DAY
|
||||
@@ -112,129 +111,89 @@ const SchedulerDialog = ({
|
||||
}
|
||||
}, [open, selectedItem]);
|
||||
|
||||
// Helper function to handle save operations
|
||||
const handleSave = useCallback(
|
||||
async (itemToSave: ScheduleItem) => {
|
||||
try {
|
||||
setFieldErrors(undefined);
|
||||
await validate(validator, itemToSave);
|
||||
onSave(itemToSave);
|
||||
} catch (error) {
|
||||
setFieldErrors(error as ValidateFieldsError);
|
||||
}
|
||||
},
|
||||
[validator, onSave]
|
||||
);
|
||||
|
||||
const save = 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 ? FLAG_ALL_DAYS : flag;
|
||||
setEditItem((prev) => ({ ...prev, time: '', flags: newFlags }));
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleDOWChange = useCallback(
|
||||
(_event: React.SyntheticEvent<HTMLElement>, flags: string[]) => {
|
||||
const newFlags =
|
||||
getFlagDOWnumber(flags) === 0 ? FLAG_ALL_DAYS : getFlagDOWnumber(flags);
|
||||
setEditItem((prev) => ({ ...prev, flags: newFlags }));
|
||||
},
|
||||
[getFlagDOWnumber]
|
||||
);
|
||||
|
||||
// Memoize derived values
|
||||
const isDaySchedule = useMemo(
|
||||
() => scheduleType === ScheduleFlag.SCHEDULE_DAY,
|
||||
[scheduleType]
|
||||
);
|
||||
const isTimerSchedule = useMemo(
|
||||
() => scheduleType === ScheduleFlag.SCHEDULE_TIMER,
|
||||
[scheduleType]
|
||||
);
|
||||
const isImmediateSchedule = useMemo(
|
||||
() => scheduleType === ScheduleFlag.SCHEDULE_IMMEDIATE,
|
||||
[scheduleType]
|
||||
);
|
||||
const needsTimeField = useMemo(
|
||||
() => isDaySchedule || isTimerSchedule,
|
||||
[isDaySchedule, isTimerSchedule]
|
||||
);
|
||||
|
||||
const dowFlags = useMemo(
|
||||
() => getFlagDOWstring(editItem.flags),
|
||||
[editItem.flags, getFlagDOWstring]
|
||||
);
|
||||
|
||||
const timeFieldValue = useMemo(() => {
|
||||
if (needsTimeField) {
|
||||
return editItem.time === '' ? DEFAULT_TIME : editItem.time;
|
||||
const handleSave = async (itemToSave: ScheduleItem) => {
|
||||
try {
|
||||
setFieldErrors(undefined);
|
||||
await validate(validator, itemToSave);
|
||||
onSave(itemToSave);
|
||||
} catch (error) {
|
||||
setFieldErrors((error as ValidationError).fieldErrors);
|
||||
}
|
||||
return editItem.time === DEFAULT_TIME ? '' : editItem.time;
|
||||
}, [editItem.time, needsTimeField]);
|
||||
};
|
||||
|
||||
const timeFieldLabel = useMemo(() => {
|
||||
const save = async () => {
|
||||
await handleSave(editItem);
|
||||
};
|
||||
|
||||
const remove = () => {
|
||||
onSave({ ...editItem, deleted: true });
|
||||
};
|
||||
|
||||
const DayOfWeekButton = (flag: number) => {
|
||||
const dayIndex = Math.log2(flag);
|
||||
const isSelected = (editItem.flags & flag) === flag;
|
||||
return (
|
||||
<Typography
|
||||
sx={{ fontSize: TYPOGRAPHY_FONT_SIZE }}
|
||||
color={isSelected ? 'primary' : 'grey'}
|
||||
>
|
||||
{dow[dayIndex]}
|
||||
</Typography>
|
||||
);
|
||||
};
|
||||
|
||||
const handleClose = (
|
||||
_event: React.SyntheticEvent,
|
||||
reason: 'backdropClick' | 'escapeKeyDown'
|
||||
) => {
|
||||
if (reason !== 'backdropClick') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleScheduleTypeChange = (
|
||||
_event: React.SyntheticEvent<HTMLElement>,
|
||||
flag: ScheduleFlag | null
|
||||
) => {
|
||||
if (flag !== null) {
|
||||
setFieldErrors(undefined); // clear any validation errors
|
||||
setScheduleType(flag);
|
||||
// wipe the time field when changing the schedule type
|
||||
// set the flags based on type
|
||||
const newFlags = flag === ScheduleFlag.SCHEDULE_DAY ? FLAG_ALL_DAYS : flag;
|
||||
setEditItem((prev) => ({ ...prev, time: '', flags: newFlags }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDOWChange = (
|
||||
_event: React.SyntheticEvent<HTMLElement>,
|
||||
flags: string[]
|
||||
) => {
|
||||
const newFlags =
|
||||
getFlagDOWnumber(flags) === 0 ? FLAG_ALL_DAYS : getFlagDOWnumber(flags);
|
||||
setEditItem((prev) => ({ ...prev, flags: newFlags }));
|
||||
};
|
||||
|
||||
const isDaySchedule = scheduleType === ScheduleFlag.SCHEDULE_DAY;
|
||||
const isTimerSchedule = scheduleType === ScheduleFlag.SCHEDULE_TIMER;
|
||||
const needsTimeField = isDaySchedule || isTimerSchedule;
|
||||
|
||||
const dowFlags = getFlagDOWstring(editItem.flags);
|
||||
|
||||
const timeFieldValue = needsTimeField
|
||||
? editItem.time === ''
|
||||
? DEFAULT_TIME
|
||||
: editItem.time
|
||||
: editItem.time === DEFAULT_TIME
|
||||
? ''
|
||||
: editItem.time;
|
||||
|
||||
const timeFieldLabel = (() => {
|
||||
if (scheduleType === ScheduleFlag.SCHEDULE_TIMER) return LL.TIMER(1);
|
||||
if (scheduleType === ScheduleFlag.SCHEDULE_CONDITION) return LL.CONDITION();
|
||||
if (scheduleType === ScheduleFlag.SCHEDULE_ONCHANGE) return LL.ONCHANGE();
|
||||
if (scheduleType === ScheduleFlag.SCHEDULE_IMMEDIATE) return LL.IMMEDIATE();
|
||||
return LL.TIME(1);
|
||||
}, [scheduleType, LL]);
|
||||
})();
|
||||
|
||||
return (
|
||||
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
|
||||
@@ -287,14 +246,6 @@ const SchedulerDialog = ({
|
||||
{LL.CONDITION()}
|
||||
</Typography>
|
||||
</ToggleButton>
|
||||
<ToggleButton value={ScheduleFlag.SCHEDULE_IMMEDIATE}>
|
||||
<Typography
|
||||
sx={{ fontSize: TYPOGRAPHY_FONT_SIZE }}
|
||||
color={isImmediateSchedule ? 'primary' : 'grey'}
|
||||
>
|
||||
{LL.IMMEDIATE()}
|
||||
</Typography>
|
||||
</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
|
||||
{isDaySchedule && (
|
||||
@@ -312,76 +263,74 @@ const SchedulerDialog = ({
|
||||
</ToggleButtonGroup>
|
||||
)}
|
||||
|
||||
{!isImmediateSchedule && (
|
||||
<>
|
||||
<Grid container>
|
||||
<BlockFormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={editItem.active}
|
||||
onChange={updateFormValue}
|
||||
name="active"
|
||||
/>
|
||||
}
|
||||
label={LL.ACTIVE()}
|
||||
<Grid container>
|
||||
<BlockFormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={editItem.active}
|
||||
onChange={updateFormValue}
|
||||
name="active"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid container>
|
||||
{needsTimeField ? (
|
||||
<>
|
||||
<TextField
|
||||
name="time"
|
||||
type="time"
|
||||
label={timeFieldLabel}
|
||||
value={timeFieldValue}
|
||||
margin="normal"
|
||||
onChange={updateFormValue}
|
||||
/>
|
||||
{isTimerSchedule && (
|
||||
<Box color="warning.main" ml={2} mt={4}>
|
||||
<Typography variant="body2">
|
||||
{LL.SCHEDULER_HELP_2()}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<TextField
|
||||
name="time"
|
||||
label={timeFieldLabel}
|
||||
multiline
|
||||
fullWidth
|
||||
value={timeFieldValue}
|
||||
margin="normal"
|
||||
onChange={updateFormValue}
|
||||
/>
|
||||
}
|
||||
label={LL.ACTIVE()}
|
||||
/>
|
||||
<CircleIcon
|
||||
color={editItem.active ? 'success' : 'error'}
|
||||
sx={{
|
||||
fontSize: 16,
|
||||
mt: '12px'
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid container>
|
||||
{needsTimeField ? (
|
||||
<>
|
||||
<TextField
|
||||
name="time"
|
||||
type="time"
|
||||
label={timeFieldLabel}
|
||||
value={timeFieldValue}
|
||||
margin="normal"
|
||||
onChange={updateFormValue}
|
||||
/>
|
||||
{isTimerSchedule && (
|
||||
<Typography sx={{ ml: 2, mt: 4 }} color="warning" variant="body2">
|
||||
00:00 = {LL.SCHEDULER_HELP_2()}
|
||||
</Typography>
|
||||
)}
|
||||
</Grid>
|
||||
</>
|
||||
)}
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
name="cmd"
|
||||
label={LL.COMMAND(0)}
|
||||
multiline
|
||||
fullWidth
|
||||
value={editItem.cmd}
|
||||
margin="normal"
|
||||
onChange={updateFormValue}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<TextField
|
||||
name="time"
|
||||
label={timeFieldLabel}
|
||||
multiline
|
||||
fullWidth
|
||||
value={timeFieldValue}
|
||||
margin="normal"
|
||||
onChange={updateFormValue}
|
||||
/>
|
||||
)}
|
||||
</Grid>
|
||||
<TextField
|
||||
name="value"
|
||||
label={LL.VALUE(0)}
|
||||
multiline
|
||||
margin="normal"
|
||||
name="cmd_name"
|
||||
label={LL.COMMAND(0)}
|
||||
value={editItem.cmd_name}
|
||||
fullWidth
|
||||
value={editItem.value}
|
||||
select
|
||||
margin="normal"
|
||||
onChange={updateFormValue}
|
||||
/>
|
||||
>
|
||||
{commandNames.map((name) => (
|
||||
<MenuItem key={name} value={name}>
|
||||
{name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
name="name"
|
||||
label={LL.NAME(0) + ' (' + LL.OPTIONAL() + ')'}
|
||||
label={LL.NAME(0)}
|
||||
value={editItem.name}
|
||||
fullWidth
|
||||
margin="normal"
|
||||
@@ -391,7 +340,7 @@ const SchedulerDialog = ({
|
||||
|
||||
<DialogActions>
|
||||
{!creating && (
|
||||
<Box flexGrow={1}>
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Button
|
||||
startIcon={<RemoveIcon />}
|
||||
variant="outlined"
|
||||
@@ -418,16 +367,6 @@ const SchedulerDialog = ({
|
||||
>
|
||||
{creating ? LL.ADD(0) : LL.UPDATE()}
|
||||
</Button>
|
||||
{isImmediateSchedule && editItem.cmd !== '' && (
|
||||
<Button
|
||||
startIcon={<PlayArrowIcon />}
|
||||
variant="outlined"
|
||||
onClick={saveandactivate}
|
||||
color="success"
|
||||
>
|
||||
{LL.EXECUTE()}
|
||||
</Button>
|
||||
)}
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
+255
-292
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useContext, useMemo, useRef, useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useContext, useRef, useState } from 'react';
|
||||
|
||||
import AddCircleOutlineOutlinedIcon from '@mui/icons-material/AddCircleOutlineOutlined';
|
||||
import KeyboardArrowDownOutlinedIcon from '@mui/icons-material/KeyboardArrowDownOutlined';
|
||||
@@ -21,6 +20,7 @@ import { useTheme } from '@table-library/react-table-library/theme';
|
||||
import type { State } from '@table-library/react-table-library/types/common';
|
||||
import { useRequest } from 'alova/client';
|
||||
import { SectionContent, useLayoutTitle } from 'components';
|
||||
import { toast } from 'components/toast';
|
||||
import { AuthenticatedContext } from 'contexts/authentication';
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
import { useInterval } from 'utils';
|
||||
@@ -158,18 +158,16 @@ const Sensors = () => {
|
||||
}
|
||||
);
|
||||
|
||||
const intervalCallback = useCallback(() => {
|
||||
useInterval(() => {
|
||||
if (!temperatureDialogOpen && !analogDialogOpen) {
|
||||
void fetchSensorData();
|
||||
}
|
||||
}, [temperatureDialogOpen, analogDialogOpen, fetchSensorData]);
|
||||
|
||||
useInterval(intervalCallback);
|
||||
});
|
||||
|
||||
const temperature_theme = useTheme([common_theme, temperature_theme_config]);
|
||||
const analog_theme = useTheme([common_theme, analog_theme_config]);
|
||||
|
||||
const getSortIcon = useCallback((state: State, sortKey: unknown) => {
|
||||
const getSortIcon = (state: State, sortKey: unknown) => {
|
||||
if (state.sortKey === sortKey && state.reverse) {
|
||||
return <KeyboardArrowDownOutlinedIcon />;
|
||||
}
|
||||
@@ -177,7 +175,7 @@ const Sensors = () => {
|
||||
return <KeyboardArrowUpOutlinedIcon />;
|
||||
}
|
||||
return <UnfoldMoreOutlinedIcon />;
|
||||
}, []);
|
||||
};
|
||||
|
||||
const analog_sort = useSort(
|
||||
{ nodes: sensorData.as },
|
||||
@@ -234,121 +232,106 @@ const Sensors = () => {
|
||||
|
||||
useLayoutTitle(LL.SENSORS());
|
||||
|
||||
const formatDurationMin = useCallback(
|
||||
(duration_min: number) => {
|
||||
const totalMs = duration_min * MS_PER_MINUTE;
|
||||
const days = Math.trunc(totalMs / MS_PER_DAY);
|
||||
const hours = Math.trunc(totalMs / MS_PER_HOUR) % 24;
|
||||
const minutes = Math.trunc(totalMs / MS_PER_MINUTE) % 60;
|
||||
const formatDurationMin = (duration_min: number) => {
|
||||
const totalMs = duration_min * MS_PER_MINUTE;
|
||||
const days = Math.trunc(totalMs / MS_PER_DAY);
|
||||
const hours = Math.trunc(totalMs / MS_PER_HOUR) % 24;
|
||||
const minutes = Math.trunc(totalMs / MS_PER_MINUTE) % 60;
|
||||
|
||||
const parts: string[] = [];
|
||||
if (days > 0) {
|
||||
parts.push(LL.NUM_DAYS({ num: days }));
|
||||
}
|
||||
if (hours > 0) {
|
||||
parts.push(LL.NUM_HOURS({ num: hours }));
|
||||
}
|
||||
if (minutes > 0) {
|
||||
parts.push(LL.NUM_MINUTES({ num: minutes }));
|
||||
}
|
||||
return parts.join(' ');
|
||||
},
|
||||
[LL]
|
||||
);
|
||||
const parts: string[] = [];
|
||||
if (days > 0) {
|
||||
parts.push(LL.NUM_DAYS({ num: days }));
|
||||
}
|
||||
if (hours > 0) {
|
||||
parts.push(LL.NUM_HOURS({ num: hours }));
|
||||
}
|
||||
if (minutes > 0) {
|
||||
parts.push(LL.NUM_MINUTES({ num: minutes }));
|
||||
}
|
||||
return parts.join(' ');
|
||||
};
|
||||
|
||||
const formatValue = useCallback(
|
||||
(value: unknown, uom: DeviceValueUOM) => {
|
||||
if (value === undefined) {
|
||||
return '';
|
||||
}
|
||||
if (typeof value !== 'number') {
|
||||
return value as string;
|
||||
}
|
||||
switch (uom) {
|
||||
case DeviceValueUOM.HOURS:
|
||||
return value ? formatDurationMin(value * 60) : LL.NUM_HOURS({ num: 0 });
|
||||
case DeviceValueUOM.MINUTES:
|
||||
return value ? formatDurationMin(value) : LL.NUM_MINUTES({ num: 0 });
|
||||
case DeviceValueUOM.SECONDS:
|
||||
return LL.NUM_SECONDS({ num: value });
|
||||
case DeviceValueUOM.NONE:
|
||||
return new Intl.NumberFormat().format(value);
|
||||
case DeviceValueUOM.DEGREES:
|
||||
case DeviceValueUOM.DEGREES_R:
|
||||
case DeviceValueUOM.FAHRENHEIT:
|
||||
return (
|
||||
new Intl.NumberFormat(undefined, {
|
||||
minimumFractionDigits: 1
|
||||
}).format(value) +
|
||||
' ' +
|
||||
DeviceValueUOM_s[uom]
|
||||
);
|
||||
default:
|
||||
return new Intl.NumberFormat().format(value) + ' ' + DeviceValueUOM_s[uom];
|
||||
}
|
||||
},
|
||||
[formatDurationMin, LL]
|
||||
);
|
||||
const formatValue = (value: unknown, uom: DeviceValueUOM) => {
|
||||
if (value === undefined) {
|
||||
return '';
|
||||
}
|
||||
if (typeof value !== 'number') {
|
||||
return value as string;
|
||||
}
|
||||
switch (uom) {
|
||||
case DeviceValueUOM.HOURS:
|
||||
return value ? formatDurationMin(value * 60) : LL.NUM_HOURS({ num: 0 });
|
||||
case DeviceValueUOM.MINUTES:
|
||||
return value ? formatDurationMin(value) : LL.NUM_MINUTES({ num: 0 });
|
||||
case DeviceValueUOM.SECONDS:
|
||||
return LL.NUM_SECONDS({ num: value });
|
||||
case DeviceValueUOM.NONE:
|
||||
return new Intl.NumberFormat().format(value);
|
||||
case DeviceValueUOM.DEGREES:
|
||||
case DeviceValueUOM.DEGREES_R:
|
||||
case DeviceValueUOM.FAHRENHEIT:
|
||||
return (
|
||||
new Intl.NumberFormat(undefined, {
|
||||
minimumFractionDigits: 1
|
||||
}).format(value) +
|
||||
' ' +
|
||||
DeviceValueUOM_s[uom]
|
||||
);
|
||||
default:
|
||||
return new Intl.NumberFormat().format(value) + ' ' + DeviceValueUOM_s[uom];
|
||||
}
|
||||
};
|
||||
|
||||
const updateTemperatureSensor = useCallback(
|
||||
(ts: TemperatureSensor) => {
|
||||
if (me.admin) {
|
||||
ts.o_n = ts.n;
|
||||
setSelectedTemperatureSensor(ts);
|
||||
setTemperatureDialogOpen(true);
|
||||
}
|
||||
},
|
||||
[me.admin]
|
||||
);
|
||||
const updateTemperatureSensor = (ts: TemperatureSensor) => {
|
||||
if (me.admin) {
|
||||
ts.o_n = ts.n;
|
||||
setSelectedTemperatureSensor(ts);
|
||||
setTemperatureDialogOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const onTemperatureDialogClose = useCallback(() => {
|
||||
const onTemperatureDialogClose = () => {
|
||||
setTemperatureDialogOpen(false);
|
||||
void fetchSensorData();
|
||||
}, [fetchSensorData]);
|
||||
};
|
||||
|
||||
const onTemperatureDialogSave = useCallback(
|
||||
async (ts: TemperatureSensor) => {
|
||||
await sendTemperatureSensor({
|
||||
id: ts.id,
|
||||
name: ts.n,
|
||||
offset: ts.o,
|
||||
is_system: ts.s
|
||||
const onTemperatureDialogSave = async (ts: TemperatureSensor) => {
|
||||
await sendTemperatureSensor({
|
||||
id: ts.id,
|
||||
name: ts.n,
|
||||
offset: ts.o,
|
||||
is_system: ts.s
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(LL.UPDATED_OF(LL.SENSOR(1)));
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(LL.UPDATED_OF(LL.SENSOR(1)));
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(LL.UPDATE_OF(LL.SENSOR(2)) + ' ' + LL.FAILED(1));
|
||||
})
|
||||
.finally(() => {
|
||||
setTemperatureDialogOpen(false);
|
||||
setSelectedTemperatureSensor(undefined);
|
||||
void fetchSensorData();
|
||||
});
|
||||
},
|
||||
[sendTemperatureSensor, LL, fetchSensorData]
|
||||
);
|
||||
.catch(() => {
|
||||
toast.error(LL.UPDATE_OF(LL.SENSOR(2)) + ' ' + LL.FAILED(1));
|
||||
})
|
||||
.finally(() => {
|
||||
setTemperatureDialogOpen(false);
|
||||
setSelectedTemperatureSensor(undefined);
|
||||
void fetchSensorData();
|
||||
});
|
||||
};
|
||||
|
||||
const updateAnalogSensor = useCallback(
|
||||
(as: AnalogSensor) => {
|
||||
if (me.admin) {
|
||||
setCreating(false);
|
||||
as.o_n = as.n;
|
||||
setSelectedAnalogSensor(as);
|
||||
setAnalogDialogOpen(true);
|
||||
}
|
||||
},
|
||||
[me.admin]
|
||||
);
|
||||
const updateAnalogSensor = (as: AnalogSensor) => {
|
||||
if (me.admin) {
|
||||
setCreating(false);
|
||||
as.o_n = as.n;
|
||||
setSelectedAnalogSensor(as);
|
||||
setAnalogDialogOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const onAnalogDialogClose = useCallback(() => {
|
||||
const onAnalogDialogClose = () => {
|
||||
setAnalogDialogOpen(false);
|
||||
void fetchSensorData();
|
||||
}, [fetchSensorData]);
|
||||
};
|
||||
|
||||
const addAnalogSensor = useCallback(() => {
|
||||
const addAnalogSensor = () => {
|
||||
if (firstAvailableGPIO.current === undefined) {
|
||||
toast.error('No available GPIO found');
|
||||
toast.error(LL.NO_GPIO());
|
||||
return;
|
||||
}
|
||||
setCreating(true);
|
||||
@@ -366,194 +349,167 @@ const Sensors = () => {
|
||||
o_n: ''
|
||||
});
|
||||
setAnalogDialogOpen(true);
|
||||
}, []);
|
||||
};
|
||||
|
||||
const onAnalogDialogSave = useCallback(
|
||||
async (as: AnalogSensor) => {
|
||||
await sendAnalogSensor({
|
||||
id: as.id,
|
||||
gpio: as.g,
|
||||
name: as.n,
|
||||
offset: as.o,
|
||||
factor: as.f,
|
||||
uom: as.u,
|
||||
type: as.t,
|
||||
deleted: as.d,
|
||||
is_system: as.s
|
||||
const onAnalogDialogSave = async (as: AnalogSensor) => {
|
||||
await sendAnalogSensor({
|
||||
id: as.id,
|
||||
gpio: as.g,
|
||||
name: as.n,
|
||||
offset: as.o,
|
||||
factor: as.f,
|
||||
uom: as.u,
|
||||
type: as.t,
|
||||
deleted: as.d,
|
||||
is_system: as.s
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(LL.UPDATED_OF(LL.ANALOG_SENSOR(2)));
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(LL.UPDATED_OF(LL.ANALOG_SENSOR(2)));
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(LL.UPDATE_OF(LL.ANALOG_SENSOR(5)) + ' ' + LL.FAILED(1));
|
||||
})
|
||||
.finally(() => {
|
||||
setAnalogDialogOpen(false);
|
||||
setSelectedAnalogSensor(undefined);
|
||||
void fetchSensorData();
|
||||
});
|
||||
},
|
||||
[sendAnalogSensor, LL, fetchSensorData]
|
||||
.catch(() => {
|
||||
toast.error(LL.UPDATE_OF(LL.ANALOG_SENSOR(5)) + ' ' + LL.FAILED(1));
|
||||
})
|
||||
.finally(() => {
|
||||
setAnalogDialogOpen(false);
|
||||
setSelectedAnalogSensor(undefined);
|
||||
void fetchSensorData();
|
||||
});
|
||||
};
|
||||
|
||||
const RenderAnalogSensors = (
|
||||
<Table
|
||||
data={{ nodes: sensorData.as }}
|
||||
theme={analog_theme}
|
||||
sort={analog_sort}
|
||||
layout={{ custom: true }}
|
||||
>
|
||||
{(tableList: AnalogSensor[]) => (
|
||||
<>
|
||||
<Header>
|
||||
<HeaderRow>
|
||||
<HeaderCell stiff>
|
||||
<Button
|
||||
fullWidth
|
||||
style={HEADER_BUTTON_STYLE}
|
||||
endIcon={getSortIcon(analog_sort.state, 'GPIO')}
|
||||
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'GPIO' })}
|
||||
>
|
||||
GPIO
|
||||
</Button>
|
||||
</HeaderCell>
|
||||
<HeaderCell resize>
|
||||
<Button
|
||||
fullWidth
|
||||
style={HEADER_BUTTON_STYLE}
|
||||
endIcon={getSortIcon(analog_sort.state, 'NAME')}
|
||||
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'NAME' })}
|
||||
>
|
||||
{LL.NAME(0)}
|
||||
</Button>
|
||||
</HeaderCell>
|
||||
<HeaderCell stiff>
|
||||
<Button
|
||||
fullWidth
|
||||
style={HEADER_BUTTON_STYLE}
|
||||
endIcon={getSortIcon(analog_sort.state, 'TYPE')}
|
||||
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'TYPE' })}
|
||||
>
|
||||
{LL.TYPE(0)}
|
||||
</Button>
|
||||
</HeaderCell>
|
||||
<HeaderCell stiff>
|
||||
<Button
|
||||
fullWidth
|
||||
style={HEADER_BUTTON_STYLE_END}
|
||||
endIcon={getSortIcon(analog_sort.state, 'VALUE')}
|
||||
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'VALUE' })}
|
||||
>
|
||||
{LL.VALUE(0)}
|
||||
</Button>
|
||||
</HeaderCell>
|
||||
</HeaderRow>
|
||||
</Header>
|
||||
<Body>
|
||||
{tableList.map((as: AnalogSensor) => (
|
||||
<Row
|
||||
style={{ color: as.s ? 'grey' : 'inherit' }}
|
||||
key={as.id}
|
||||
item={as}
|
||||
onClick={() => updateAnalogSensor(as)}
|
||||
>
|
||||
<Cell stiff>{as.g}</Cell>
|
||||
<Cell>{as.n}</Cell>
|
||||
<Cell stiff>{AnalogTypeNames[as.t - 1]} </Cell>
|
||||
{(as.t === AnalogType.DIGITAL_OUT &&
|
||||
as.g !== GPIO_25 &&
|
||||
as.g !== GPIO_26) ||
|
||||
as.t === AnalogType.DIGITAL_IN ||
|
||||
as.t === AnalogType.PULSE ? (
|
||||
<Cell stiff>{as.v ? LL.ON() : LL.OFF()}</Cell>
|
||||
) : (
|
||||
<Cell stiff>{formatValue(as.v, as.u)}</Cell>
|
||||
)}
|
||||
</Row>
|
||||
))}
|
||||
</Body>
|
||||
</>
|
||||
)}
|
||||
</Table>
|
||||
);
|
||||
|
||||
const RenderAnalogSensors = useMemo(
|
||||
() => (
|
||||
<Table
|
||||
data={{ nodes: sensorData.as }}
|
||||
theme={analog_theme}
|
||||
sort={analog_sort}
|
||||
layout={{ custom: true }}
|
||||
>
|
||||
{(tableList: AnalogSensor[]) => (
|
||||
<>
|
||||
<Header>
|
||||
<HeaderRow>
|
||||
<HeaderCell stiff>
|
||||
<Button
|
||||
fullWidth
|
||||
style={HEADER_BUTTON_STYLE}
|
||||
endIcon={getSortIcon(analog_sort.state, 'GPIO')}
|
||||
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'GPIO' })}
|
||||
>
|
||||
GPIO
|
||||
</Button>
|
||||
</HeaderCell>
|
||||
<HeaderCell resize>
|
||||
<Button
|
||||
fullWidth
|
||||
style={HEADER_BUTTON_STYLE}
|
||||
endIcon={getSortIcon(analog_sort.state, 'NAME')}
|
||||
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'NAME' })}
|
||||
>
|
||||
{LL.NAME(0)}
|
||||
</Button>
|
||||
</HeaderCell>
|
||||
<HeaderCell stiff>
|
||||
<Button
|
||||
fullWidth
|
||||
style={HEADER_BUTTON_STYLE}
|
||||
endIcon={getSortIcon(analog_sort.state, 'TYPE')}
|
||||
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'TYPE' })}
|
||||
>
|
||||
{LL.TYPE(0)}
|
||||
</Button>
|
||||
</HeaderCell>
|
||||
<HeaderCell stiff>
|
||||
<Button
|
||||
fullWidth
|
||||
style={HEADER_BUTTON_STYLE_END}
|
||||
endIcon={getSortIcon(analog_sort.state, 'VALUE')}
|
||||
onClick={() =>
|
||||
analog_sort.fns.onToggleSort({ sortKey: 'VALUE' })
|
||||
}
|
||||
>
|
||||
{LL.VALUE(0)}
|
||||
</Button>
|
||||
</HeaderCell>
|
||||
</HeaderRow>
|
||||
</Header>
|
||||
<Body>
|
||||
{tableList.map((as: AnalogSensor) => (
|
||||
<Row
|
||||
style={{ color: as.s ? 'grey' : 'inherit' }}
|
||||
key={as.id}
|
||||
item={as}
|
||||
onClick={() => updateAnalogSensor(as)}
|
||||
const RenderTemperatureSensors = (
|
||||
<Table
|
||||
data={{ nodes: sensorData.ts }}
|
||||
theme={temperature_theme}
|
||||
sort={temperature_sort}
|
||||
layout={{ custom: true }}
|
||||
>
|
||||
{(tableList: TemperatureSensor[]) => (
|
||||
<>
|
||||
<Header>
|
||||
<HeaderRow>
|
||||
<HeaderCell resize>
|
||||
<Button
|
||||
fullWidth
|
||||
style={HEADER_BUTTON_STYLE}
|
||||
endIcon={getSortIcon(temperature_sort.state, 'NAME')}
|
||||
onClick={() =>
|
||||
temperature_sort.fns.onToggleSort({ sortKey: 'NAME' })
|
||||
}
|
||||
>
|
||||
<Cell stiff>{as.g}</Cell>
|
||||
<Cell>{as.n}</Cell>
|
||||
<Cell stiff>{AnalogTypeNames[as.t - 1]} </Cell>
|
||||
{(as.t === AnalogType.DIGITAL_OUT &&
|
||||
as.g !== GPIO_25 &&
|
||||
as.g !== GPIO_26) ||
|
||||
as.t === AnalogType.DIGITAL_IN ||
|
||||
as.t === AnalogType.PULSE ? (
|
||||
<Cell stiff>{as.v ? LL.ON() : LL.OFF()}</Cell>
|
||||
) : (
|
||||
<Cell stiff>{formatValue(as.v, as.u)}</Cell>
|
||||
)}
|
||||
</Row>
|
||||
))}
|
||||
</Body>
|
||||
</>
|
||||
)}
|
||||
</Table>
|
||||
),
|
||||
[
|
||||
analog_sort,
|
||||
analog_theme,
|
||||
getSortIcon,
|
||||
sensorData.as,
|
||||
LL,
|
||||
updateAnalogSensor,
|
||||
formatValue
|
||||
]
|
||||
);
|
||||
|
||||
const RenderTemperatureSensors = useMemo(
|
||||
() => (
|
||||
<Table
|
||||
data={{ nodes: sensorData.ts }}
|
||||
theme={temperature_theme}
|
||||
sort={temperature_sort}
|
||||
layout={{ custom: true }}
|
||||
>
|
||||
{(tableList: TemperatureSensor[]) => (
|
||||
<>
|
||||
<Header>
|
||||
<HeaderRow>
|
||||
<HeaderCell resize>
|
||||
<Button
|
||||
fullWidth
|
||||
style={HEADER_BUTTON_STYLE}
|
||||
endIcon={getSortIcon(temperature_sort.state, 'NAME')}
|
||||
onClick={() =>
|
||||
temperature_sort.fns.onToggleSort({ sortKey: 'NAME' })
|
||||
}
|
||||
>
|
||||
{LL.NAME(0)}
|
||||
</Button>
|
||||
</HeaderCell>
|
||||
<HeaderCell stiff>
|
||||
<Button
|
||||
fullWidth
|
||||
style={HEADER_BUTTON_STYLE_END}
|
||||
endIcon={getSortIcon(temperature_sort.state, 'VALUE')}
|
||||
onClick={() =>
|
||||
temperature_sort.fns.onToggleSort({ sortKey: 'VALUE' })
|
||||
}
|
||||
>
|
||||
{LL.VALUE(0)}
|
||||
</Button>
|
||||
</HeaderCell>
|
||||
</HeaderRow>
|
||||
</Header>
|
||||
<Body>
|
||||
{tableList.map((ts: TemperatureSensor) => (
|
||||
<Row
|
||||
style={{ color: ts.s ? 'grey' : 'inherit' }}
|
||||
key={ts.id}
|
||||
item={ts}
|
||||
onClick={() => updateTemperatureSensor(ts)}
|
||||
{LL.NAME(0)}
|
||||
</Button>
|
||||
</HeaderCell>
|
||||
<HeaderCell stiff>
|
||||
<Button
|
||||
fullWidth
|
||||
style={HEADER_BUTTON_STYLE_END}
|
||||
endIcon={getSortIcon(temperature_sort.state, 'VALUE')}
|
||||
onClick={() =>
|
||||
temperature_sort.fns.onToggleSort({ sortKey: 'VALUE' })
|
||||
}
|
||||
>
|
||||
<Cell>{ts.n}</Cell>
|
||||
<Cell>{formatValue(ts.t, ts.u)}</Cell>
|
||||
</Row>
|
||||
))}
|
||||
</Body>
|
||||
</>
|
||||
)}
|
||||
</Table>
|
||||
),
|
||||
[
|
||||
temperature_sort,
|
||||
temperature_theme,
|
||||
getSortIcon,
|
||||
sensorData.ts,
|
||||
LL,
|
||||
updateTemperatureSensor,
|
||||
formatValue
|
||||
]
|
||||
{LL.VALUE(0)}
|
||||
</Button>
|
||||
</HeaderCell>
|
||||
</HeaderRow>
|
||||
</Header>
|
||||
<Body>
|
||||
{tableList.map((ts: TemperatureSensor) => (
|
||||
<Row
|
||||
style={{ color: ts.s ? 'grey' : 'inherit' }}
|
||||
key={ts.id}
|
||||
item={ts}
|
||||
onClick={() => updateTemperatureSensor(ts)}
|
||||
>
|
||||
<Cell>{ts.n}</Cell>
|
||||
<Cell>{formatValue(ts.t, ts.u)}</Cell>
|
||||
</Row>
|
||||
))}
|
||||
</Body>
|
||||
</>
|
||||
)}
|
||||
</Table>
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -591,7 +547,14 @@ const Sensors = () => {
|
||||
/>
|
||||
)}
|
||||
{sensorData?.analog_enabled === true && me.admin && (
|
||||
<Box mt={2} display="flex" flexWrap="wrap" justifyContent="flex-end">
|
||||
<Box
|
||||
sx={{
|
||||
mt: 2,
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'flex-end'
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import DoneIcon from '@mui/icons-material/Done';
|
||||
import RemoveIcon from '@mui/icons-material/RemoveCircleOutline';
|
||||
import RemoveIcon from '@mui/icons-material/RemoveCircleOutlined';
|
||||
import WarningIcon from '@mui/icons-material/Warning';
|
||||
import {
|
||||
Box,
|
||||
@@ -18,12 +18,12 @@ import {
|
||||
} from '@mui/material';
|
||||
|
||||
import { dialogStyle } from 'CustomTheme';
|
||||
import type Schema from 'async-validator';
|
||||
import type { ValidateFieldsError } from 'async-validator';
|
||||
import { ValidatedTextField } from 'components';
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
import { numberValue, updateValue } from 'utils';
|
||||
import { validate } from 'validators';
|
||||
import { ValidationError, validate } from 'validators';
|
||||
import type Schema from 'validators/schema';
|
||||
import type { ValidateFieldsError } from 'validators/schema';
|
||||
|
||||
import { AnalogType, AnalogTypeNames, DeviceValueUOM_s } from './types';
|
||||
import type { AnalogSensor } from './types';
|
||||
@@ -53,84 +53,54 @@ const SensorsAnalogDialog = ({
|
||||
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
||||
const [editItem, setEditItem] = useState<AnalogSensor>(selectedItem);
|
||||
|
||||
const updateFormValue = useMemo(
|
||||
() =>
|
||||
updateValue((updater) =>
|
||||
setEditItem(
|
||||
(prev) =>
|
||||
updater(
|
||||
prev as unknown as Record<string, unknown>
|
||||
) as unknown as AnalogSensor
|
||||
)
|
||||
),
|
||||
[setEditItem]
|
||||
const updateFormValue = updateValue((updater) =>
|
||||
setEditItem(
|
||||
(prev) =>
|
||||
updater(
|
||||
prev as unknown as Record<string, unknown>
|
||||
) as unknown as AnalogSensor
|
||||
)
|
||||
);
|
||||
|
||||
// Memoize helper functions to check sensor type conditions
|
||||
const isCounterOrRate = useMemo(
|
||||
() =>
|
||||
editItem.t === AnalogType.COUNTER ||
|
||||
editItem.t === AnalogType.RATE ||
|
||||
(editItem.t >= AnalogType.CNT_0 && editItem.t <= AnalogType.CNT_2),
|
||||
[editItem.t]
|
||||
);
|
||||
const isCounter = useMemo(
|
||||
() =>
|
||||
editItem.t === AnalogType.COUNTER ||
|
||||
(editItem.t >= AnalogType.CNT_0 && editItem.t <= AnalogType.CNT_2),
|
||||
[editItem.t]
|
||||
);
|
||||
const isFreqType = useMemo(
|
||||
() => editItem.t >= AnalogType.FREQ_0 && editItem.t <= AnalogType.FREQ_2,
|
||||
[editItem.t]
|
||||
);
|
||||
const isPWM = useMemo(
|
||||
() =>
|
||||
editItem.t === AnalogType.PWM_0 ||
|
||||
editItem.t === AnalogType.PWM_1 ||
|
||||
editItem.t === AnalogType.PWM_2,
|
||||
[editItem.t]
|
||||
);
|
||||
const isDACOutGPIO = useMemo(
|
||||
() =>
|
||||
editItem.t === AnalogType.DIGITAL_OUT &&
|
||||
(editItem.g === 25 || editItem.g === 26),
|
||||
[editItem.t, editItem.g]
|
||||
);
|
||||
const isDigitalOutGPIO = useMemo(
|
||||
() =>
|
||||
editItem.t === AnalogType.DIGITAL_OUT &&
|
||||
editItem.g !== 25 &&
|
||||
editItem.g !== 26,
|
||||
[editItem.t, editItem.g]
|
||||
);
|
||||
const isCounterOrRate =
|
||||
editItem.t === AnalogType.COUNTER ||
|
||||
editItem.t === AnalogType.RATE ||
|
||||
(editItem.t >= AnalogType.CNT_0 && editItem.t <= AnalogType.CNT_2);
|
||||
const isCounter =
|
||||
editItem.t === AnalogType.COUNTER ||
|
||||
(editItem.t >= AnalogType.CNT_0 && editItem.t <= AnalogType.CNT_2);
|
||||
const isFreqType =
|
||||
editItem.t >= AnalogType.FREQ_0 && editItem.t <= AnalogType.FREQ_2;
|
||||
const isPWM =
|
||||
editItem.t === AnalogType.PWM_0 ||
|
||||
editItem.t === AnalogType.PWM_1 ||
|
||||
editItem.t === AnalogType.PWM_2;
|
||||
const isDACOutGPIO =
|
||||
editItem.t === AnalogType.DIGITAL_OUT &&
|
||||
(editItem.g === 25 || editItem.g === 26);
|
||||
const isDigitalOutGPIO =
|
||||
editItem.t === AnalogType.DIGITAL_OUT && editItem.g !== 25 && editItem.g !== 26;
|
||||
|
||||
// Memoize 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 analogTypeMenuItems = AnalogTypeNames.map((val, i) => ({
|
||||
name: val,
|
||||
value: i + 1
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map(({ name, value }) => (
|
||||
<MenuItem
|
||||
key={name}
|
||||
value={value}
|
||||
disabled={disabledTypeList?.includes(value)}
|
||||
>
|
||||
{name}
|
||||
</MenuItem>
|
||||
));
|
||||
|
||||
const uomMenuItems = useMemo(
|
||||
() =>
|
||||
DeviceValueUOM_s.map((val, i) => (
|
||||
<MenuItem key={val} value={i}>
|
||||
{val}
|
||||
</MenuItem>
|
||||
)),
|
||||
[]
|
||||
);
|
||||
const uomMenuItems = DeviceValueUOM_s.map((val, i) => (
|
||||
<MenuItem key={val} value={i}>
|
||||
{val}
|
||||
</MenuItem>
|
||||
));
|
||||
|
||||
const analogGPIOMenuItems = () =>
|
||||
// add selectedItem.g to the list
|
||||
@@ -157,34 +127,30 @@ const SensorsAnalogDialog = ({
|
||||
}
|
||||
}, [open, selectedItem]);
|
||||
|
||||
const handleClose = useCallback(
|
||||
(_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => {
|
||||
if (reason !== 'backdropClick') {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[onClose]
|
||||
);
|
||||
const handleClose = (
|
||||
_event: React.SyntheticEvent,
|
||||
reason: 'backdropClick' | 'escapeKeyDown'
|
||||
) => {
|
||||
if (reason !== 'backdropClick') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const save = useCallback(async () => {
|
||||
const save = async () => {
|
||||
try {
|
||||
setFieldErrors(undefined);
|
||||
await validate(validator, editItem);
|
||||
onSave(editItem);
|
||||
} catch (error) {
|
||||
setFieldErrors(error as ValidateFieldsError);
|
||||
setFieldErrors((error as ValidationError).fieldErrors);
|
||||
}
|
||||
}, [validator, editItem, onSave]);
|
||||
};
|
||||
|
||||
const remove = useCallback(() => {
|
||||
const remove = () => {
|
||||
onSave({ ...editItem, d: true });
|
||||
}, [editItem, onSave]);
|
||||
};
|
||||
|
||||
const dialogTitle = useMemo(
|
||||
() =>
|
||||
`${creating ? LL.ADD(1) + ' ' + LL.NEW(0) : LL.EDIT()} ${LL.ANALOG_SENSOR(0)}`,
|
||||
[creating, LL]
|
||||
);
|
||||
const dialogTitle = `${creating ? LL.ADD(1) + ' ' + LL.NEW(0) : LL.EDIT()} ${LL.ANALOG_SENSOR(0)}`;
|
||||
|
||||
return (
|
||||
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
|
||||
@@ -242,6 +208,23 @@ const SensorsAnalogDialog = ({
|
||||
</ValidatedTextField>
|
||||
</Grid>
|
||||
)}
|
||||
{editItem.t === AnalogType.DIGITAL_IN && (
|
||||
<Grid>
|
||||
<ValidatedTextField
|
||||
name="f"
|
||||
label={LL.POLARITY()}
|
||||
value={editItem.f}
|
||||
sx={{ width: '15ch' }}
|
||||
fullWidth
|
||||
select
|
||||
onChange={updateFormValue}
|
||||
disabled={editItem.s}
|
||||
>
|
||||
<MenuItem value={1}>{LL.ACTIVEHIGH()}</MenuItem>
|
||||
<MenuItem value={0}>{LL.ACTIVELOW()}</MenuItem>
|
||||
</ValidatedTextField>
|
||||
</Grid>
|
||||
)}
|
||||
{editItem.t === AnalogType.ADC && (
|
||||
<Grid>
|
||||
<ValidatedTextField
|
||||
@@ -447,7 +430,7 @@ const SensorsAnalogDialog = ({
|
||||
name="o"
|
||||
label={LL.POLARITY()}
|
||||
value={editItem.o}
|
||||
sx={{ width: '11ch' }}
|
||||
sx={{ width: '15ch' }}
|
||||
select
|
||||
onChange={updateFormValue}
|
||||
disabled={editItem.s}
|
||||
@@ -479,7 +462,7 @@ const SensorsAnalogDialog = ({
|
||||
)}
|
||||
</Grid>
|
||||
{fieldErrors && Object.keys(fieldErrors).length > 0 && (
|
||||
<Box mt={1}>
|
||||
<Box sx={{ mt: 1 }}>
|
||||
{Object.values(fieldErrors).map((errArr, idx) =>
|
||||
Array.isArray(errArr)
|
||||
? errArr.map((err, j) => (
|
||||
@@ -487,7 +470,7 @@ const SensorsAnalogDialog = ({
|
||||
key={`${idx}-${j}`}
|
||||
color="error"
|
||||
variant="caption"
|
||||
display="block"
|
||||
sx={{ display: 'block' }}
|
||||
>
|
||||
{err.message}
|
||||
</Typography>
|
||||
@@ -498,7 +481,7 @@ const SensorsAnalogDialog = ({
|
||||
)}
|
||||
{editItem.s && (
|
||||
<Grid>
|
||||
<Typography mt={1} color="warning.main" variant="body2">
|
||||
<Typography sx={{ mt: 1 }} color="warning" variant="body2">
|
||||
<WarningIcon
|
||||
fontSize="small"
|
||||
sx={{ mr: 1, verticalAlign: 'middle' }}
|
||||
@@ -511,7 +494,7 @@ const SensorsAnalogDialog = ({
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
{!creating && (
|
||||
<Box flexGrow={1} sx={{ '& button': { mt: 0 } }}>
|
||||
<Box sx={{ flexGrow: 1, '& button': { mt: 0 } }}>
|
||||
<Button
|
||||
startIcon={<RemoveIcon />}
|
||||
disabled={editItem.s}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import DoneIcon from '@mui/icons-material/Done';
|
||||
import WarningIcon from '@mui/icons-material/Warning';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
@@ -17,12 +16,12 @@ import {
|
||||
} from '@mui/material';
|
||||
|
||||
import { dialogStyle } from 'CustomTheme';
|
||||
import type Schema from 'async-validator';
|
||||
import type { ValidateFieldsError } from 'async-validator';
|
||||
import { ValidatedTextField } from 'components';
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
import { numberValue, updateValue } from 'utils';
|
||||
import { validate } from 'validators';
|
||||
import { ValidationError, validate } from 'validators';
|
||||
import type Schema from 'validators/schema';
|
||||
import type { ValidateFieldsError } from 'validators/schema';
|
||||
|
||||
import type { TemperatureSensor } from './types';
|
||||
|
||||
@@ -51,16 +50,12 @@ const SensorsTemperatureDialog = ({
|
||||
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
||||
const [editItem, setEditItem] = useState<TemperatureSensor>(selectedItem);
|
||||
|
||||
const updateFormValue = useMemo(
|
||||
() =>
|
||||
updateValue(
|
||||
setEditItem as unknown as (
|
||||
updater: (
|
||||
prevState: Readonly<Record<string, unknown>>
|
||||
) => Record<string, unknown>
|
||||
) => void
|
||||
),
|
||||
[setEditItem]
|
||||
const updateFormValue = updateValue(
|
||||
setEditItem as unknown as (
|
||||
updater: (
|
||||
prevState: Readonly<Record<string, unknown>>
|
||||
) => Record<string, unknown>
|
||||
) => void
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -70,52 +65,29 @@ const SensorsTemperatureDialog = ({
|
||||
}
|
||||
}, [open, selectedItem]);
|
||||
|
||||
const handleClose = useCallback(
|
||||
(_event: React.SyntheticEvent, reason?: string) => {
|
||||
if (reason !== 'backdropClick') {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[onClose]
|
||||
);
|
||||
const handleClose = (_event: React.SyntheticEvent, reason?: string) => {
|
||||
if (reason !== 'backdropClick') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const save = useCallback(async () => {
|
||||
const save = async () => {
|
||||
try {
|
||||
setFieldErrors(undefined);
|
||||
await validate(validator, editItem);
|
||||
onSave(editItem);
|
||||
} catch (error) {
|
||||
setFieldErrors(error as ValidateFieldsError);
|
||||
setFieldErrors((error as ValidationError).fieldErrors);
|
||||
}
|
||||
}, [validator, editItem, onSave]);
|
||||
|
||||
const dialogTitle = useMemo(() => `${LL.EDIT()} ${LL.TEMP_SENSOR()}`, [LL]);
|
||||
|
||||
const offsetValue = useMemo(() => numberValue(editItem.o), [editItem.o]);
|
||||
|
||||
const slotProps = useMemo(
|
||||
() => ({
|
||||
input: {
|
||||
startAdornment: <InputAdornment position="start">{TEMP_UNIT}</InputAdornment>
|
||||
},
|
||||
htmlInput: {
|
||||
min: OFFSET_MIN,
|
||||
max: OFFSET_MAX,
|
||||
step: OFFSET_STEP
|
||||
}
|
||||
}),
|
||||
[]
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
|
||||
<DialogTitle>{dialogTitle}</DialogTitle>
|
||||
<DialogTitle>{`${LL.EDIT()} ${LL.TEMP_SENSOR()}`}</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<Box color="warning.main" mb={2}>
|
||||
<Typography variant="body2">
|
||||
{LL.ID_OF(LL.SENSOR(0))}: {editItem.id}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography sx={{ mb: 2 }} color="warning" variant="body2">
|
||||
{LL.ID_OF(LL.SENSOR(0))}: {editItem.id}
|
||||
</Typography>
|
||||
<Grid container spacing={2}>
|
||||
<Grid>
|
||||
<ValidatedTextField
|
||||
@@ -131,18 +103,29 @@ const SensorsTemperatureDialog = ({
|
||||
<TextField
|
||||
name="o"
|
||||
label={LL.OFFSET()}
|
||||
value={offsetValue}
|
||||
value={numberValue(editItem.o)}
|
||||
sx={{ width: '11ch' }}
|
||||
type="number"
|
||||
variant="outlined"
|
||||
onChange={updateFormValue}
|
||||
slotProps={slotProps}
|
||||
slotProps={{
|
||||
input: {
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">{TEMP_UNIT}</InputAdornment>
|
||||
)
|
||||
},
|
||||
htmlInput: {
|
||||
min: OFFSET_MIN,
|
||||
max: OFFSET_MAX,
|
||||
step: OFFSET_STEP
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
{editItem.s && (
|
||||
<Grid>
|
||||
<Typography mt={1} color="warning.main" variant="body2">
|
||||
<Typography sx={{ mt: 1 }} color="warning" variant="body2">
|
||||
<WarningIcon
|
||||
fontSize="small"
|
||||
sx={{ mr: 1, verticalAlign: 'middle' }}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useCallback, useContext } from 'react';
|
||||
import { memo, useContext } from 'react';
|
||||
|
||||
import PersonIcon from '@mui/icons-material/Person';
|
||||
import {
|
||||
@@ -23,9 +23,9 @@ const UserProfileComponent = () => {
|
||||
|
||||
useLayoutTitle(LL.USER_PROFILE());
|
||||
|
||||
const handleSignOut = useCallback(() => {
|
||||
const handleSignOut = () => {
|
||||
signOut(true);
|
||||
}, [signOut]);
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionContent>
|
||||
@@ -41,8 +41,12 @@ const UserProfileComponent = () => {
|
||||
/>
|
||||
</ListItem>
|
||||
</List>
|
||||
<Box mt={2} mb={2} display="flex" alignItems="center">
|
||||
<Typography mr={2} variant="body1" align="center">
|
||||
<Box sx={{ mt: 2, mb: 2, display: 'flex', alignItems: 'center' }}>
|
||||
<Typography
|
||||
sx={{ mr: 2, textAlign: 'center' }}
|
||||
color="warning"
|
||||
variant="body1"
|
||||
>
|
||||
{LL.LANGUAGE()}:
|
||||
</Typography>
|
||||
<LanguageSelector />
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export interface Settings {
|
||||
system_name: string;
|
||||
locale: string;
|
||||
tx_mode: number;
|
||||
ems_bus_id: number;
|
||||
@@ -43,7 +44,17 @@ export interface Settings {
|
||||
modbus_port: number;
|
||||
modbus_max_clients: number;
|
||||
modbus_timeout: number;
|
||||
email_enabled: boolean;
|
||||
email_security: number;
|
||||
email_server: string;
|
||||
email_port: number;
|
||||
email_login: string;
|
||||
email_pass: string;
|
||||
email_sender: string;
|
||||
email_recp: string;
|
||||
email_subject: string;
|
||||
developer_mode: boolean;
|
||||
disable_reset: boolean;
|
||||
}
|
||||
|
||||
export enum busConnectionStatus {
|
||||
@@ -199,6 +210,18 @@ export enum DeviceValueUOM {
|
||||
HERTZ
|
||||
}
|
||||
|
||||
export type DeviceValueKind =
|
||||
'value' | 'scheduler' | 'command' | 'deviceCommand' | 'deviceEntity';
|
||||
|
||||
export function getDeviceValueKind(item: DeviceValue): DeviceValueKind {
|
||||
if (!item.c) return 'value';
|
||||
if (item.v !== undefined && item.l !== undefined && item.u === undefined)
|
||||
return 'scheduler';
|
||||
if (item.v === undefined && item.l === undefined) return 'command';
|
||||
if (item.u === DeviceValueUOM.NONE) return 'deviceCommand';
|
||||
return 'deviceEntity';
|
||||
}
|
||||
|
||||
export const DeviceValueUOM_s = [
|
||||
'',
|
||||
'°C',
|
||||
@@ -339,21 +362,19 @@ export enum DeviceEntityMask {
|
||||
}
|
||||
|
||||
export interface ScheduleItem {
|
||||
id: number; // unique index
|
||||
id: number; // unique index for table
|
||||
active: boolean;
|
||||
deleted?: boolean;
|
||||
flags: number;
|
||||
time: string; // also used for Condition and On Change
|
||||
cmd: string;
|
||||
value: string;
|
||||
name: string; // can be empty
|
||||
cmd_name: string; // references a named Command
|
||||
name: string;
|
||||
o_id?: number;
|
||||
o_active?: boolean;
|
||||
o_deleted?: boolean;
|
||||
o_flags?: number;
|
||||
o_time?: string;
|
||||
o_cmd?: string;
|
||||
o_value?: string;
|
||||
o_cmd_name?: string;
|
||||
o_name?: string;
|
||||
}
|
||||
|
||||
@@ -361,6 +382,23 @@ export interface Schedule {
|
||||
readonly schedule: readonly ScheduleItem[];
|
||||
}
|
||||
|
||||
export interface CommandItem {
|
||||
id: number;
|
||||
cmd: string;
|
||||
value: string;
|
||||
name: string;
|
||||
deleted?: boolean;
|
||||
o_id?: number;
|
||||
o_cmd?: string;
|
||||
o_value?: string;
|
||||
o_name?: string;
|
||||
o_deleted?: boolean;
|
||||
}
|
||||
|
||||
export interface Commands {
|
||||
readonly commands: readonly CommandItem[];
|
||||
}
|
||||
|
||||
export interface ModuleItem {
|
||||
id: number; // unique index
|
||||
key: string;
|
||||
@@ -391,8 +429,7 @@ export enum ScheduleFlag {
|
||||
SCHEDULE_DAY = 0, // no bits set
|
||||
SCHEDULE_TIMER = 128, // bit 8
|
||||
SCHEDULE_ONCHANGE = 129, // bit 1
|
||||
SCHEDULE_CONDITION = 130, // bit 2
|
||||
SCHEDULE_IMMEDIATE = 132 // bit 3
|
||||
SCHEDULE_CONDITION = 130 // bit 2
|
||||
}
|
||||
|
||||
export interface EntityItem {
|
||||
@@ -435,6 +472,7 @@ export const enum DeviceType {
|
||||
ANALOGSENSOR = 2,
|
||||
SCHEDULER = 3,
|
||||
CUSTOM = 4,
|
||||
COMMAND = 5,
|
||||
BOILER,
|
||||
THERMOSTAT,
|
||||
MIXER,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import Schema from 'async-validator';
|
||||
import type { InternalRuleItem } from 'async-validator';
|
||||
import Schema from 'validators/schema';
|
||||
import type { InternalRuleItem } from 'validators/schema';
|
||||
import { IP_OR_HOSTNAME_VALIDATOR } from 'validators/shared';
|
||||
|
||||
import type {
|
||||
AnalogSensor,
|
||||
CommandItem,
|
||||
DeviceValue,
|
||||
EntityItem,
|
||||
ScheduleItem,
|
||||
@@ -232,7 +233,29 @@ export const schedulerItemValidation = (
|
||||
scheduleItem: ScheduleItem
|
||||
) =>
|
||||
new Schema({
|
||||
name: [NAME_PATTERN, uniqueNameValidator(schedule, scheduleItem.o_name)],
|
||||
name: [
|
||||
{ required: true, message: 'Name is required' },
|
||||
NAME_PATTERN_REQUIRED,
|
||||
uniqueNameValidator(schedule, scheduleItem.o_name)
|
||||
],
|
||||
cmd_name: [{ required: true, message: 'Command is required' }]
|
||||
});
|
||||
|
||||
export const uniqueCommandNameValidator = (
|
||||
commands: CommandItem[],
|
||||
o_name?: string
|
||||
) => createUniqueNameValidator(commands, o_name);
|
||||
|
||||
export const commandItemValidation = (
|
||||
commands: CommandItem[],
|
||||
commandItem: CommandItem
|
||||
) =>
|
||||
new Schema({
|
||||
name: [
|
||||
{ required: true, message: 'Name is required' },
|
||||
NAME_PATTERN_REQUIRED,
|
||||
uniqueCommandNameValidator(commands, commandItem.o_name)
|
||||
],
|
||||
cmd: [
|
||||
{ required: true, message: 'Command is required' },
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import WarningIcon from '@mui/icons-material/Warning';
|
||||
@@ -6,7 +6,6 @@ import { Button, Checkbox, MenuItem } from '@mui/material';
|
||||
|
||||
import * as APApi from 'api/ap';
|
||||
|
||||
import type { ValidateFieldsError } from 'async-validator';
|
||||
import {
|
||||
BlockFormControlLabel,
|
||||
BlockNavigation,
|
||||
@@ -21,10 +20,10 @@ import { useI18nContext } from 'i18n/i18n-react';
|
||||
import type { APSettingsType } from 'types';
|
||||
import { APProvisionMode } from 'types';
|
||||
import { numberValue, updateValueDirty, useRest } from 'utils';
|
||||
import { createAPSettingsValidator, validate } from 'validators';
|
||||
import { ValidationError, createAPSettingsValidator, validate } from 'validators';
|
||||
import type { ValidateFieldsError } from 'validators/schema';
|
||||
|
||||
export const isAPEnabled = ({ provision_mode }: APSettingsType) =>
|
||||
provision_mode === APProvisionMode.AP_MODE_ALWAYS ||
|
||||
provision_mode === APProvisionMode.AP_MODE_DISCONNECTED;
|
||||
|
||||
// Efficient range function without recursion
|
||||
@@ -63,22 +62,16 @@ const APSettings = () => {
|
||||
|
||||
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
||||
|
||||
const updateFormValue = useMemo(
|
||||
() =>
|
||||
updateValueDirty(
|
||||
origData as unknown as Record<string, unknown>,
|
||||
dirtyFlags,
|
||||
setDirtyFlags,
|
||||
updateDataValue as (value: unknown) => void
|
||||
),
|
||||
[origData, dirtyFlags, setDirtyFlags, updateDataValue]
|
||||
const updateFormValue = updateValueDirty(
|
||||
origData as unknown as Record<string, unknown>,
|
||||
dirtyFlags,
|
||||
setDirtyFlags,
|
||||
updateDataValue as (value: unknown) => void
|
||||
);
|
||||
|
||||
// Memoize AP enabled state
|
||||
const apEnabled = useMemo(() => (data ? isAPEnabled(data) : false), [data]);
|
||||
const apEnabled = data ? isAPEnabled(data) : false;
|
||||
|
||||
// Memoize validation and submit handler
|
||||
const validateAndSubmit = useCallback(async () => {
|
||||
const validateAndSubmit = async () => {
|
||||
if (!data) return;
|
||||
|
||||
try {
|
||||
@@ -86,9 +79,9 @@ const APSettings = () => {
|
||||
await validate(createAPSettingsValidator(data), data);
|
||||
await saveData();
|
||||
} catch (error) {
|
||||
setFieldErrors(error as ValidateFieldsError);
|
||||
setFieldErrors((error as ValidationError).fieldErrors);
|
||||
}
|
||||
}, [data, saveData]);
|
||||
};
|
||||
|
||||
const content = () => {
|
||||
if (!data) {
|
||||
@@ -108,9 +101,6 @@ const APSettings = () => {
|
||||
onChange={updateFormValue}
|
||||
margin="normal"
|
||||
>
|
||||
<MenuItem value={APProvisionMode.AP_MODE_ALWAYS}>
|
||||
{LL.AP_PROVIDE_TEXT_1()}
|
||||
</MenuItem>
|
||||
<MenuItem value={APProvisionMode.AP_MODE_DISCONNECTED}>
|
||||
{LL.AP_PROVIDE_TEXT_2()}
|
||||
</MenuItem>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useState } from 'react';
|
||||
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import PowerSettingsNewIcon from '@mui/icons-material/PowerSettingsNew';
|
||||
@@ -20,7 +19,6 @@ import { readSystemStatus } from 'api/system';
|
||||
|
||||
import { useRequest } from 'alova/client';
|
||||
import SystemMonitor from 'app/status/SystemMonitor';
|
||||
import type { ValidateFieldsError } from 'async-validator';
|
||||
import {
|
||||
BlockFormControlLabel,
|
||||
BlockNavigation,
|
||||
@@ -28,12 +26,15 @@ import {
|
||||
FormLoader,
|
||||
MessageBox,
|
||||
SectionContent,
|
||||
ValidatedPasswordField,
|
||||
ValidatedTextField,
|
||||
useLayoutTitle
|
||||
} from 'components';
|
||||
import { toast } from 'components/toast';
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
import { numberValue, updateValueDirty, useRest } from 'utils';
|
||||
import { validate } from 'validators';
|
||||
import { ValidationError, validate } from 'validators';
|
||||
import type { ValidateFieldsError } from 'validators/schema';
|
||||
|
||||
import { API, getBoardProfile, readSettings, writeSettings } from '../../api/app';
|
||||
import { BOARD_PROFILES } from '../main/types';
|
||||
@@ -106,82 +107,79 @@ const ApplicationSettings = () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Memoized input props to prevent recreation on every render
|
||||
const SecondsInputProps = useMemo(
|
||||
() => ({
|
||||
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
|
||||
}),
|
||||
[LL]
|
||||
);
|
||||
const SecondsInputProps = {
|
||||
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
|
||||
};
|
||||
|
||||
const MinutesInputProps = useMemo(
|
||||
() => ({
|
||||
endAdornment: <InputAdornment position="end">{LL.MINUTES()}</InputAdornment>
|
||||
}),
|
||||
[LL]
|
||||
);
|
||||
const MinutesInputProps = {
|
||||
endAdornment: <InputAdornment position="end">{LL.MINUTES()}</InputAdornment>
|
||||
};
|
||||
|
||||
const HoursInputProps = useMemo(
|
||||
() => ({
|
||||
endAdornment: <InputAdornment position="end">{LL.HOURS()}</InputAdornment>
|
||||
}),
|
||||
[LL]
|
||||
);
|
||||
const HoursInputProps = {
|
||||
endAdornment: <InputAdornment position="end">{LL.HOURS()}</InputAdornment>
|
||||
};
|
||||
|
||||
const doRestart = useCallback(async () => {
|
||||
const doRestart = async () => {
|
||||
setRestarting(true);
|
||||
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
|
||||
(error: Error) => {
|
||||
toast.error(error.message);
|
||||
}
|
||||
);
|
||||
}, [sendAPI]);
|
||||
};
|
||||
|
||||
const updateBoardProfile = useCallback(
|
||||
async (board_profile: string) => {
|
||||
await readBoardProfile(board_profile).catch((error: Error) => {
|
||||
toast.error(error.message);
|
||||
});
|
||||
},
|
||||
[readBoardProfile]
|
||||
);
|
||||
const updateBoardProfile = async (board_profile: string) => {
|
||||
await readBoardProfile(board_profile).catch((error: Error) => {
|
||||
toast.error(error.message);
|
||||
});
|
||||
};
|
||||
|
||||
useLayoutTitle(LL.APPLICATION());
|
||||
|
||||
const validateAndSubmit = useCallback(async () => {
|
||||
const validateAndSubmit = async () => {
|
||||
try {
|
||||
setFieldErrors(undefined);
|
||||
await validate(createSettingsValidator(data), data);
|
||||
} catch (error) {
|
||||
setFieldErrors(error as ValidateFieldsError);
|
||||
} finally {
|
||||
await saveData();
|
||||
} catch (error) {
|
||||
setFieldErrors((error as ValidationError).fieldErrors);
|
||||
}
|
||||
}, [data, saveData]);
|
||||
};
|
||||
|
||||
const changeBoardProfile = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const boardProfile = event.target.value;
|
||||
updateFormValue(event);
|
||||
if (boardProfile === 'CUSTOM') {
|
||||
updateDataValue({
|
||||
...data,
|
||||
board_profile: boardProfile
|
||||
});
|
||||
} else {
|
||||
void updateBoardProfile(boardProfile);
|
||||
}
|
||||
},
|
||||
[data, updateBoardProfile, updateFormValue, updateDataValue]
|
||||
);
|
||||
const changeBoardProfile = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const boardProfile = event.target.value;
|
||||
updateFormValue(event);
|
||||
if (boardProfile === 'CUSTOM') {
|
||||
updateDataValue({
|
||||
...data,
|
||||
board_profile: boardProfile
|
||||
});
|
||||
} else {
|
||||
void updateBoardProfile(boardProfile);
|
||||
}
|
||||
};
|
||||
|
||||
const restart = useCallback(async () => {
|
||||
const restart = async () => {
|
||||
await validateAndSubmit();
|
||||
await doRestart();
|
||||
}, [validateAndSubmit, doRestart]);
|
||||
};
|
||||
|
||||
// Memoize board profile select items to prevent recreation
|
||||
const boardProfileItems = useMemo(() => boardProfileSelectItems(), []);
|
||||
const sendmail = async () => {
|
||||
await sendAPI({
|
||||
device: 'system',
|
||||
cmd: 'sendmail',
|
||||
data: 'Email notification test successful!',
|
||||
id: 0
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(LL.TEST_EMAIL_SUCCESSFUL());
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
toast.error(error.message);
|
||||
});
|
||||
};
|
||||
|
||||
const boardProfileItems = boardProfileSelectItems();
|
||||
|
||||
const content = () => {
|
||||
if (!data || !hardwareData) {
|
||||
@@ -190,7 +188,19 @@ const ApplicationSettings = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Typography sx={{ pb: 1 }} variant="h6" color="primary">
|
||||
<Typography variant="h6" color="primary">
|
||||
{LL.SYSTEM(0)}
|
||||
{LL.CUSTOMIZATIONS()}
|
||||
</Typography>
|
||||
<TextField
|
||||
name="system_name"
|
||||
label={LL.SYSTEM_NAME()}
|
||||
value={data.system_name}
|
||||
variant="outlined"
|
||||
onChange={updateFormValue}
|
||||
margin="normal"
|
||||
/>
|
||||
<Typography sx={{ pb: 1, pt: 2 }} variant="h6" color="primary">
|
||||
{LL.SERVICES()}
|
||||
</Typography>
|
||||
<Typography color="secondary">API</Typography>
|
||||
@@ -351,6 +361,148 @@ const ApplicationSettings = () => {
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
<Typography color="secondary">eMail</Typography>
|
||||
<BlockFormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={data.email_enabled}
|
||||
onChange={updateFormValue}
|
||||
name="email_enabled"
|
||||
disabled={!hardwareData.psram}
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Typography color={!hardwareData.psram ? 'grey' : 'default'}>
|
||||
Enable eMail notification
|
||||
{!hardwareData.psram && (
|
||||
<Typography variant="caption">
|
||||
({LL.IS_REQUIRED('PSRAM')})
|
||||
</Typography>
|
||||
)}
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
{data.email_enabled && (
|
||||
<>
|
||||
<Grid
|
||||
container
|
||||
spacing={2}
|
||||
direction="row"
|
||||
sx={{ justifyContent: 'flex-start', alignItems: 'flex-start' }}
|
||||
>
|
||||
<Grid>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
name="email_server"
|
||||
label="SMTP Server"
|
||||
variant="outlined"
|
||||
value={data.email_server}
|
||||
onChange={updateFormValue}
|
||||
margin="normal"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
sx={{ width: '12ch' }}
|
||||
name="email_port"
|
||||
variant="outlined"
|
||||
label="Port"
|
||||
value={numberValue(data.email_port)}
|
||||
type="number"
|
||||
onChange={updateFormValue}
|
||||
margin="normal"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid>
|
||||
<TextField
|
||||
sx={{ width: '20ch' }}
|
||||
name="email_security"
|
||||
label={LL.SECURITY(0)}
|
||||
value={data.email_security}
|
||||
variant="outlined"
|
||||
onChange={updateFormValue}
|
||||
margin="normal"
|
||||
select
|
||||
>
|
||||
<MenuItem value={0}>{LL.OFF()}</MenuItem>
|
||||
<MenuItem value={1}>SSL</MenuItem>
|
||||
<MenuItem value={2}>StartTLS</MenuItem>
|
||||
</TextField>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Grid container spacing={2} rowSpacing={0}>
|
||||
<Grid>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
name="email_login"
|
||||
label="Login"
|
||||
variant="outlined"
|
||||
value={data.email_login}
|
||||
onChange={updateFormValue}
|
||||
margin="normal"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid>
|
||||
<ValidatedPasswordField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
name="email_pass"
|
||||
label="Password"
|
||||
variant="outlined"
|
||||
value={data.email_pass}
|
||||
onChange={updateFormValue}
|
||||
margin="normal"
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Grid container spacing={2} rowSpacing={0}>
|
||||
<Grid>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
name="email_sender"
|
||||
label="From"
|
||||
variant="outlined"
|
||||
value={data.email_sender}
|
||||
onChange={updateFormValue}
|
||||
margin="normal"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
name="email_recp"
|
||||
label="To"
|
||||
variant="outlined"
|
||||
value={data.email_recp}
|
||||
onChange={updateFormValue}
|
||||
margin="normal"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
name="email_subject"
|
||||
label="Subject"
|
||||
variant="outlined"
|
||||
value={data.email_subject}
|
||||
onChange={updateFormValue}
|
||||
margin="normal"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid>
|
||||
<Button
|
||||
sx={{ mt: 3 }}
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
disabled={dirtyFlags.length !== 0}
|
||||
onClick={sendmail}
|
||||
>
|
||||
Send test email
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</>
|
||||
)}
|
||||
<Typography sx={{ pb: 1, pt: 2 }} variant="h6" color="primary">
|
||||
{LL.SENSORS()}
|
||||
</Typography>
|
||||
@@ -396,7 +548,7 @@ const ApplicationSettings = () => {
|
||||
margin="normal"
|
||||
select
|
||||
>
|
||||
<MenuItem value="cz">Česky (CZ)</MenuItem>
|
||||
<MenuItem value="cs">Česky (CS)</MenuItem>
|
||||
<MenuItem value="de">Deutsch (DE)</MenuItem>
|
||||
<MenuItem value="en">English (EN)</MenuItem>
|
||||
<MenuItem value="fr">Français (FR)</MenuItem>
|
||||
@@ -677,6 +829,7 @@ const ApplicationSettings = () => {
|
||||
<MenuItem value={2}>EMS+</MenuItem>
|
||||
<MenuItem value={3}>HT3</MenuItem>
|
||||
<MenuItem value={4}>{LL.HARDWARE()}</MenuItem>
|
||||
<MenuItem value={5}>Auto</MenuItem>
|
||||
</TextField>
|
||||
</Grid>
|
||||
<Grid>
|
||||
@@ -750,6 +903,16 @@ const ApplicationSettings = () => {
|
||||
}
|
||||
label={LL.DEVELOPER_MODE()}
|
||||
/>
|
||||
<BlockFormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={data.disable_reset}
|
||||
onChange={updateFormValue}
|
||||
name="disable_reset"
|
||||
/>
|
||||
}
|
||||
label={LL.DISABLE_RESET()}
|
||||
/>
|
||||
<BlockFormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
@@ -771,7 +934,7 @@ const ApplicationSettings = () => {
|
||||
label={LL.REMOTE_TIMEOUT_EN()}
|
||||
/>
|
||||
{data.remote_timeout_en && (
|
||||
<Box mt={2}>
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
name="remote_timeout"
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useState } from 'react';
|
||||
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import DownloadIcon from '@mui/icons-material/GetApp';
|
||||
import { Box, Button, Grid, Typography } from '@mui/material';
|
||||
import WarningIcon from '@mui/icons-material/Warning';
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Grid,
|
||||
Typography
|
||||
} from '@mui/material';
|
||||
|
||||
import * as SystemApi from 'api/system';
|
||||
import { API, callAction } from 'api/app';
|
||||
|
||||
import { dialogStyle } from '@/CustomTheme';
|
||||
import { useRequest } from 'alova/client';
|
||||
import type { APIcall } from 'app/main/types';
|
||||
import SystemMonitor from 'app/status/SystemMonitor';
|
||||
@@ -16,19 +26,15 @@ import {
|
||||
SingleUpload,
|
||||
useLayoutTitle
|
||||
} from 'components';
|
||||
import { toast } from 'components/toast';
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
import { saveFile } from 'utils';
|
||||
|
||||
interface DownloadButton {
|
||||
key: string;
|
||||
type: string;
|
||||
label: string | number;
|
||||
isGridButton: boolean;
|
||||
}
|
||||
|
||||
const DownloadUpload = () => {
|
||||
const { LL } = useI18nContext();
|
||||
|
||||
const [confirmBackup, setConfirmBackup] = useState<boolean>(false);
|
||||
|
||||
const [restarting, setRestarting] = useState<boolean>(false);
|
||||
|
||||
const { send: sendExportData } = useRequest(
|
||||
@@ -51,7 +57,7 @@ const DownloadUpload = () => {
|
||||
|
||||
const { data, send: loadData, error } = useRequest(SystemApi.readSystemStatus);
|
||||
|
||||
const doRestart = useCallback(async () => {
|
||||
const doRestart = async () => {
|
||||
setRestarting(true);
|
||||
try {
|
||||
await sendAPI({ device: 'system', cmd: 'restart', id: 0 });
|
||||
@@ -59,52 +65,18 @@ const DownloadUpload = () => {
|
||||
toast.error((error as Error).message);
|
||||
setRestarting(false);
|
||||
}
|
||||
}, [sendAPI]);
|
||||
};
|
||||
|
||||
useLayoutTitle(LL.DOWNLOAD_UPLOAD());
|
||||
|
||||
const downloadButtons: DownloadButton[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'settings',
|
||||
type: 'settings',
|
||||
label: LL.SETTINGS_OF(LL.APPLICATION()),
|
||||
isGridButton: true
|
||||
},
|
||||
{
|
||||
key: 'customizations',
|
||||
type: 'customizations',
|
||||
label: LL.CUSTOMIZATIONS(),
|
||||
isGridButton: true
|
||||
},
|
||||
{
|
||||
key: 'entities',
|
||||
type: 'entities',
|
||||
label: LL.CUSTOM_ENTITIES(0),
|
||||
isGridButton: true
|
||||
},
|
||||
{
|
||||
key: 'schedule',
|
||||
type: 'schedule',
|
||||
label: LL.SCHEDULE(0),
|
||||
isGridButton: true
|
||||
},
|
||||
{
|
||||
key: 'allvalues',
|
||||
type: 'allvalues',
|
||||
label: LL.ALLVALUES(),
|
||||
isGridButton: false
|
||||
}
|
||||
],
|
||||
[LL]
|
||||
);
|
||||
const handleCloseBackupDialog = () => {
|
||||
setConfirmBackup(false);
|
||||
};
|
||||
|
||||
const handleDownload = useCallback(
|
||||
(type: string) => () => {
|
||||
void sendExportData(type);
|
||||
},
|
||||
[sendExportData]
|
||||
);
|
||||
const handleDownload = (type: string) => () => {
|
||||
void sendExportData(type);
|
||||
setConfirmBackup(false);
|
||||
};
|
||||
|
||||
if (restarting) {
|
||||
return <SystemMonitor />;
|
||||
@@ -118,58 +90,86 @@ const DownloadUpload = () => {
|
||||
);
|
||||
}
|
||||
|
||||
const gridButtons = downloadButtons.filter((btn) => btn.isGridButton);
|
||||
const standaloneButton = downloadButtons.find((btn) => !btn.isGridButton);
|
||||
|
||||
return (
|
||||
<SectionContent>
|
||||
<Dialog
|
||||
sx={dialogStyle}
|
||||
open={confirmBackup}
|
||||
onClose={handleCloseBackupDialog}
|
||||
>
|
||||
<DialogTitle>{LL.DOWNLOAD_SYSTEM_BACKUP()}</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<WarningIcon color="warning" sx={{ fontSize: 18 }} />
|
||||
|
||||
{LL.WARNING_SYSTEM_BACKUP()}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
startIcon={<CancelIcon />}
|
||||
variant="outlined"
|
||||
onClick={handleCloseBackupDialog}
|
||||
color="secondary"
|
||||
>
|
||||
{LL.CANCEL()}
|
||||
</Button>
|
||||
<Button
|
||||
startIcon={<DownloadIcon />}
|
||||
variant="outlined"
|
||||
onClick={handleDownload('systembackup')}
|
||||
color="primary"
|
||||
>
|
||||
{LL.DOWNLOAD(0)}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<Typography sx={{ pb: 2 }} variant="h6" color="primary">
|
||||
{LL.DOWNLOAD(0)}
|
||||
</Typography>
|
||||
|
||||
<Typography mb={1} variant="body1" color="warning">
|
||||
{LL.DOWNLOAD_SETTINGS_TEXT()}.
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={2}>
|
||||
{gridButtons.map((button) => (
|
||||
<Grid key={button.key}>
|
||||
<Button
|
||||
startIcon={<DownloadIcon />}
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
onClick={handleDownload(button.type)}
|
||||
>
|
||||
{button.label}
|
||||
</Button>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
<Typography mt={2} mb={1} variant="body1" color="warning">
|
||||
{LL.DOWNLOAD_SETTINGS_TEXT2()}.
|
||||
</Typography>
|
||||
|
||||
{standaloneButton && (
|
||||
<Grid
|
||||
container
|
||||
spacing={2}
|
||||
sx={{
|
||||
alignItems: 'center'
|
||||
}}
|
||||
>
|
||||
<Typography variant="body1" color="warning">
|
||||
{LL.DOWNLOAD_SETTINGS_TEXT()}:
|
||||
</Typography>
|
||||
<Button
|
||||
startIcon={<DownloadIcon />}
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
onClick={handleDownload(standaloneButton.type)}
|
||||
onClick={() => setConfirmBackup(true)}
|
||||
>
|
||||
{standaloneButton.label}
|
||||
{LL.DOWNLOAD_SYSTEM_BACKUP()}
|
||||
</Button>
|
||||
)}
|
||||
</Grid>
|
||||
|
||||
<Grid container spacing={2} sx={{ mt: 2, alignItems: 'center' }}>
|
||||
<Typography variant="body1" color="warning">
|
||||
{LL.DOWNLOAD_SETTINGS_TEXT2()}:
|
||||
</Typography>
|
||||
<Button
|
||||
startIcon={<DownloadIcon />}
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
onClick={handleDownload('allvalues')}
|
||||
>
|
||||
{LL.ALLVALUES()}
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
<Typography sx={{ pt: 2, pb: 2 }} variant="h6" color="primary">
|
||||
{LL.UPLOAD()}
|
||||
</Typography>
|
||||
|
||||
<Box color="warning.main" sx={{ pb: 2 }}>
|
||||
<Typography variant="body1">{LL.UPLOAD_TEXT()}.</Typography>
|
||||
</Box>
|
||||
<Typography sx={{ pb: 2 }} color="warning" variant="body1">
|
||||
{LL.UPLOAD_TEXT()}:
|
||||
</Typography>
|
||||
|
||||
<SingleUpload text={LL.UPLOAD_DRAG()} doRestart={doRestart} />
|
||||
<SingleUpload doRestart={doRestart} />
|
||||
</SectionContent>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useState } from 'react';
|
||||
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import SettingsBackupRestoreIcon from '@mui/icons-material/SettingsBackupRestore';
|
||||
@@ -17,7 +16,6 @@ import {
|
||||
|
||||
import * as MqttApi from 'api/mqtt';
|
||||
|
||||
import type { ValidateFieldsError } from 'async-validator';
|
||||
import {
|
||||
BlockFormControlLabel,
|
||||
BlockNavigation,
|
||||
@@ -28,10 +26,12 @@ import {
|
||||
ValidatedTextField,
|
||||
useLayoutTitle
|
||||
} from 'components';
|
||||
import { toast } from 'components/toast';
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
import type { MqttSettingsType } from 'types';
|
||||
import { numberValue, updateValueDirty, useRest } from 'utils';
|
||||
import { createMqttSettingsValidator, validate } from 'validators';
|
||||
import { ValidationError, createMqttSettingsValidator, validate } from 'validators';
|
||||
import type { ValidateFieldsError } from 'validators/schema';
|
||||
|
||||
import { callAction } from '../../api/app';
|
||||
|
||||
@@ -57,7 +57,7 @@ const MqttSettings = () => {
|
||||
|
||||
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
||||
|
||||
const sendResetMQTT = useCallback(() => {
|
||||
const sendResetMQTT = () => {
|
||||
void callAction({ action: 'resetMQTT' })
|
||||
.then(() => {
|
||||
toast.success('MQTT ' + LL.REFRESH() + ' successful');
|
||||
@@ -65,56 +65,44 @@ const MqttSettings = () => {
|
||||
.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 updateFormValue = updateValueDirty(
|
||||
origData as unknown as Record<string, unknown>,
|
||||
dirtyFlags,
|
||||
setDirtyFlags,
|
||||
updateDataValue as (value: unknown) => void
|
||||
);
|
||||
|
||||
const SecondsInputProps = useMemo(
|
||||
() => ({
|
||||
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
|
||||
}),
|
||||
[LL]
|
||||
);
|
||||
const SecondsInputProps = {
|
||||
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
|
||||
};
|
||||
|
||||
const emptyFieldErrors = useMemo(() => ({}), []);
|
||||
|
||||
const validateAndSubmit = useCallback(async () => {
|
||||
const validateAndSubmit = async () => {
|
||||
if (!data) return;
|
||||
try {
|
||||
setFieldErrors(undefined);
|
||||
await validate(createMqttSettingsValidator(data), data);
|
||||
await saveData();
|
||||
} catch (error) {
|
||||
setFieldErrors(error as ValidateFieldsError);
|
||||
setFieldErrors((error as ValidationError).fieldErrors);
|
||||
}
|
||||
}, [data, saveData]);
|
||||
};
|
||||
|
||||
const publishIntervalFields = useMemo(
|
||||
() => [
|
||||
{ name: 'publish_time_heartbeat', label: 'Heartbeat', validated: true },
|
||||
{ name: 'publish_time_boiler', label: LL.MQTT_INT_BOILER(), validated: false },
|
||||
{
|
||||
name: 'publish_time_thermostat',
|
||||
label: LL.MQTT_INT_THERMOSTATS(),
|
||||
validated: false
|
||||
},
|
||||
{ name: 'publish_time_solar', label: LL.MQTT_INT_SOLAR(), validated: false },
|
||||
{ name: 'publish_time_mixer', label: LL.MQTT_INT_MIXER(), validated: false },
|
||||
{ name: 'publish_time_water', label: LL.MQTT_INT_WATER(), validated: false },
|
||||
{ name: 'publish_time_sensor', label: LL.SENSORS(), validated: false },
|
||||
{ name: 'publish_time_other', label: LL.DEFAULT(0), validated: false }
|
||||
],
|
||||
[LL]
|
||||
);
|
||||
const publishIntervalFields = [
|
||||
{ name: 'publish_time_heartbeat', label: 'Heartbeat', validated: true },
|
||||
{ name: 'publish_time_boiler', label: LL.MQTT_INT_BOILER(), validated: false },
|
||||
{
|
||||
name: 'publish_time_thermostat',
|
||||
label: LL.MQTT_INT_THERMOSTATS(),
|
||||
validated: false
|
||||
},
|
||||
{ name: 'publish_time_solar', label: LL.MQTT_INT_SOLAR(), validated: false },
|
||||
{ name: 'publish_time_mixer', label: LL.MQTT_INT_MIXER(), validated: false },
|
||||
{ name: 'publish_time_water', label: LL.MQTT_INT_WATER(), validated: false },
|
||||
{ name: 'publish_time_sensor', label: LL.SENSORS(), validated: false },
|
||||
{ name: 'publish_time_other', label: LL.DEFAULT(0), validated: false }
|
||||
];
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
@@ -129,7 +117,7 @@ const MqttSettings = () => {
|
||||
<SectionContent>
|
||||
{blocker ? <BlockNavigation blocker={blocker} /> : null}
|
||||
<>
|
||||
<Box display="flex" gap={2} mb={1}>
|
||||
<Box sx={{ display: 'flex', gap: 2, mb: 1 }}>
|
||||
<BlockFormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
@@ -154,7 +142,7 @@ const MqttSettings = () => {
|
||||
<Grid container spacing={2} rowSpacing={0}>
|
||||
<Grid>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors ?? emptyFieldErrors}
|
||||
fieldErrors={fieldErrors ?? {}}
|
||||
name="host"
|
||||
label={LL.ADDRESS_OF(LL.BROKER())}
|
||||
multiline
|
||||
@@ -166,7 +154,7 @@ const MqttSettings = () => {
|
||||
</Grid>
|
||||
<Grid>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors ?? emptyFieldErrors}
|
||||
fieldErrors={fieldErrors ?? {}}
|
||||
name="port"
|
||||
label="Port"
|
||||
variant="outlined"
|
||||
@@ -178,7 +166,7 @@ const MqttSettings = () => {
|
||||
</Grid>
|
||||
<Grid>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors ?? emptyFieldErrors}
|
||||
fieldErrors={fieldErrors ?? {}}
|
||||
name="base"
|
||||
label={LL.BASE_TOPIC()}
|
||||
variant="outlined"
|
||||
@@ -219,7 +207,7 @@ const MqttSettings = () => {
|
||||
</Grid>
|
||||
<Grid>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors ?? emptyFieldErrors}
|
||||
fieldErrors={fieldErrors ?? {}}
|
||||
name="keep_alive"
|
||||
label="Keep Alive"
|
||||
slotProps={{
|
||||
@@ -438,7 +426,7 @@ const MqttSettings = () => {
|
||||
<Grid key={field.name}>
|
||||
{field.validated ? (
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors ?? emptyFieldErrors}
|
||||
fieldErrors={fieldErrors ?? {}}
|
||||
name={field.name}
|
||||
label={field.label}
|
||||
slotProps={{
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useState } from 'react';
|
||||
|
||||
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
@@ -12,6 +11,7 @@ import {
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Grid,
|
||||
MenuItem,
|
||||
TextField,
|
||||
Typography
|
||||
@@ -23,7 +23,6 @@ import { readNTPSettings } from 'api/ntp';
|
||||
import { dialogStyle } from 'CustomTheme';
|
||||
import { useRequest } from 'alova/client';
|
||||
import { updateState } from 'alova/client';
|
||||
import type { ValidateFieldsError } from 'async-validator';
|
||||
import {
|
||||
BlockFormControlLabel,
|
||||
BlockNavigation,
|
||||
@@ -33,13 +32,20 @@ import {
|
||||
ValidatedTextField,
|
||||
useLayoutTitle
|
||||
} from 'components';
|
||||
import { toast } from 'components/toast';
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
import type { NTPSettingsType, Time } from 'types';
|
||||
import { formatLocalDateTime, updateValueDirty, useRest } from 'utils';
|
||||
import { validate } from 'validators';
|
||||
import { ValidationError, validate } from 'validators';
|
||||
import { NTP_SETTINGS_VALIDATOR } from 'validators/ntp';
|
||||
import type { ValidateFieldsError } from 'validators/schema';
|
||||
|
||||
import { TIME_ZONES, selectedTimeZone, useTimeZoneSelectItems } from './TZ';
|
||||
import {
|
||||
TIME_ZONES,
|
||||
selectedTimeZone,
|
||||
timeZoneSelectItemsT,
|
||||
useTimeZoneSelectItems
|
||||
} from './TZ';
|
||||
|
||||
const NTPSettings = () => {
|
||||
const {
|
||||
@@ -61,15 +67,16 @@ const NTPSettings = () => {
|
||||
const { LL } = useI18nContext();
|
||||
useLayoutTitle('NTP');
|
||||
|
||||
// Memoized timezone select items for better performance
|
||||
const timeZoneItems = useTimeZoneSelectItems();
|
||||
const timeZoneItemsT = timeZoneSelectItemsT();
|
||||
|
||||
// Memoized selected timezone value
|
||||
const selectedTzValue = useMemo(
|
||||
() => (data ? selectedTimeZone(data.tz_label, data.tz_format) : undefined),
|
||||
[data?.tz_label, data?.tz_format]
|
||||
);
|
||||
const selectedTzValue = data
|
||||
? selectedTimeZone(data.tz_label, data.tz_format)
|
||||
: undefined;
|
||||
|
||||
const selectedTzValueT = data
|
||||
? selectedTimeZone(data.tz_label_t, data.tz_format_t)
|
||||
: undefined;
|
||||
const [localTime, setLocalTime] = useState<string>('');
|
||||
const [settingTime, setSettingTime] = useState<boolean>(false);
|
||||
const [processing, setProcessing] = useState<boolean>(false);
|
||||
@@ -82,32 +89,22 @@ const NTPSettings = () => {
|
||||
}
|
||||
);
|
||||
|
||||
// Memoize updateFormValue to prevent recreation on every render
|
||||
const updateFormValue = useMemo(
|
||||
() =>
|
||||
updateValueDirty(
|
||||
origData as unknown as Record<string, unknown>,
|
||||
dirtyFlags,
|
||||
setDirtyFlags,
|
||||
updateDataValue as (value: unknown) => void
|
||||
),
|
||||
[origData, dirtyFlags, setDirtyFlags, updateDataValue]
|
||||
const updateFormValue = updateValueDirty(
|
||||
origData as unknown as Record<string, unknown>,
|
||||
dirtyFlags,
|
||||
setDirtyFlags,
|
||||
updateDataValue as (value: unknown) => void
|
||||
);
|
||||
|
||||
// Memoize updateLocalTime handler
|
||||
const updateLocalTime = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => setLocalTime(event.target.value),
|
||||
[]
|
||||
);
|
||||
const updateLocalTime = (event: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setLocalTime(event.target.value);
|
||||
|
||||
// Memoize openSetTime handler
|
||||
const openSetTime = useCallback(() => {
|
||||
const openSetTime = () => {
|
||||
setLocalTime(formatLocalDateTime(new Date()));
|
||||
setSettingTime(true);
|
||||
}, []);
|
||||
};
|
||||
|
||||
// Memoize configureTime handler
|
||||
const configureTime = useCallback(async () => {
|
||||
const configureTime = async () => {
|
||||
setProcessing(true);
|
||||
|
||||
try {
|
||||
@@ -120,38 +117,40 @@ const NTPSettings = () => {
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
}, [localTime, updateTime, LL, loadData]);
|
||||
};
|
||||
|
||||
// Memoize close dialog handler
|
||||
const handleCloseSetTime = useCallback(() => setSettingTime(false), []);
|
||||
const handleCloseSetTime = () => setSettingTime(false);
|
||||
|
||||
// Memoize validate and submit handler
|
||||
const validateAndSubmit = useCallback(async () => {
|
||||
const validateAndSubmit = async () => {
|
||||
if (!data) return;
|
||||
try {
|
||||
setFieldErrors(undefined);
|
||||
await validate(NTP_SETTINGS_VALIDATOR, data);
|
||||
await saveData();
|
||||
} catch (error) {
|
||||
setFieldErrors(error as ValidateFieldsError);
|
||||
setFieldErrors((error as ValidationError).fieldErrors);
|
||||
}
|
||||
}, [data, saveData]);
|
||||
};
|
||||
|
||||
// Memoize timezone change handler
|
||||
const changeTimeZone = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
void updateState(readNTPSettings(), (settings: NTPSettingsType) => ({
|
||||
...settings,
|
||||
tz_label: event.target.value,
|
||||
tz_format: TIME_ZONES[event.target.value]
|
||||
}));
|
||||
updateFormValue(event);
|
||||
},
|
||||
[updateFormValue]
|
||||
);
|
||||
const changeTimeZone = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
void updateState(readNTPSettings(), (settings: NTPSettingsType) => ({
|
||||
...settings,
|
||||
tz_label: event.target.value,
|
||||
tz_format: TIME_ZONES[event.target.value]
|
||||
}));
|
||||
updateFormValue(event);
|
||||
};
|
||||
|
||||
// Memoize render content to prevent unnecessary re-renders
|
||||
const renderContent = useMemo(() => {
|
||||
const changeTimeZoneT = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
void updateState(readNTPSettings(), (settings: NTPSettingsType) => ({
|
||||
...settings,
|
||||
tz_label_t: event.target.value,
|
||||
tz_format_t: TIME_ZONES[event.target.value]
|
||||
}));
|
||||
updateFormValue(event);
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
if (!data) {
|
||||
return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />;
|
||||
}
|
||||
@@ -174,6 +173,7 @@ const NTPSettings = () => {
|
||||
label={LL.NTP_SERVER()}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
disabled={!data.enabled}
|
||||
value={data.server}
|
||||
onChange={updateFormValue}
|
||||
margin="normal"
|
||||
@@ -192,10 +192,50 @@ const NTPSettings = () => {
|
||||
<MenuItem disabled>{LL.TIME_ZONE()}...</MenuItem>
|
||||
{timeZoneItems}
|
||||
</ValidatedTextField>
|
||||
|
||||
<Box display="flex" flexWrap="wrap">
|
||||
{data.enabled && (
|
||||
<Grid container spacing={2} rowSpacing={0}>
|
||||
<Grid>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
name="thermostat_sync"
|
||||
label="Sync EMS-Thermostat"
|
||||
variant="outlined"
|
||||
sx={{ width: '30ch' }}
|
||||
value={data.thermostat_sync}
|
||||
onChange={updateFormValue}
|
||||
margin="normal"
|
||||
select
|
||||
>
|
||||
<MenuItem value={0}>{LL.OFF()}</MenuItem>
|
||||
<MenuItem value={1}>{LL.ON()}</MenuItem>
|
||||
<MenuItem value={2}>
|
||||
{LL.USE()} {LL.TIME_ZONE()}
|
||||
</MenuItem>
|
||||
</ValidatedTextField>
|
||||
</Grid>
|
||||
<Grid>
|
||||
{data.thermostat_sync === 2 && (
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
name="tz_label_t"
|
||||
label={LL.TIME_ZONE() + ' Thermostat'}
|
||||
sx={{ width: '30ch' }}
|
||||
variant="outlined"
|
||||
value={selectedTzValueT}
|
||||
onChange={changeTimeZoneT}
|
||||
margin="normal"
|
||||
select
|
||||
>
|
||||
<MenuItem disabled>{LL.TIME_ZONE()}...</MenuItem>
|
||||
{timeZoneItemsT}
|
||||
</ValidatedTextField>
|
||||
)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap' }}>
|
||||
{!data.enabled && !dirtyFlags.length && (
|
||||
<Box flexWrap="nowrap" whiteSpace="nowrap">
|
||||
<Box sx={{ flexWrap: 'nowrap', whiteSpace: 'nowrap' }}>
|
||||
<ButtonRow>
|
||||
<Button
|
||||
onClick={openSetTime}
|
||||
@@ -236,32 +276,18 @@ const NTPSettings = () => {
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}, [
|
||||
data,
|
||||
errorMessage,
|
||||
loadData,
|
||||
updateFormValue,
|
||||
fieldErrors,
|
||||
selectedTzValue,
|
||||
changeTimeZone,
|
||||
timeZoneItems,
|
||||
dirtyFlags,
|
||||
openSetTime,
|
||||
saving,
|
||||
validateAndSubmit,
|
||||
LL
|
||||
]);
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionContent>
|
||||
{blocker ? <BlockNavigation blocker={blocker} /> : null}
|
||||
{renderContent}
|
||||
{renderContent()}
|
||||
<Dialog sx={dialogStyle} open={settingTime} onClose={handleCloseSetTime}>
|
||||
<DialogTitle>{LL.SET_TIME(1)}</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<Box color="warning.main" p={0} pl={0} pr={0} mt={0} mb={2}>
|
||||
<Typography variant="body2">{LL.SET_TIME_TEXT()}</Typography>
|
||||
</Box>
|
||||
<Typography color="warning" variant="body2">
|
||||
{LL.SET_TIME_TEXT()}
|
||||
</Typography>
|
||||
<TextField
|
||||
label={LL.LOCAL_TIME(0)}
|
||||
type="datetime-local"
|
||||
|
||||
@@ -1,188 +1,108 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useContext } from 'react';
|
||||
|
||||
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import BuildIcon from '@mui/icons-material/Build';
|
||||
import DeviceHubIcon from '@mui/icons-material/DeviceHub';
|
||||
import ImportExportIcon from '@mui/icons-material/ImportExport';
|
||||
import LockIcon from '@mui/icons-material/Lock';
|
||||
import SettingsBackupRestoreIcon from '@mui/icons-material/SettingsBackupRestore';
|
||||
import SettingsEthernetIcon from '@mui/icons-material/SettingsEthernet';
|
||||
import SettingsInputAntennaIcon from '@mui/icons-material/SettingsInputAntenna';
|
||||
import TuneIcon from '@mui/icons-material/Tune';
|
||||
import ViewModuleIcon from '@mui/icons-material/ViewModule';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Divider,
|
||||
List
|
||||
} from '@mui/material';
|
||||
import { List } from '@mui/material';
|
||||
|
||||
import { API } from 'api/app';
|
||||
|
||||
import { dialogStyle } from 'CustomTheme';
|
||||
import { useRequest } from 'alova/client';
|
||||
import type { APIcall } from 'app/main/types';
|
||||
import { SectionContent, useLayoutTitle } from 'components';
|
||||
import ListMenuItem from 'components/layout/ListMenuItem';
|
||||
import { AuthenticatedContext } from 'contexts/authentication';
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
|
||||
import SystemMonitor from '../status/SystemMonitor';
|
||||
|
||||
const Settings = () => {
|
||||
const { LL } = useI18nContext();
|
||||
const { versions } = useContext(AuthenticatedContext);
|
||||
useLayoutTitle(LL.SETTINGS(0));
|
||||
|
||||
const [confirmFactoryReset, setConfirmFactoryReset] = useState(false);
|
||||
const [restarting, setRestarting] = useState<boolean>();
|
||||
const upgradeAvailable = versions?.current?.upgradeable ?? false;
|
||||
const firmwareText = versions?.current?.version
|
||||
? `v${versions.current.version}${upgradeAvailable ? ` (${LL.UPDATE_AVAILABLE()})` : ''}`
|
||||
: '';
|
||||
|
||||
const { send: sendAPI } = useRequest((data: APIcall) => API(data), {
|
||||
immediate: false
|
||||
});
|
||||
return (
|
||||
<SectionContent>
|
||||
<List>
|
||||
<ListMenuItem
|
||||
icon={BuildIcon}
|
||||
bgcolor="#72caf9"
|
||||
label="EMS-ESP Firmware"
|
||||
text={firmwareText}
|
||||
to="/settings/version"
|
||||
badge={upgradeAvailable}
|
||||
/>
|
||||
|
||||
const doFormat = useCallback(async () => {
|
||||
await sendAPI({ device: 'system', cmd: 'format', id: 0 }).then(() => {
|
||||
setRestarting(true);
|
||||
setConfirmFactoryReset(false);
|
||||
});
|
||||
}, [sendAPI]);
|
||||
<ListMenuItem
|
||||
icon={TuneIcon}
|
||||
bgcolor="#134ba2"
|
||||
label={LL.APPLICATION()}
|
||||
text={LL.APPLICATION_SETTINGS_1()}
|
||||
to="application"
|
||||
/>
|
||||
|
||||
const handleFactoryResetClose = useCallback(() => {
|
||||
setConfirmFactoryReset(false);
|
||||
}, []);
|
||||
<ListMenuItem
|
||||
icon={SettingsEthernetIcon}
|
||||
bgcolor="#40828f"
|
||||
label={LL.NETWORK(0)}
|
||||
text={LL.CONFIGURE(LL.SETTINGS_OF(LL.NETWORK(1)))}
|
||||
to="network"
|
||||
/>
|
||||
|
||||
const handleFactoryResetClick = useCallback(() => {
|
||||
setConfirmFactoryReset(true);
|
||||
}, []);
|
||||
<ListMenuItem
|
||||
icon={SettingsInputAntennaIcon}
|
||||
bgcolor="#5f9a5f"
|
||||
label={LL.ACCESS_POINT(0)}
|
||||
text={LL.CONFIGURE(LL.ACCESS_POINT(1))}
|
||||
to="ap"
|
||||
/>
|
||||
|
||||
const content = useMemo(() => {
|
||||
return (
|
||||
<>
|
||||
<List>
|
||||
<ListMenuItem
|
||||
icon={TuneIcon}
|
||||
bgcolor="#134ba2"
|
||||
label={LL.APPLICATION()}
|
||||
text={LL.APPLICATION_SETTINGS_1()}
|
||||
to="application"
|
||||
/>
|
||||
<ListMenuItem
|
||||
icon={AccessTimeIcon}
|
||||
bgcolor="#c5572c"
|
||||
label="NTP"
|
||||
text={LL.CONFIGURE(LL.LOCAL_TIME(1))}
|
||||
to="ntp"
|
||||
/>
|
||||
|
||||
<ListMenuItem
|
||||
icon={SettingsEthernetIcon}
|
||||
bgcolor="#40828f"
|
||||
label={LL.NETWORK(0)}
|
||||
text={LL.CONFIGURE(LL.SETTINGS_OF(LL.NETWORK(1)))}
|
||||
to="network"
|
||||
/>
|
||||
<ListMenuItem
|
||||
icon={DeviceHubIcon}
|
||||
bgcolor="#68374d"
|
||||
label="MQTT"
|
||||
text={LL.CONFIGURE('MQTT')}
|
||||
to="mqtt"
|
||||
/>
|
||||
|
||||
<ListMenuItem
|
||||
icon={SettingsInputAntennaIcon}
|
||||
bgcolor="#5f9a5f"
|
||||
label={LL.ACCESS_POINT(0)}
|
||||
text={LL.CONFIGURE(LL.ACCESS_POINT(1))}
|
||||
to="ap"
|
||||
/>
|
||||
<ListMenuItem
|
||||
icon={LockIcon}
|
||||
label={LL.SECURITY(0)}
|
||||
text={LL.SECURITY_1()}
|
||||
to="security"
|
||||
/>
|
||||
|
||||
<ListMenuItem
|
||||
icon={AccessTimeIcon}
|
||||
bgcolor="#c5572c"
|
||||
label="NTP"
|
||||
text={LL.CONFIGURE(LL.LOCAL_TIME(1))}
|
||||
to="ntp"
|
||||
/>
|
||||
<ListMenuItem
|
||||
icon={ViewModuleIcon}
|
||||
bgcolor="#efc34b"
|
||||
label={LL.MODULES()}
|
||||
text={LL.MODULES_1()}
|
||||
to="modules"
|
||||
/>
|
||||
|
||||
<ListMenuItem
|
||||
icon={DeviceHubIcon}
|
||||
bgcolor="#68374d"
|
||||
label="MQTT"
|
||||
text={LL.CONFIGURE('MQTT')}
|
||||
to="mqtt"
|
||||
/>
|
||||
|
||||
<ListMenuItem
|
||||
icon={LockIcon}
|
||||
label={LL.SECURITY(0)}
|
||||
text={LL.SECURITY_1()}
|
||||
to="security"
|
||||
/>
|
||||
|
||||
<ListMenuItem
|
||||
icon={ViewModuleIcon}
|
||||
bgcolor="#efc34b"
|
||||
label={LL.MODULES()}
|
||||
text={LL.MODULES_1()}
|
||||
to="modules"
|
||||
/>
|
||||
|
||||
<ListMenuItem
|
||||
icon={ImportExportIcon}
|
||||
bgcolor="#5d89f7"
|
||||
label={LL.DOWNLOAD_UPLOAD()}
|
||||
text={LL.DOWNLOAD_UPLOAD_1()}
|
||||
to="downloadUpload"
|
||||
/>
|
||||
</List>
|
||||
|
||||
<Dialog
|
||||
sx={dialogStyle}
|
||||
open={confirmFactoryReset}
|
||||
onClose={handleFactoryResetClose}
|
||||
>
|
||||
<DialogTitle>{LL.FACTORY_RESET()}</DialogTitle>
|
||||
<DialogContent dividers>{LL.SYSTEM_FACTORY_TEXT_DIALOG()}</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
startIcon={<CancelIcon />}
|
||||
variant="outlined"
|
||||
onClick={handleFactoryResetClose}
|
||||
color="secondary"
|
||||
>
|
||||
{LL.CANCEL()}
|
||||
</Button>
|
||||
<Button
|
||||
startIcon={<SettingsBackupRestoreIcon />}
|
||||
variant="outlined"
|
||||
onClick={doFormat}
|
||||
color="error"
|
||||
>
|
||||
{LL.FACTORY_RESET()}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box
|
||||
mt={2}
|
||||
display="flex"
|
||||
justifyContent="flex-end"
|
||||
flexWrap="nowrap"
|
||||
whiteSpace="nowrap"
|
||||
>
|
||||
<Button
|
||||
startIcon={<SettingsBackupRestoreIcon />}
|
||||
variant="outlined"
|
||||
onClick={handleFactoryResetClick}
|
||||
color="error"
|
||||
>
|
||||
{LL.FACTORY_RESET()}
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}, [
|
||||
LL,
|
||||
handleFactoryResetClick,
|
||||
handleFactoryResetClose,
|
||||
doFormat,
|
||||
confirmFactoryReset,
|
||||
restarting
|
||||
]);
|
||||
|
||||
return restarting ? <SystemMonitor /> : <SectionContent>{content}</SectionContent>;
|
||||
<ListMenuItem
|
||||
icon={ImportExportIcon}
|
||||
bgcolor="#5d89f7"
|
||||
label={LL.DOWNLOAD_UPLOAD()}
|
||||
text={LL.DOWNLOAD_UPLOAD_1()}
|
||||
to="downloadUpload"
|
||||
/>
|
||||
</List>
|
||||
</SectionContent>
|
||||
);
|
||||
};
|
||||
|
||||
export default Settings;
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { MenuItem } from '@mui/material';
|
||||
|
||||
export const TIME_ZONES: Record<string, string> = {
|
||||
@@ -472,26 +470,20 @@ export function selectedTimeZone(label: string, format: string) {
|
||||
return TIME_ZONES[label] === format ? label : undefined;
|
||||
}
|
||||
|
||||
// Memoized version for use in components
|
||||
export function useTimeZoneSelectItems() {
|
||||
return useMemo(
|
||||
() =>
|
||||
TIME_ZONE_LABELS.map((label) => (
|
||||
<MenuItem key={label} value={label}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
)),
|
||||
[]
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback export for backward compatibility - now memoized
|
||||
const precomputedTimeZoneItems = TIME_ZONE_LABELS.map((label) => (
|
||||
<MenuItem key={label} value={label}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
));
|
||||
|
||||
export function useTimeZoneSelectItems() {
|
||||
return precomputedTimeZoneItems;
|
||||
}
|
||||
|
||||
export function timeZoneSelectItems() {
|
||||
return precomputedTimeZoneItems;
|
||||
}
|
||||
|
||||
export function timeZoneSelectItemsT() {
|
||||
return precomputedTimeZoneItems;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,944 @@
|
||||
import { memo, useContext, useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import CheckIcon from '@mui/icons-material/Done';
|
||||
import DownloadIcon from '@mui/icons-material/GetApp';
|
||||
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
|
||||
import PowerSettingsNewIcon from '@mui/icons-material/PowerSettingsNew';
|
||||
import SettingsBackupRestoreIcon from '@mui/icons-material/SettingsBackupRestore';
|
||||
import WarningIcon from '@mui/icons-material/Warning';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Grid,
|
||||
IconButton,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableRow,
|
||||
Typography
|
||||
} from '@mui/material';
|
||||
|
||||
import * as SystemApi from 'api/system';
|
||||
import { API, callAction } from 'api/app';
|
||||
|
||||
import { dialogStyle } from 'CustomTheme';
|
||||
import { useRequest } from 'alova/client';
|
||||
import type { APIcall } from 'app/main/types';
|
||||
import SystemMonitor from 'app/status/SystemMonitor';
|
||||
import {
|
||||
FormLoader,
|
||||
SectionContent,
|
||||
SingleUpload,
|
||||
useLayoutTitle
|
||||
} from 'components';
|
||||
import { toast } from 'components/toast';
|
||||
import { AuthenticatedContext } from 'contexts/authentication';
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
import type { TranslationFunctions } from 'i18n/i18n-types';
|
||||
import type { VersionInfo } from 'types';
|
||||
import { prettyDateTime } from 'utils/time';
|
||||
|
||||
// Constants moved outside component to avoid recreation
|
||||
const STABLE_URL = 'https://github.com/emsesp/EMS-ESP32/releases/download/';
|
||||
const STABLE_RELNOTES_URL =
|
||||
'https://github.com/emsesp/EMS-ESP32/blob/main/CHANGELOG.md';
|
||||
const DEV_URL = 'https://github.com/emsesp/EMS-ESP32/releases/download/latest/';
|
||||
const DEV_RELNOTES_URL =
|
||||
'https://github.com/emsesp/EMS-ESP32/blob/dev/CHANGELOG_LATEST.md';
|
||||
|
||||
// Types for better type safety
|
||||
interface PartitionData {
|
||||
partition: string;
|
||||
version: string;
|
||||
install_date?: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
interface VersionData {
|
||||
emsesp_version: string;
|
||||
arduino_version: string;
|
||||
esp_platform: string;
|
||||
flash_chip_size: number;
|
||||
psram: boolean;
|
||||
build_flags?: string;
|
||||
partition: string;
|
||||
partitions: PartitionData[];
|
||||
developer_mode: boolean;
|
||||
disable_reset: boolean;
|
||||
}
|
||||
|
||||
// Memoized components for better performance
|
||||
const VersionInfoDialog = memo(
|
||||
({
|
||||
showVersionInfo,
|
||||
latestVersion,
|
||||
latestDevVersion,
|
||||
partitionVersion,
|
||||
partition,
|
||||
currentPartition,
|
||||
size,
|
||||
locale,
|
||||
LL,
|
||||
onClose
|
||||
}: {
|
||||
showVersionInfo: number;
|
||||
latestVersion: VersionInfo | undefined;
|
||||
latestDevVersion: VersionInfo | undefined;
|
||||
partitionVersion: VersionInfo | undefined;
|
||||
partition: string;
|
||||
currentPartition: string;
|
||||
size: number;
|
||||
locale: string;
|
||||
LL: TranslationFunctions;
|
||||
onClose: () => void;
|
||||
}) => {
|
||||
if (showVersionInfo === 0) return null;
|
||||
|
||||
const isStable = showVersionInfo === 1;
|
||||
const isDev = showVersionInfo === 2;
|
||||
const isPartition = showVersionInfo === 3;
|
||||
|
||||
const version = isStable
|
||||
? latestVersion
|
||||
: isDev
|
||||
? latestDevVersion
|
||||
: partitionVersion;
|
||||
const relNotesUrl = isStable
|
||||
? STABLE_RELNOTES_URL
|
||||
: isDev
|
||||
? DEV_RELNOTES_URL
|
||||
: '';
|
||||
|
||||
return (
|
||||
<Dialog sx={dialogStyle} open={showVersionInfo !== 0} onClose={onClose}>
|
||||
<DialogTitle>{LL.FIRMWARE_VERSION_INFO()}</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<Table size="small" sx={{ borderCollapse: 'collapse', minWidth: 0 }}>
|
||||
<TableBody>
|
||||
<TableRow sx={{ height: 24, borderBottom: 'none' }}>
|
||||
<TableCell
|
||||
component="th"
|
||||
scope="row"
|
||||
sx={{
|
||||
color: 'lightblue',
|
||||
borderBottom: 'none',
|
||||
pr: 1,
|
||||
py: 0.5,
|
||||
fontSize: 13
|
||||
}}
|
||||
>
|
||||
{LL.VERSION()}
|
||||
</TableCell>
|
||||
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
|
||||
{isPartition
|
||||
? typeof version === 'string'
|
||||
? version
|
||||
: version?.version
|
||||
: version?.version}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow sx={{ height: 24, borderBottom: 'none' }}>
|
||||
<TableCell
|
||||
component="th"
|
||||
scope="row"
|
||||
sx={{
|
||||
color: 'lightblue',
|
||||
borderBottom: 'none',
|
||||
pr: 1,
|
||||
py: 0.5,
|
||||
fontSize: 13,
|
||||
width: 140
|
||||
}}
|
||||
>
|
||||
{isPartition ? LL.TYPE(0) : LL.RELEASE_TYPE()}
|
||||
</TableCell>
|
||||
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
|
||||
{partition === currentPartition && LL.ACTIVE() + ' '}
|
||||
{isStable
|
||||
? LL.STABLE()
|
||||
: isDev
|
||||
? LL.DEVELOPMENT()
|
||||
: 'Partition ' + LL.VERSION()}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{isPartition && (
|
||||
<TableRow sx={{ height: 24, borderBottom: 'none' }}>
|
||||
<TableCell
|
||||
component="th"
|
||||
scope="row"
|
||||
sx={{
|
||||
color: 'lightblue',
|
||||
borderBottom: 'none',
|
||||
pr: 1,
|
||||
py: 0.5,
|
||||
fontSize: 13
|
||||
}}
|
||||
>
|
||||
Partition
|
||||
</TableCell>
|
||||
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
|
||||
{partition}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{isPartition && (
|
||||
<TableRow sx={{ height: 24, borderBottom: 'none' }}>
|
||||
<TableCell
|
||||
component="th"
|
||||
scope="row"
|
||||
sx={{
|
||||
color: 'lightblue',
|
||||
borderBottom: 'none',
|
||||
pr: 1,
|
||||
py: 0.5,
|
||||
fontSize: 13
|
||||
}}
|
||||
>
|
||||
Size
|
||||
</TableCell>
|
||||
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
|
||||
{size} KB
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{version && version.date && (
|
||||
<TableRow sx={{ height: 24, borderBottom: 'none' }}>
|
||||
<TableCell
|
||||
component="th"
|
||||
scope="row"
|
||||
sx={{
|
||||
color: 'lightblue',
|
||||
borderBottom: 'none',
|
||||
pr: 1,
|
||||
py: 0.5,
|
||||
fontSize: 13
|
||||
}}
|
||||
>
|
||||
{isPartition ? 'Install Date' : 'Build Date'}
|
||||
</TableCell>
|
||||
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
|
||||
{prettyDateTime(locale, new Date(version.date))}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
{!isPartition && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
component="a"
|
||||
href={relNotesUrl}
|
||||
target="_blank"
|
||||
color="primary"
|
||||
>
|
||||
Changelog
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outlined" onClick={onClose} color="secondary">
|
||||
{LL.CLOSE()}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const InstallDialog = memo(
|
||||
({
|
||||
openInstallDialog,
|
||||
fetchDevVersion,
|
||||
latestVersion,
|
||||
latestDevVersion,
|
||||
upgradeImportantMessageType,
|
||||
platform,
|
||||
LL,
|
||||
onClose,
|
||||
onInstall
|
||||
}: {
|
||||
openInstallDialog: boolean;
|
||||
fetchDevVersion: boolean;
|
||||
latestVersion: VersionInfo | undefined;
|
||||
latestDevVersion: VersionInfo | undefined;
|
||||
upgradeImportantMessageType: number;
|
||||
platform: string;
|
||||
LL: TranslationFunctions;
|
||||
onClose: () => void;
|
||||
onInstall: (url: string) => void;
|
||||
}) => {
|
||||
const binURL = (() => {
|
||||
if (!latestVersion || !latestDevVersion) return '';
|
||||
const version = fetchDevVersion ? latestDevVersion : latestVersion;
|
||||
const filename = `EMS-ESP-${version.version.replaceAll('.', '_')}-${platform}.bin`;
|
||||
return fetchDevVersion
|
||||
? `${DEV_URL}${filename}`
|
||||
: `${STABLE_URL}v${version.version}/${filename}`;
|
||||
})();
|
||||
|
||||
return (
|
||||
<Dialog sx={dialogStyle} open={openInstallDialog} onClose={onClose}>
|
||||
<DialogTitle>
|
||||
{`${LL.INSTALL()} ${fetchDevVersion ? LL.DEVELOPMENT() : LL.STABLE()} Firmware`}
|
||||
</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<Typography sx={{ mb: 2 }}>
|
||||
{LL.INSTALL_VERSION(
|
||||
LL.INSTALL(),
|
||||
fetchDevVersion ? latestDevVersion?.version : latestVersion?.version
|
||||
)}
|
||||
</Typography>
|
||||
{upgradeImportantMessageType === 2 && LL.UPGRADE_IMPORTANT_MESSAGES_2()}
|
||||
{upgradeImportantMessageType === 1 && (
|
||||
<>
|
||||
{LL.UPGRADE_IMPORTANT_MESSAGES_1()}
|
||||
<Typography sx={{ mt: 2 }}>
|
||||
<Link to="/settings/downloadUpload" style={{ color: 'lightblue' }}>
|
||||
{LL.DOWNLOAD_SYSTEM_BACKUP()}
|
||||
</Link>
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
<Typography sx={{ mt: 2 }}>
|
||||
<Link
|
||||
to="https://docs.emsesp.org/FAQ#upgrading-the-firmware"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
style={{ color: 'lightblue' }}
|
||||
>
|
||||
{LL.ONLINE_HELP()}
|
||||
</Link>
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
startIcon={<CancelIcon />}
|
||||
variant="outlined"
|
||||
onClick={onClose}
|
||||
color="secondary"
|
||||
>
|
||||
{LL.CANCEL()}
|
||||
</Button>
|
||||
<Button
|
||||
startIcon={<DownloadIcon />}
|
||||
variant="outlined"
|
||||
onClick={onClose}
|
||||
color="primary"
|
||||
>
|
||||
<Link
|
||||
to={binURL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
style={{ color: 'lightblue', textDecoration: 'none' }}
|
||||
>
|
||||
{LL.DOWNLOAD(0)}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
startIcon={<WarningIcon color="warning" />}
|
||||
variant="outlined"
|
||||
onClick={() => onInstall(binURL)}
|
||||
color="primary"
|
||||
>
|
||||
{LL.INSTALL()}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const InstallPartitionDialog = memo(
|
||||
({
|
||||
openInstallPartitionDialog,
|
||||
version,
|
||||
partition,
|
||||
LL,
|
||||
onClose,
|
||||
onInstall
|
||||
}: {
|
||||
openInstallPartitionDialog: boolean;
|
||||
version: string;
|
||||
partition: string;
|
||||
LL: TranslationFunctions;
|
||||
onClose: () => void;
|
||||
onInstall: (partition: string) => void;
|
||||
}) => {
|
||||
return (
|
||||
<Dialog sx={dialogStyle} open={openInstallPartitionDialog} onClose={onClose}>
|
||||
<DialogTitle>
|
||||
{LL.INSTALL()} {LL.STORED_VERSIONS()}
|
||||
</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<Typography sx={{ mb: 2 }}>
|
||||
{LL.INSTALL_VERSION(LL.INSTALL(), version)}
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
startIcon={<CancelIcon />}
|
||||
variant="outlined"
|
||||
onClick={onClose}
|
||||
color="secondary"
|
||||
>
|
||||
{LL.CANCEL()}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
startIcon={<WarningIcon color="warning" />}
|
||||
variant="outlined"
|
||||
onClick={() => onInstall(partition)}
|
||||
color="primary"
|
||||
>
|
||||
{LL.INSTALL()}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
// Helper function moved outside component
|
||||
const getPlatform = (data: VersionData): string => {
|
||||
return `${data.esp_platform}-${data.flash_chip_size >= 16384 ? '16MB' : '4MB'}${data.psram ? '+' : ''}`;
|
||||
};
|
||||
|
||||
const Version = () => {
|
||||
const { LL, locale } = useI18nContext();
|
||||
const { me, versions } = useContext(AuthenticatedContext);
|
||||
|
||||
const [restarting, setRestarting] = useState<boolean>(false);
|
||||
const [confirmFactoryReset, setConfirmFactoryReset] = useState<boolean>(false);
|
||||
const [confirmRestart, setConfirmRestart] = useState<boolean>(false);
|
||||
const [openInstallDialog, setOpenInstallDialog] = useState<boolean>(false);
|
||||
|
||||
const [partitionVersion, setPartitionVersion] = useState<VersionInfo | undefined>(
|
||||
undefined
|
||||
);
|
||||
const [partition, setPartition] = useState<string>('');
|
||||
const [openInstallPartitionDialog, setOpenInstallPartitionDialog] =
|
||||
useState<boolean>(false);
|
||||
|
||||
const [fetchDevVersion, setFetchDevVersion] = useState<boolean>(false);
|
||||
const [showVersionInfo, setShowVersionInfo] = useState<number>(0); // 1 = stable, 2 = dev, 3 = partition
|
||||
const [firmwareSize, setFirmwareSize] = useState<number>(0);
|
||||
|
||||
const latestVersion = useMemo<VersionInfo | undefined>(
|
||||
() =>
|
||||
versions?.stable
|
||||
? { version: versions.stable.version, date: versions.stable.date }
|
||||
: undefined,
|
||||
[versions?.stable]
|
||||
);
|
||||
const latestDevVersion = useMemo<VersionInfo | undefined>(
|
||||
() =>
|
||||
versions?.dev
|
||||
? { version: versions.dev.version, date: versions.dev.date }
|
||||
: undefined,
|
||||
[versions?.dev]
|
||||
);
|
||||
const usingDevVersion = versions?.current?.type === 'dev';
|
||||
const stableUpgradeAvailable = versions?.stable?.upgradeable ?? false;
|
||||
const devUpgradeAvailable = versions?.dev?.upgradeable ?? false;
|
||||
const internetLive = Boolean(versions?.stable || versions?.dev);
|
||||
|
||||
const { send: sendSetPartition } = useRequest(
|
||||
(partition: string) => callAction({ action: 'setPartition', param: partition }),
|
||||
{ immediate: false }
|
||||
).onError((error) => {
|
||||
toast.error(String(error.error?.message || 'An error occurred'));
|
||||
});
|
||||
|
||||
const { data, send: loadData, error } = useRequest(SystemApi.readSystemStatus);
|
||||
|
||||
const { send: sendUploadURL } = useRequest(
|
||||
(url: string) => callAction({ action: 'uploadURL', param: url }),
|
||||
{ immediate: false }
|
||||
);
|
||||
|
||||
const { send: sendAPI } = useRequest((data: APIcall) => API(data), {
|
||||
immediate: false
|
||||
});
|
||||
|
||||
const [upgradeImportantMessageType, setUpgradeImportantMessageType] =
|
||||
useState<number>(0);
|
||||
|
||||
const { send: checkUpgradeImportantMessages } = useRequest(
|
||||
(version: string) =>
|
||||
callAction({ action: 'upgradeImportantMessages', param: version }),
|
||||
{
|
||||
immediate: false
|
||||
}
|
||||
)
|
||||
.onSuccess((event) => {
|
||||
const upgradeImportantMessageType_n = (
|
||||
event.data as { upgradeImportantMessageType: number }
|
||||
).upgradeImportantMessageType;
|
||||
setUpgradeImportantMessageType(upgradeImportantMessageType_n);
|
||||
})
|
||||
.onError((error) => {
|
||||
toast.error(String(error.error?.message || 'An error occurred'));
|
||||
});
|
||||
|
||||
const platform = data ? getPlatform(data) : '';
|
||||
|
||||
const otherPartitions =
|
||||
data?.partitions.filter((p) => p.partition !== data.partition) ?? [];
|
||||
|
||||
const setPartitionVersionInfo = (partition: string) => {
|
||||
setShowVersionInfo(3);
|
||||
const partitionData = data?.partitions.find((p) => p.partition === partition);
|
||||
if (partitionData) {
|
||||
setPartitionVersion({
|
||||
version: partitionData.version,
|
||||
date: partitionData.install_date ?? ''
|
||||
});
|
||||
setPartition(partitionData.partition);
|
||||
setFirmwareSize(partitionData.size);
|
||||
}
|
||||
};
|
||||
|
||||
const doRestart = async () => {
|
||||
setConfirmRestart(false);
|
||||
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
|
||||
(error: Error) => {
|
||||
toast.error(error.message);
|
||||
}
|
||||
);
|
||||
setRestarting(true);
|
||||
};
|
||||
|
||||
const doFormat = async () => {
|
||||
await sendAPI({ device: 'system', cmd: 'format', id: 0 }).then(() => {
|
||||
setRestarting(true);
|
||||
setConfirmFactoryReset(false);
|
||||
});
|
||||
};
|
||||
|
||||
const handleFactoryResetClose = () => setConfirmFactoryReset(false);
|
||||
const handleFactoryResetClick = () => setConfirmFactoryReset(true);
|
||||
const handleRestartClose = () => setConfirmRestart(false);
|
||||
const handleRestartClick = () => setConfirmRestart(true);
|
||||
|
||||
const installFirmwareURL = async (url: string) => {
|
||||
await sendUploadURL(url).catch((error: Error) => {
|
||||
toast.error(error.message);
|
||||
});
|
||||
await doRestart();
|
||||
};
|
||||
|
||||
const installPartitionFirmware = async (partition: string) => {
|
||||
await sendSetPartition(partition).catch((error: Error) => {
|
||||
toast.error(error.message);
|
||||
});
|
||||
setRestarting(true);
|
||||
};
|
||||
|
||||
const showPartitionDialog = (
|
||||
version: string,
|
||||
partition: string,
|
||||
install_date: string
|
||||
) => {
|
||||
setOpenInstallPartitionDialog(true);
|
||||
setPartitionVersion({ version: version, date: install_date });
|
||||
setPartition(partition);
|
||||
};
|
||||
|
||||
const showFirmwareDialog = (useDevVersion: boolean) => {
|
||||
setFetchDevVersion(useDevVersion);
|
||||
const targetVersion = useDevVersion
|
||||
? latestDevVersion?.version
|
||||
: latestVersion?.version;
|
||||
if (targetVersion) {
|
||||
void checkUpgradeImportantMessages(targetVersion);
|
||||
}
|
||||
setOpenInstallDialog(true);
|
||||
};
|
||||
|
||||
const closeInstallDialog = () => setOpenInstallDialog(false);
|
||||
const closeInstallPartitionDialog = () => setOpenInstallPartitionDialog(false);
|
||||
|
||||
const handleVersionInfoClose = () => {
|
||||
setShowVersionInfo(0);
|
||||
setPartitionVersion(undefined);
|
||||
setPartition('');
|
||||
};
|
||||
|
||||
useLayoutTitle('EMS-ESP Firmware');
|
||||
|
||||
const showButtons = (showingDev: boolean) => {
|
||||
const choice = showingDev
|
||||
? !usingDevVersion
|
||||
? LL.SWITCH_RELEASE_TYPE(LL.DEVELOPMENT())
|
||||
: devUpgradeAvailable
|
||||
? LL.UPDATE_AVAILABLE()
|
||||
: undefined
|
||||
: usingDevVersion
|
||||
? LL.SWITCH_RELEASE_TYPE(LL.STABLE())
|
||||
: stableUpgradeAvailable
|
||||
? LL.UPDATE_AVAILABLE()
|
||||
: undefined;
|
||||
|
||||
if (!choice) {
|
||||
return (
|
||||
<>
|
||||
<CheckIcon
|
||||
color="success"
|
||||
sx={{ verticalAlign: 'middle', ml: 0.5, mr: 0.5 }}
|
||||
/>
|
||||
<span style={{ color: '#66bb6a', fontSize: '0.8em' }}>
|
||||
{LL.LATEST_VERSION(usingDevVersion ? LL.DEVELOPMENT() : LL.STABLE())}
|
||||
</span>
|
||||
<Button
|
||||
sx={{ ml: 1 }}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={() => showFirmwareDialog(showingDev)}
|
||||
>
|
||||
{LL.REINSTALL()}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (!me.admin) return null;
|
||||
|
||||
const isUpdateAvailable = choice === LL.UPDATE_AVAILABLE();
|
||||
|
||||
return (
|
||||
<Button
|
||||
sx={{ ml: 1 }}
|
||||
variant="outlined"
|
||||
color={isUpdateAvailable ? 'success' : 'warning'}
|
||||
size="small"
|
||||
onClick={() => showFirmwareDialog(showingDev)}
|
||||
>
|
||||
{choice}
|
||||
{isUpdateAvailable && (
|
||||
<Box
|
||||
component="span"
|
||||
aria-label="update available"
|
||||
sx={{
|
||||
display: 'inline-block',
|
||||
width: 8,
|
||||
height: 8,
|
||||
ml: 1,
|
||||
verticalAlign: 'middle',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: '#ffeb3b',
|
||||
boxShadow: '0 0 6px rgba(255, 235, 59, 0.8)'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
if (restarting) {
|
||||
return <SystemMonitor />;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<SectionContent>
|
||||
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
|
||||
</SectionContent>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionContent>
|
||||
<Box sx={{ p: 2, border: '1px solid #565656', borderRadius: 2 }}>
|
||||
<Typography sx={{ mb: 1 }} variant="h6" color="primary">
|
||||
{LL.THIS_VERSION()}
|
||||
</Typography>
|
||||
|
||||
<Grid
|
||||
container
|
||||
direction="row"
|
||||
sx={{
|
||||
justifyContent: 'flex-start',
|
||||
alignItems: 'baseline'
|
||||
}}
|
||||
>
|
||||
<Grid size={{ xs: 4, md: 2 }}>
|
||||
<Typography color="secondary">{LL.VERSION()}</Typography>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 8, md: 10 }}>
|
||||
<Typography>
|
||||
{data.emsesp_version}
|
||||
{data.build_flags && (
|
||||
<Typography variant="caption">
|
||||
({data.build_flags})
|
||||
</Typography>
|
||||
)}
|
||||
<IconButton
|
||||
onClick={() => setPartitionVersionInfo(data.partition)}
|
||||
aria-label={LL.FIRMWARE_VERSION_INFO()}
|
||||
>
|
||||
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 4, md: 2 }}>
|
||||
<Typography color="secondary">{LL.PLATFORM()}</Typography>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 8, md: 10 }}>
|
||||
<Typography>
|
||||
{platform}
|
||||
<Typography variant="caption">
|
||||
(
|
||||
{data.psram ? (
|
||||
<CheckIcon
|
||||
color="success"
|
||||
sx={{
|
||||
fontSize: '1.5em',
|
||||
verticalAlign: 'middle'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<CloseIcon
|
||||
color="error"
|
||||
sx={{
|
||||
fontSize: '1.5em',
|
||||
verticalAlign: 'middle'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
PSRAM)
|
||||
</Typography>
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{internetLive ? (
|
||||
<>
|
||||
<Typography sx={{ mt: 4, mb: 1 }} variant="h6" color="primary">
|
||||
{LL.AVAILABLE_VERSION()}
|
||||
</Typography>
|
||||
|
||||
<Grid
|
||||
container
|
||||
direction="row"
|
||||
rowSpacing={1}
|
||||
sx={{
|
||||
justifyContent: 'flex-start',
|
||||
alignItems: 'baseline'
|
||||
}}
|
||||
>
|
||||
{otherPartitions.length > 0 && data.developer_mode && (
|
||||
<>
|
||||
<Grid size={{ xs: 4, md: 2 }}>
|
||||
<Typography color="secondary">{LL.STORED_VERSIONS()}</Typography>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 8, md: 10 }}>
|
||||
{otherPartitions.map((partition) => (
|
||||
<Typography key={partition.partition} sx={{ mb: 1 }}>
|
||||
{partition.version}
|
||||
<IconButton
|
||||
onClick={() =>
|
||||
setPartitionVersionInfo(partition.partition)
|
||||
}
|
||||
aria-label={LL.FIRMWARE_VERSION_INFO()}
|
||||
>
|
||||
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
<Button
|
||||
sx={{ ml: 0 }}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={() =>
|
||||
showPartitionDialog(
|
||||
partition.version,
|
||||
partition.partition,
|
||||
partition.install_date ?? ''
|
||||
)
|
||||
}
|
||||
>
|
||||
{LL.INSTALL()}
|
||||
</Button>
|
||||
</Typography>
|
||||
))}
|
||||
</Grid>
|
||||
</>
|
||||
)}
|
||||
<Grid size={{ xs: 4, md: 2 }}>
|
||||
<Typography color="secondary">{LL.STABLE()}</Typography>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 8, md: 10 }}>
|
||||
<Typography>
|
||||
{latestVersion?.version}
|
||||
<IconButton
|
||||
onClick={() => setShowVersionInfo(1)}
|
||||
aria-label={LL.FIRMWARE_VERSION_INFO()}
|
||||
>
|
||||
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
{showButtons(false)}
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 4, md: 2 }}>
|
||||
<Typography color="secondary">{LL.DEVELOPMENT()}</Typography>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 8, md: 10 }}>
|
||||
<Typography>
|
||||
{latestDevVersion?.version}
|
||||
<IconButton
|
||||
onClick={() => setShowVersionInfo(2)}
|
||||
aria-label={LL.FIRMWARE_VERSION_INFO()}
|
||||
>
|
||||
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
{showButtons(true)}
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</>
|
||||
) : (
|
||||
<Typography sx={{ mt: 2 }} color="warning">
|
||||
<WarningIcon color="warning" sx={{ verticalAlign: 'middle', mr: 2 }} />
|
||||
{LL.INTERNET_CONNECTION_REQUIRED()}
|
||||
</Typography>
|
||||
)}
|
||||
{me.admin && (
|
||||
<>
|
||||
<VersionInfoDialog
|
||||
showVersionInfo={showVersionInfo}
|
||||
latestVersion={latestVersion}
|
||||
latestDevVersion={latestDevVersion}
|
||||
partitionVersion={partitionVersion}
|
||||
locale={locale}
|
||||
partition={partition}
|
||||
currentPartition={data?.partition ?? ''}
|
||||
size={firmwareSize}
|
||||
LL={LL}
|
||||
onClose={handleVersionInfoClose}
|
||||
/>
|
||||
<InstallDialog
|
||||
openInstallDialog={openInstallDialog}
|
||||
fetchDevVersion={fetchDevVersion}
|
||||
latestVersion={latestVersion}
|
||||
latestDevVersion={latestDevVersion}
|
||||
upgradeImportantMessageType={upgradeImportantMessageType}
|
||||
platform={platform}
|
||||
LL={LL}
|
||||
onClose={closeInstallDialog}
|
||||
onInstall={installFirmwareURL}
|
||||
/>
|
||||
<InstallPartitionDialog
|
||||
openInstallPartitionDialog={openInstallPartitionDialog}
|
||||
version={partitionVersion?.version || ''}
|
||||
partition={partition}
|
||||
LL={LL}
|
||||
onClose={closeInstallPartitionDialog}
|
||||
onInstall={installPartitionFirmware}
|
||||
/>
|
||||
<Typography sx={{ pt: 2, pb: 2 }} variant="h6" color="primary">
|
||||
{LL.UPLOAD()}
|
||||
</Typography>
|
||||
<SingleUpload doRestart={doRestart} />
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{me.admin && (
|
||||
<>
|
||||
<Dialog
|
||||
sx={dialogStyle}
|
||||
open={confirmFactoryReset}
|
||||
onClose={handleFactoryResetClose}
|
||||
>
|
||||
<DialogTitle>{LL.FACTORY_RESET()}</DialogTitle>
|
||||
<DialogContent dividers>{LL.SYSTEM_FACTORY_TEXT_DIALOG()}</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
startIcon={<CancelIcon />}
|
||||
variant="outlined"
|
||||
onClick={handleFactoryResetClose}
|
||||
color="secondary"
|
||||
>
|
||||
{LL.CANCEL()}
|
||||
</Button>
|
||||
<Button
|
||||
startIcon={<SettingsBackupRestoreIcon />}
|
||||
variant="outlined"
|
||||
onClick={doFormat}
|
||||
color="error"
|
||||
>
|
||||
{LL.FACTORY_RESET()}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
sx={dialogStyle}
|
||||
open={confirmRestart}
|
||||
onClose={handleRestartClose}
|
||||
>
|
||||
<DialogTitle>{LL.RESTART()}</DialogTitle>
|
||||
<DialogContent dividers>{LL.RESTART_CONFIRM()}</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
startIcon={<CancelIcon />}
|
||||
variant="outlined"
|
||||
onClick={handleRestartClose}
|
||||
color="secondary"
|
||||
>
|
||||
{LL.CANCEL()}
|
||||
</Button>
|
||||
<Button
|
||||
startIcon={<PowerSettingsNewIcon />}
|
||||
variant="outlined"
|
||||
onClick={doRestart}
|
||||
color="error"
|
||||
>
|
||||
{LL.RESTART()}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
mt: 2,
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
flexWrap: 'nowrap',
|
||||
whiteSpace: 'nowrap',
|
||||
gap: 1
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
startIcon={<PowerSettingsNewIcon />}
|
||||
variant="outlined"
|
||||
onClick={handleRestartClick}
|
||||
color="error"
|
||||
>
|
||||
{LL.RESTART()}
|
||||
</Button>
|
||||
{!data.disable_reset && (
|
||||
<Button
|
||||
startIcon={<SettingsBackupRestoreIcon />}
|
||||
variant="outlined"
|
||||
onClick={handleFactoryResetClick}
|
||||
color="error"
|
||||
>
|
||||
{LL.FACTORY_RESET()}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</SectionContent>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(Version);
|
||||
@@ -1,12 +1,5 @@
|
||||
import { memo, useCallback, useMemo, useState } from 'react';
|
||||
import {
|
||||
Navigate,
|
||||
Route,
|
||||
Routes,
|
||||
matchRoutes,
|
||||
useLocation,
|
||||
useNavigate
|
||||
} from 'react-router';
|
||||
import { memo, useState } from 'react';
|
||||
import { Outlet, useMatch, useNavigate } from 'react-router';
|
||||
|
||||
import { Tab } from '@mui/material';
|
||||
|
||||
@@ -14,52 +7,32 @@ import { RouterTabs, useLayoutTitle } from 'components';
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
import type { WiFiNetwork } from 'types';
|
||||
|
||||
import NetworkSettings from './NetworkSettings';
|
||||
import { WiFiConnectionContext } from './WiFiConnectionContext';
|
||||
import WiFiNetworkScanner from './WiFiNetworkScanner';
|
||||
|
||||
const Network = () => {
|
||||
const { LL } = useI18nContext();
|
||||
useLayoutTitle(LL.NETWORK(0));
|
||||
|
||||
// this also works!
|
||||
// const routerTab = useMatch(`settings/network/:path/*`)?.pathname || false;
|
||||
const matchedRoutes = matchRoutes(
|
||||
[
|
||||
{
|
||||
path: '/settings/network/settings',
|
||||
element: <NetworkSettings />
|
||||
},
|
||||
{ path: '/settings/network/scan', element: <WiFiNetworkScanner /> }
|
||||
],
|
||||
useLocation()
|
||||
);
|
||||
const routerTab = matchedRoutes?.[0]?.route.path || false;
|
||||
const routerTab = useMatch('/settings/network/:tab')?.pathname || false;
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [selectedNetwork, setSelectedNetwork] = useState<WiFiNetwork>();
|
||||
|
||||
const selectNetwork = useCallback(
|
||||
(network: WiFiNetwork) => {
|
||||
setSelectedNetwork(network);
|
||||
void navigate('/settings/network/settings');
|
||||
},
|
||||
[navigate]
|
||||
);
|
||||
const selectNetwork = (network: WiFiNetwork) => {
|
||||
setSelectedNetwork(network);
|
||||
void navigate('/settings/network/settings');
|
||||
};
|
||||
|
||||
const deselectNetwork = useCallback(() => {
|
||||
const deselectNetwork = () => {
|
||||
setSelectedNetwork(undefined);
|
||||
}, []);
|
||||
};
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
...(selectedNetwork && { selectedNetwork }),
|
||||
selectNetwork,
|
||||
deselectNetwork
|
||||
}),
|
||||
[selectedNetwork, selectNetwork, deselectNetwork]
|
||||
);
|
||||
const contextValue = {
|
||||
...(selectedNetwork && { selectedNetwork }),
|
||||
selectNetwork,
|
||||
deselectNetwork
|
||||
};
|
||||
|
||||
return (
|
||||
<WiFiConnectionContext.Provider value={contextValue}>
|
||||
@@ -70,14 +43,7 @@ const Network = () => {
|
||||
/>
|
||||
<Tab value="/settings/network/scan" label={LL.NETWORK_SCAN()} />
|
||||
</RouterTabs>
|
||||
<Routes>
|
||||
<Route path="scan" element={<WiFiNetworkScanner />} />
|
||||
<Route path="settings" element={<NetworkSettings />} />
|
||||
<Route
|
||||
path="*"
|
||||
element={<Navigate replace to="/settings/network/settings" />}
|
||||
/>
|
||||
</Routes>
|
||||
<Outlet />
|
||||
</WiFiConnectionContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { memo, useCallback, useContext, useEffect, useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
@@ -26,7 +25,6 @@ import { API } from 'api/app';
|
||||
|
||||
import { updateState, useRequest } from 'alova/client';
|
||||
import type { APIcall } from 'app/main/types';
|
||||
import type { ValidateFieldsError } from 'async-validator';
|
||||
import {
|
||||
BlockFormControlLabel,
|
||||
BlockNavigation,
|
||||
@@ -37,11 +35,13 @@ import {
|
||||
ValidatedPasswordField,
|
||||
ValidatedTextField
|
||||
} from 'components';
|
||||
import { toast } from 'components/toast';
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
import type { NetworkSettingsType } from 'types';
|
||||
import { updateValueDirty, useRest } from 'utils';
|
||||
import { validate } from 'validators';
|
||||
import { ValidationError, validate } from 'validators';
|
||||
import { createNetworkSettingsValidator } from 'validators/network';
|
||||
import type { ValidateFieldsError } from 'validators/schema';
|
||||
|
||||
import SystemMonitor from '../../status/SystemMonitor';
|
||||
import { WiFiConnectionContext } from './WiFiConnectionContext';
|
||||
@@ -89,7 +89,7 @@ const NetworkSettings = () => {
|
||||
static_ip_config: false,
|
||||
bandwidth20: false,
|
||||
tx_power: 0,
|
||||
nosleep: false,
|
||||
nosleep: true,
|
||||
enableMDNS: true,
|
||||
enableCORS: false,
|
||||
CORSOrigin: '*'
|
||||
@@ -116,24 +116,24 @@ const NetworkSettings = () => {
|
||||
await validate(createNetworkSettingsValidator(data), data);
|
||||
await saveData();
|
||||
} catch (error) {
|
||||
setFieldErrors(error as ValidateFieldsError);
|
||||
setFieldErrors((error as ValidationError).fieldErrors);
|
||||
}
|
||||
deselectNetwork();
|
||||
}, [data, saveData, deselectNetwork]);
|
||||
|
||||
const setCancel = useCallback(async () => {
|
||||
const setCancel = async () => {
|
||||
deselectNetwork();
|
||||
await loadData();
|
||||
}, [deselectNetwork, loadData]);
|
||||
};
|
||||
|
||||
const doRestart = useCallback(async () => {
|
||||
const doRestart = async () => {
|
||||
setRestarting(true);
|
||||
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
|
||||
(error: Error) => {
|
||||
toast.error(error.message);
|
||||
}
|
||||
);
|
||||
}, [sendAPI]);
|
||||
};
|
||||
|
||||
const content = () => {
|
||||
if (!data) {
|
||||
@@ -173,7 +173,7 @@ const NetworkSettings = () => {
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
name="ssid"
|
||||
label={'SSID (' + LL.NETWORK_BLANK_SSID() + ')'}
|
||||
label="SSID"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={data.ssid}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useCallback, useRef, useState } from 'react';
|
||||
import { memo, useRef, useState } from 'react';
|
||||
|
||||
import PermScanWifiIcon from '@mui/icons-material/PermScanWifi';
|
||||
import { Button } from '@mui/material';
|
||||
@@ -48,12 +48,12 @@ const WiFiNetworkScanner = () => {
|
||||
}
|
||||
});
|
||||
|
||||
const renderNetworkScanner = useCallback(() => {
|
||||
const renderNetworkScanner = () => {
|
||||
if (!networkList) {
|
||||
return <FormLoader errorMessage={errorMessage || ''} />;
|
||||
}
|
||||
return <WiFiNetworkSelector networkList={networkList} />;
|
||||
}, [networkList, errorMessage]);
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionContent>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useCallback, useContext } from 'react';
|
||||
import { memo, useContext } from 'react';
|
||||
|
||||
import LockIcon from '@mui/icons-material/Lock';
|
||||
import LockOpenIcon from '@mui/icons-material/LockOpen';
|
||||
@@ -63,34 +63,31 @@ const WiFiNetworkSelector = ({ networkList }: { networkList: WiFiNetworkList })
|
||||
|
||||
const wifiConnectionContext = useContext(WiFiConnectionContext);
|
||||
|
||||
const renderNetwork = useCallback(
|
||||
(network: WiFiNetwork) => (
|
||||
<ListItem
|
||||
key={network.bssid}
|
||||
onClick={() => wifiConnectionContext.selectNetwork(network)}
|
||||
>
|
||||
<ListItemAvatar>
|
||||
<Avatar>{isNetworkOpen(network) ? <LockOpenIcon /> : <LockIcon />}</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary={network.ssid}
|
||||
secondary={
|
||||
'Security: ' +
|
||||
networkSecurityMode(network) +
|
||||
', Ch: ' +
|
||||
network.channel +
|
||||
', bssid: ' +
|
||||
network.bssid
|
||||
}
|
||||
/>
|
||||
<ListItemIcon>
|
||||
<Badge badgeContent={network.rssi + 'dBm'}>
|
||||
<WifiIcon sx={{ color: networkQualityHighlight(network, theme) }} />
|
||||
</Badge>
|
||||
</ListItemIcon>
|
||||
</ListItem>
|
||||
),
|
||||
[wifiConnectionContext, theme]
|
||||
const renderNetwork = (network: WiFiNetwork) => (
|
||||
<ListItem
|
||||
key={network.bssid}
|
||||
onClick={() => wifiConnectionContext.selectNetwork(network)}
|
||||
>
|
||||
<ListItemAvatar>
|
||||
<Avatar>{isNetworkOpen(network) ? <LockOpenIcon /> : <LockIcon />}</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary={network.ssid}
|
||||
secondary={
|
||||
'Security: ' +
|
||||
networkSecurityMode(network) +
|
||||
', Ch: ' +
|
||||
network.channel +
|
||||
', bssid: ' +
|
||||
network.bssid
|
||||
}
|
||||
/>
|
||||
<ListItemIcon>
|
||||
<Badge badgeContent={network.rssi + 'dBm'}>
|
||||
<WifiIcon sx={{ color: networkQualityHighlight(network, theme) }} />
|
||||
</Badge>
|
||||
</ListItemIcon>
|
||||
</ListItem>
|
||||
);
|
||||
|
||||
if (networkList.networks.length === 0) {
|
||||
|
||||
@@ -54,19 +54,27 @@ const GenerateToken = ({ username, onClose }: GenerateTokenProps) => {
|
||||
<DialogContent dividers>
|
||||
{token ? (
|
||||
<>
|
||||
<MessageBox message={LL.ACCESS_TOKEN_TEXT()} level="info" my={2} />
|
||||
<Box mt={2} mb={2}>
|
||||
<MessageBox
|
||||
message={LL.ACCESS_TOKEN_TEXT()}
|
||||
level="info"
|
||||
sx={{ mt: 2, mb: 2 }}
|
||||
/>
|
||||
<Box sx={{ mt: 2, mb: 2 }}>
|
||||
<TextField
|
||||
label="Token"
|
||||
multiline
|
||||
value={token.token}
|
||||
fullWidth
|
||||
contentEditable={false}
|
||||
slotProps={{
|
||||
input: {
|
||||
readOnly: true
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
) : (
|
||||
<Box m={4} textAlign="center">
|
||||
<Box sx={{ m: 4, textAlign: 'center' }}>
|
||||
<LinearProgress />
|
||||
<Typography variant="h6">{LL.GENERATING_TOKEN()}…</Typography>
|
||||
</Box>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useCallback, useContext, useMemo, useState } from 'react';
|
||||
import { memo, useCallback, useContext, useState } from 'react';
|
||||
import { useBlocker } from 'react-router';
|
||||
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
@@ -55,16 +55,14 @@ const ManageUsers = () => {
|
||||
const blocker = useBlocker(changed !== 0);
|
||||
const { LL } = useI18nContext();
|
||||
|
||||
const table_theme = useMemo(
|
||||
() =>
|
||||
useTheme({
|
||||
Table: `
|
||||
const table_theme = useTheme({
|
||||
Table: `
|
||||
--data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) minmax(120px, max-content) 120px;
|
||||
`,
|
||||
BaseRow: `
|
||||
BaseRow: `
|
||||
font-size: 14px;
|
||||
`,
|
||||
HeaderRow: `
|
||||
HeaderRow: `
|
||||
text-transform: uppercase;
|
||||
background-color: black;
|
||||
color: #90CAF9;
|
||||
@@ -74,7 +72,7 @@ const ManageUsers = () => {
|
||||
border-bottom: 1px solid #565656;
|
||||
}
|
||||
`,
|
||||
Row: `
|
||||
Row: `
|
||||
.td {
|
||||
padding: 8px;
|
||||
border-top: 1px solid #565656;
|
||||
@@ -87,7 +85,7 @@ const ManageUsers = () => {
|
||||
background-color: #1e1e1e;
|
||||
}
|
||||
`,
|
||||
BaseCell: `
|
||||
BaseCell: `
|
||||
&:nth-of-type(2) {
|
||||
text-align: center;
|
||||
}
|
||||
@@ -95,44 +93,36 @@ const ManageUsers = () => {
|
||||
text-align: right;
|
||||
}
|
||||
`
|
||||
}),
|
||||
[]
|
||||
);
|
||||
});
|
||||
|
||||
const noAdminConfigured = useCallback(
|
||||
() => !data?.users.find((u) => u.admin),
|
||||
[data]
|
||||
);
|
||||
const noAdminConfigured = () => !data?.users.find((u) => u.admin);
|
||||
|
||||
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 removeUser = (toRemove: UserType) => {
|
||||
if (!data) return;
|
||||
const users = data.users.filter((u) => u.username !== toRemove.username);
|
||||
updateDataValue({ ...data, users });
|
||||
setChanged(changed + 1);
|
||||
};
|
||||
|
||||
const createUser = useCallback(() => {
|
||||
const createUser = () => {
|
||||
setCreating(true);
|
||||
setUser({
|
||||
username: '',
|
||||
password: '',
|
||||
admin: true
|
||||
});
|
||||
}, []);
|
||||
};
|
||||
|
||||
const editUser = useCallback((toEdit: UserType) => {
|
||||
const editUser = (toEdit: UserType) => {
|
||||
setCreating(false);
|
||||
setUser({ ...toEdit });
|
||||
}, []);
|
||||
};
|
||||
|
||||
const cancelEditingUser = useCallback(() => {
|
||||
const cancelEditingUser = () => {
|
||||
setUser(undefined);
|
||||
}, []);
|
||||
};
|
||||
|
||||
const doneEditingUser = useCallback(() => {
|
||||
const doneEditingUser = () => {
|
||||
if (user && data) {
|
||||
const users = [
|
||||
...data.users.filter(
|
||||
@@ -144,26 +134,26 @@ const ManageUsers = () => {
|
||||
setUser(undefined);
|
||||
setChanged(changed + 1);
|
||||
}
|
||||
}, [user, data, updateDataValue, changed]);
|
||||
};
|
||||
|
||||
const closeGenerateToken = useCallback(() => {
|
||||
setGeneratingToken(undefined);
|
||||
}, []);
|
||||
|
||||
const generateTokenForUser = useCallback((username: string) => {
|
||||
const generateTokenForUser = (username: string) => {
|
||||
setGeneratingToken(username);
|
||||
}, []);
|
||||
};
|
||||
|
||||
const onSubmit = useCallback(async () => {
|
||||
const onSubmit = async () => {
|
||||
await saveData();
|
||||
await authenticatedContext.refresh();
|
||||
setChanged(0);
|
||||
}, [saveData, authenticatedContext]);
|
||||
};
|
||||
|
||||
const onCancelSubmit = useCallback(async () => {
|
||||
const onCancelSubmit = async () => {
|
||||
await loadData();
|
||||
setChanged(0);
|
||||
}, [loadData]);
|
||||
};
|
||||
|
||||
const content = () => {
|
||||
if (!data) {
|
||||
@@ -177,15 +167,10 @@ const ManageUsers = () => {
|
||||
admin: boolean;
|
||||
}
|
||||
|
||||
// add id to the type, needed for the table
|
||||
const user_table = useMemo(
|
||||
() =>
|
||||
data.users.map((u) => ({
|
||||
...u,
|
||||
id: u.username
|
||||
})) as UserType2[],
|
||||
[data.users]
|
||||
);
|
||||
const user_table = data.users.map((u) => ({
|
||||
...u,
|
||||
id: u.username
|
||||
})) as UserType2[];
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -240,12 +225,16 @@ const ManageUsers = () => {
|
||||
</Table>
|
||||
|
||||
{noAdminConfigured() && (
|
||||
<MessageBox level="warning" message={LL.USER_WARNING()} my={2} />
|
||||
<MessageBox
|
||||
level="warning"
|
||||
message={LL.USER_WARNING()}
|
||||
sx={{ mt: 2, mb: 2 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Box display="flex" flexWrap="wrap">
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap' }}>
|
||||
{changed !== 0 && (
|
||||
<Box flexGrow={1} sx={{ '& button': { mt: 2 } }}>
|
||||
<Box sx={{ flexGrow: 1, '& button': { mt: 2 } }}>
|
||||
<ButtonRow>
|
||||
<Button
|
||||
startIcon={<CancelIcon />}
|
||||
@@ -270,7 +259,7 @@ const ManageUsers = () => {
|
||||
</ButtonRow>
|
||||
</Box>
|
||||
)}
|
||||
<Box flexWrap="nowrap" whiteSpace="nowrap">
|
||||
<Box sx={{ flexWrap: 'nowrap', whiteSpace: 'nowrap' }}>
|
||||
<ButtonRow>
|
||||
<Button
|
||||
startIcon={<PersonAddIcon />}
|
||||
|
||||
@@ -1,35 +1,16 @@
|
||||
import { memo, useMemo } from 'react';
|
||||
import { Navigate, Route, Routes, matchRoutes, useLocation } from 'react-router';
|
||||
import { memo } from 'react';
|
||||
import { Outlet, useMatch } from 'react-router';
|
||||
|
||||
import { Tab } from '@mui/material';
|
||||
|
||||
import { RouterTabs, useLayoutTitle } from 'components';
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
|
||||
import ManageUsers from './ManageUsers';
|
||||
import SecuritySettings from './SecuritySettings';
|
||||
|
||||
const Security = () => {
|
||||
const { LL } = useI18nContext();
|
||||
useLayoutTitle(LL.SECURITY(0));
|
||||
|
||||
const location = useLocation();
|
||||
|
||||
const matchedRoutes = useMemo(
|
||||
() =>
|
||||
matchRoutes(
|
||||
[
|
||||
{
|
||||
path: '/settings/security/settings',
|
||||
element: <ManageUsers />
|
||||
},
|
||||
{ path: '/settings/security/users', element: <SecuritySettings /> }
|
||||
],
|
||||
location
|
||||
),
|
||||
[location]
|
||||
);
|
||||
const routerTab = matchedRoutes?.[0]?.route.path || false;
|
||||
const routerTab = useMatch('/settings/security/:tab')?.pathname || false;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -40,14 +21,7 @@ const Security = () => {
|
||||
/>
|
||||
<Tab value="/settings/security/users" label={LL.MANAGE_USERS()} />
|
||||
</RouterTabs>
|
||||
<Routes>
|
||||
<Route path="users" element={<ManageUsers />} />
|
||||
<Route path="settings" element={<SecuritySettings />} />
|
||||
<Route
|
||||
path="*"
|
||||
element={<Navigate replace to="/settings/security/settings" />}
|
||||
/>
|
||||
</Routes>
|
||||
<Outlet />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,7 +6,6 @@ import { Button } from '@mui/material';
|
||||
|
||||
import * as SecurityApi from 'api/security';
|
||||
|
||||
import type { ValidateFieldsError } from 'async-validator';
|
||||
import {
|
||||
BlockNavigation,
|
||||
ButtonRow,
|
||||
@@ -19,7 +18,8 @@ import { AuthenticatedContext } from 'contexts/authentication';
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
import type { SecuritySettingsType } from 'types';
|
||||
import { updateValueDirty, useRest } from 'utils';
|
||||
import { SECURITY_SETTINGS_VALIDATOR, validate } from 'validators';
|
||||
import { SECURITY_SETTINGS_VALIDATOR, ValidationError, validate } from 'validators';
|
||||
import type { ValidateFieldsError } from 'validators/schema';
|
||||
|
||||
const SecuritySettings = () => {
|
||||
const { LL } = useI18nContext();
|
||||
@@ -58,7 +58,7 @@ const SecuritySettings = () => {
|
||||
await saveData();
|
||||
await authenticatedContext.refresh();
|
||||
} catch (error) {
|
||||
setFieldErrors(error as ValidateFieldsError);
|
||||
setFieldErrors((error as ValidationError).fieldErrors);
|
||||
}
|
||||
}, [data, saveData, authenticatedContext]);
|
||||
|
||||
@@ -79,7 +79,7 @@ const SecuritySettings = () => {
|
||||
onChange={updateFormValue}
|
||||
margin="normal"
|
||||
/>
|
||||
<MessageBox level="info" message={LL.SU_TEXT()} mt={1} />
|
||||
<MessageBox level="info" message={LL.SU_TEXT()} sx={{ mt: 1 }} />
|
||||
{dirtyFlags && dirtyFlags.length !== 0 && (
|
||||
<ButtonRow>
|
||||
<Button
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useCallback, useEffect, useState } from 'react';
|
||||
import { memo, useEffect, useState } from 'react';
|
||||
import type { FC } from 'react';
|
||||
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
@@ -14,8 +14,6 @@ import {
|
||||
} from '@mui/material';
|
||||
|
||||
import { dialogStyle } from 'CustomTheme';
|
||||
import type Schema from 'async-validator';
|
||||
import type { ValidateFieldsError } from 'async-validator';
|
||||
import {
|
||||
BlockFormControlLabel,
|
||||
ValidatedPasswordField,
|
||||
@@ -24,7 +22,9 @@ import {
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
import type { UserType } from 'types';
|
||||
import { updateValue } from 'utils';
|
||||
import { validate } from 'validators';
|
||||
import { ValidationError, validate } from 'validators';
|
||||
import type Schema from 'validators/schema';
|
||||
import type { ValidateFieldsError } from 'validators/schema';
|
||||
|
||||
interface UserFormProps {
|
||||
creating: boolean;
|
||||
@@ -62,17 +62,17 @@ const User: FC<UserFormProps> = ({
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const validateAndDone = useCallback(async () => {
|
||||
const validateAndDone = async () => {
|
||||
if (user) {
|
||||
try {
|
||||
setFieldErrors(undefined);
|
||||
await validate(validator, user);
|
||||
onDoneEditing();
|
||||
} catch (error) {
|
||||
setFieldErrors(error as ValidateFieldsError);
|
||||
setFieldErrors((error as ValidationError).fieldErrors);
|
||||
}
|
||||
}
|
||||
}, [user, validator, onDoneEditing]);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import {
|
||||
Body,
|
||||
Cell,
|
||||
@@ -36,16 +34,14 @@ const SystemActivity = () => {
|
||||
|
||||
useLayoutTitle(LL.DATA_TRAFFIC());
|
||||
|
||||
const stats_theme = tableTheme(
|
||||
useMemo(
|
||||
() => ({
|
||||
Table: `
|
||||
const stats_theme = tableTheme({
|
||||
Table: `
|
||||
--data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) 90px 90px 80px;
|
||||
`,
|
||||
BaseRow: `
|
||||
BaseRow: `
|
||||
font-size: 14px;
|
||||
`,
|
||||
HeaderRow: `
|
||||
HeaderRow: `
|
||||
text-transform: uppercase;
|
||||
background-color: black;
|
||||
color: #90CAF9;
|
||||
@@ -55,7 +51,7 @@ const SystemActivity = () => {
|
||||
border-bottom: 1px solid #565656;
|
||||
}
|
||||
`,
|
||||
Row: `
|
||||
Row: `
|
||||
.td {
|
||||
padding: 8px;
|
||||
border-top: 1px solid #565656;
|
||||
@@ -69,26 +65,20 @@ const SystemActivity = () => {
|
||||
background-color: #1e1e1e;
|
||||
}
|
||||
`,
|
||||
BaseCell: `
|
||||
BaseCell: `
|
||||
&:not(:first-of-type) {
|
||||
text-align: center;
|
||||
}
|
||||
`
|
||||
}),
|
||||
[]
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
const showName = useCallback(
|
||||
(id: number) => {
|
||||
const name: keyof Translation['STATUS_NAMES'] =
|
||||
id.toString() as keyof Translation['STATUS_NAMES'];
|
||||
return LL.STATUS_NAMES[name]();
|
||||
},
|
||||
[LL]
|
||||
);
|
||||
const showName = (id: number) => {
|
||||
const name: keyof Translation['STATUS_NAMES'] =
|
||||
id.toString() as keyof Translation['STATUS_NAMES'];
|
||||
return LL.STATUS_NAMES[name]();
|
||||
};
|
||||
|
||||
const showQuality = useCallback((stat: Stat) => {
|
||||
const showQuality = (stat: Stat) => {
|
||||
if (stat.q === 0 || stat.s + stat.f === 0) {
|
||||
return;
|
||||
}
|
||||
@@ -100,14 +90,18 @@ const SystemActivity = () => {
|
||||
} else {
|
||||
return <div style={{ color: QUALITY_COLORS.POOR }}>{stat.q}%</div>;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const content = useMemo(() => {
|
||||
if (!data) {
|
||||
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
|
||||
}
|
||||
};
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<SectionContent>
|
||||
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
|
||||
</SectionContent>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionContent>
|
||||
<Table
|
||||
data={{ nodes: data.stats }}
|
||||
theme={stats_theme}
|
||||
@@ -136,10 +130,8 @@ const SystemActivity = () => {
|
||||
</>
|
||||
)}
|
||||
</Table>
|
||||
);
|
||||
}, [data, loadData, error?.message, stats_theme, LL, showName, showQuality]);
|
||||
|
||||
return <SectionContent>{content}</SectionContent>;
|
||||
</SectionContent>
|
||||
);
|
||||
};
|
||||
|
||||
export default SystemActivity;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type FC, memo, useMemo } from 'react';
|
||||
import { type FC, memo } from 'react';
|
||||
|
||||
import AutoAwesomeMotionIcon from '@mui/icons-material/AutoAwesomeMotion';
|
||||
import DeviceHubIcon from '@mui/icons-material/DeviceHub';
|
||||
@@ -127,16 +127,15 @@ const MqttStatus = () => {
|
||||
void loadData();
|
||||
});
|
||||
|
||||
// Memoize error message separately to avoid re-renders on error object changes
|
||||
const errorMessage = error?.message || '';
|
||||
|
||||
const mqttStatusText = useMemo(() => {
|
||||
if (!data) return '';
|
||||
if (!data.enabled) return LL.NOT_ENABLED();
|
||||
return data.connected
|
||||
? `${LL.CONNECTED(0)} (${data.connect_count})`
|
||||
: `${LL.DISCONNECTED()} (${data.connect_count})`;
|
||||
}, [data, LL]);
|
||||
const mqttStatusText = !data
|
||||
? ''
|
||||
: !data.enabled
|
||||
? LL.NOT_ENABLED()
|
||||
: data.connected
|
||||
? `${LL.CONNECTED(0)} (${data.connect_count})`
|
||||
: `${LL.DISCONNECTED()} (${data.connect_count})`;
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
||||
import DnsIcon from '@mui/icons-material/Dns';
|
||||
import SwapVerticalCircleIcon from '@mui/icons-material/SwapVerticalCircle';
|
||||
@@ -67,12 +65,16 @@ const NTPStatus = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const content = useMemo(() => {
|
||||
if (!data) {
|
||||
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<SectionContent>
|
||||
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
|
||||
</SectionContent>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionContent>
|
||||
<List>
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
@@ -121,10 +123,8 @@ const NTPStatus = () => {
|
||||
</ListItem>
|
||||
<Divider variant="inset" component="li" />
|
||||
</List>
|
||||
);
|
||||
}, [data, error, loadData, LL, theme]);
|
||||
|
||||
return <SectionContent>{content}</SectionContent>;
|
||||
</SectionContent>
|
||||
);
|
||||
};
|
||||
|
||||
export default NTPStatus;
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import DeviceHubIcon from '@mui/icons-material/DeviceHub';
|
||||
import DnsIcon from '@mui/icons-material/Dns';
|
||||
import GiteIcon from '@mui/icons-material/Gite';
|
||||
@@ -124,16 +122,20 @@ const NetworkStatus = () => {
|
||||
|
||||
const theme = useTheme();
|
||||
|
||||
const content = useMemo(() => {
|
||||
if (!data) {
|
||||
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
|
||||
}
|
||||
|
||||
const statusText = getNetworkStatusText(data.status, data.reconnect_count, LL);
|
||||
const statusColor = networkStatusHighlight(data, theme);
|
||||
const qualityColor = networkQualityHighlight(data, theme);
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<SectionContent>
|
||||
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
|
||||
</SectionContent>
|
||||
);
|
||||
}
|
||||
|
||||
const statusText = getNetworkStatusText(data.status, data.reconnect_count, LL);
|
||||
const statusColor = networkStatusHighlight(data, theme);
|
||||
const qualityColor = networkQualityHighlight(data, theme);
|
||||
|
||||
return (
|
||||
<SectionContent>
|
||||
<List>
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
@@ -227,10 +229,8 @@ const NetworkStatus = () => {
|
||||
</>
|
||||
)}
|
||||
</List>
|
||||
);
|
||||
}, [data, error, loadData, LL, theme]);
|
||||
|
||||
return <SectionContent>{content}</SectionContent>;
|
||||
</SectionContent>
|
||||
);
|
||||
};
|
||||
|
||||
export default NetworkStatus;
|
||||
|
||||
+108
-273
@@ -1,25 +1,16 @@
|
||||
import { useCallback, useContext, useMemo, useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useContext } from 'react';
|
||||
|
||||
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
||||
import BuildIcon from '@mui/icons-material/Build';
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import DeviceHubIcon from '@mui/icons-material/DeviceHub';
|
||||
import DirectionsBusIcon from '@mui/icons-material/DirectionsBus';
|
||||
import LogoDevIcon from '@mui/icons-material/LogoDev';
|
||||
import MemoryIcon from '@mui/icons-material/Memory';
|
||||
import MonitorHeartIcon from '@mui/icons-material/MonitorHeart';
|
||||
import PowerSettingsNewIcon from '@mui/icons-material/PowerSettingsNew';
|
||||
import RouterIcon from '@mui/icons-material/Router';
|
||||
import SettingsInputAntennaIcon from '@mui/icons-material/SettingsInputAntenna';
|
||||
import WifiIcon from '@mui/icons-material/Wifi';
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemAvatar,
|
||||
@@ -27,12 +18,10 @@ import {
|
||||
useTheme
|
||||
} from '@mui/material';
|
||||
|
||||
import { API } from 'api/app';
|
||||
import { readSystemStatus } from 'api/system';
|
||||
|
||||
import { dialogStyle } from 'CustomTheme';
|
||||
import { useRequest } from 'alova/client';
|
||||
import { type APIcall, busConnectionStatus } from 'app/main/types';
|
||||
import { busConnectionStatus } from 'app/main/types';
|
||||
import { FormLoader, SectionContent, useLayoutTitle } from 'components';
|
||||
import ListMenuItem from 'components/layout/ListMenuItem';
|
||||
import { AuthenticatedContext } from 'contexts/authentication';
|
||||
@@ -41,9 +30,6 @@ import { NTPSyncStatus, NetworkConnectionStatus, SystemStatusCodes } from 'types
|
||||
import { useInterval } from 'utils';
|
||||
import { formatDateTime } from 'utils/time';
|
||||
|
||||
import SystemMonitor from './SystemMonitor';
|
||||
|
||||
// Pure functions moved outside component to avoid recreation on each render
|
||||
const formatNumber = (num: number) => new Intl.NumberFormat().format(num);
|
||||
|
||||
const formatDurationSec = (
|
||||
@@ -72,24 +58,7 @@ const SystemStatus = () => {
|
||||
|
||||
const { me } = useContext(AuthenticatedContext);
|
||||
|
||||
const [confirmRestart, setConfirmRestart] = useState<boolean>(false);
|
||||
const [restarting, setRestarting] = useState<boolean>();
|
||||
|
||||
const { send: sendAPI } = useRequest((data: APIcall) => API(data), {
|
||||
immediate: false
|
||||
});
|
||||
|
||||
const {
|
||||
data,
|
||||
send: loadData,
|
||||
error
|
||||
} = useRequest(readSystemStatus, {
|
||||
async middleware(_, next) {
|
||||
if (!restarting) {
|
||||
await next();
|
||||
}
|
||||
}
|
||||
});
|
||||
const { data, send: loadData, error } = useRequest(readSystemStatus);
|
||||
|
||||
useInterval(() => {
|
||||
void loadData();
|
||||
@@ -97,10 +66,8 @@ const SystemStatus = () => {
|
||||
|
||||
const theme = useTheme();
|
||||
|
||||
// Memoize derived status values to avoid recalculation on every render
|
||||
const busStatus = useMemo(() => {
|
||||
const busStatus = (() => {
|
||||
if (!data) return 'EMS state unknown';
|
||||
|
||||
switch (data.bus_status) {
|
||||
case busConnectionStatus.BUS_STATUS_CONNECTED:
|
||||
return `EMS ${LL.CONNECTED(0)} (${formatDurationSec(data.bus_uptime, LL)})`;
|
||||
@@ -111,12 +78,10 @@ const SystemStatus = () => {
|
||||
default:
|
||||
return 'EMS state unknown';
|
||||
}
|
||||
}, [data?.bus_status, data?.bus_uptime, LL]);
|
||||
})();
|
||||
|
||||
// Memoize derived status values to avoid recalculation on every render
|
||||
const systemStatus = useMemo(() => {
|
||||
const systemStatus = (() => {
|
||||
if (!data) return '??';
|
||||
|
||||
switch (data.status) {
|
||||
case SystemStatusCodes.SYSTEM_STATUS_PENDING_UPLOAD:
|
||||
case SystemStatusCodes.SYSTEM_STATUS_UPLOADING:
|
||||
@@ -129,14 +94,12 @@ const SystemStatus = () => {
|
||||
case SystemStatusCodes.SYSTEM_STATUS_INVALID_GPIO:
|
||||
return LL.GPIO_OF(LL.FAILED(0));
|
||||
default:
|
||||
// SystemStatusCodes.SYSTEM_STATUS_NORMAL
|
||||
return 'OK';
|
||||
}
|
||||
}, [data?.status, LL]);
|
||||
})();
|
||||
|
||||
const busStatusHighlight = useMemo(() => {
|
||||
const busStatusHighlight = (() => {
|
||||
if (!data) return theme.palette.warning.main;
|
||||
|
||||
switch (data.bus_status) {
|
||||
case busConnectionStatus.BUS_STATUS_TX_ERRORS:
|
||||
return theme.palette.warning.main;
|
||||
@@ -147,11 +110,10 @@ const SystemStatus = () => {
|
||||
default:
|
||||
return theme.palette.warning.main;
|
||||
}
|
||||
}, [data?.bus_status, theme.palette]);
|
||||
})();
|
||||
|
||||
const ntpStatus = useMemo(() => {
|
||||
const ntpStatus = (() => {
|
||||
if (!data) return LL.UNKNOWN();
|
||||
|
||||
switch (data.ntp_status) {
|
||||
case NTPSyncStatus.NTP_DISABLED:
|
||||
return LL.NOT_ENABLED();
|
||||
@@ -164,11 +126,10 @@ const SystemStatus = () => {
|
||||
default:
|
||||
return LL.UNKNOWN();
|
||||
}
|
||||
}, [data?.ntp_status, data?.ntp_time, LL]);
|
||||
})();
|
||||
|
||||
const ntpStatusHighlight = useMemo(() => {
|
||||
const ntpStatusHighlight = (() => {
|
||||
if (!data) return theme.palette.error.main;
|
||||
|
||||
switch (data.ntp_status) {
|
||||
case NTPSyncStatus.NTP_DISABLED:
|
||||
return theme.palette.info.main;
|
||||
@@ -179,11 +140,10 @@ const SystemStatus = () => {
|
||||
default:
|
||||
return theme.palette.error.main;
|
||||
}
|
||||
}, [data?.ntp_status, theme.palette]);
|
||||
})();
|
||||
|
||||
const networkStatusHighlight = useMemo(() => {
|
||||
const networkStatusHighlight = (() => {
|
||||
if (!data) return theme.palette.warning.main;
|
||||
|
||||
switch (data.network_status) {
|
||||
case NetworkConnectionStatus.WIFI_STATUS_IDLE:
|
||||
case NetworkConnectionStatus.WIFI_STATUS_DISCONNECTED:
|
||||
@@ -198,11 +158,10 @@ const SystemStatus = () => {
|
||||
default:
|
||||
return theme.palette.warning.main;
|
||||
}
|
||||
}, [data?.network_status, theme.palette]);
|
||||
})();
|
||||
|
||||
const networkStatus = useMemo(() => {
|
||||
const networkStatus = (() => {
|
||||
if (!data) return LL.UNKNOWN();
|
||||
|
||||
switch (data.network_status) {
|
||||
case NetworkConnectionStatus.WIFI_STATUS_NO_SHIELD:
|
||||
return LL.INACTIVE(1);
|
||||
@@ -223,227 +182,103 @@ const SystemStatus = () => {
|
||||
default:
|
||||
return LL.UNKNOWN();
|
||||
}
|
||||
}, [data?.network_status, data?.wifi_rssi, LL]);
|
||||
})();
|
||||
|
||||
const activeHighlight = useCallback(
|
||||
(value: boolean) =>
|
||||
value ? theme.palette.success.main : theme.palette.info.main,
|
||||
[theme.palette]
|
||||
);
|
||||
|
||||
const doRestart = useCallback(async () => {
|
||||
setConfirmRestart(false);
|
||||
setRestarting(true);
|
||||
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
|
||||
(error: Error) => {
|
||||
toast.error(error.message);
|
||||
}
|
||||
);
|
||||
}, [sendAPI]);
|
||||
|
||||
const handleCloseRestartDialog = useCallback(() => {
|
||||
setConfirmRestart(false);
|
||||
}, []);
|
||||
|
||||
const renderRestartDialog = useMemo(
|
||||
() => (
|
||||
<Dialog
|
||||
sx={dialogStyle}
|
||||
open={confirmRestart}
|
||||
onClose={handleCloseRestartDialog}
|
||||
>
|
||||
<DialogTitle>{LL.RESTART()}</DialogTitle>
|
||||
<DialogContent dividers>{LL.RESTART_CONFIRM()}</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
startIcon={<CancelIcon />}
|
||||
variant="outlined"
|
||||
onClick={handleCloseRestartDialog}
|
||||
color="secondary"
|
||||
>
|
||||
{LL.CANCEL()}
|
||||
</Button>
|
||||
<Button
|
||||
startIcon={<PowerSettingsNewIcon />}
|
||||
variant="outlined"
|
||||
onClick={doRestart}
|
||||
color="error"
|
||||
>
|
||||
{LL.RESTART()}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
),
|
||||
[confirmRestart, handleCloseRestartDialog, doRestart, LL]
|
||||
);
|
||||
|
||||
// Memoize formatted values
|
||||
const firmwareVersion = useMemo(
|
||||
() => `v${data?.emsesp_version || ''}`,
|
||||
[data?.emsesp_version]
|
||||
);
|
||||
|
||||
const uptimeText = useMemo(
|
||||
() => (data ? formatDurationSec(data.uptime, LL) : ''),
|
||||
[data?.uptime, LL]
|
||||
);
|
||||
|
||||
const freeMemoryText = useMemo(
|
||||
() => (data ? `${formatNumber(data.free_heap)} KB ${LL.FREE_MEMORY()}` : ''),
|
||||
[data?.free_heap, LL]
|
||||
);
|
||||
|
||||
const networkIcon = useMemo(
|
||||
() =>
|
||||
data?.network_status === NetworkConnectionStatus.WIFI_STATUS_CONNECTED
|
||||
? WifiIcon
|
||||
: RouterIcon,
|
||||
[data?.network_status]
|
||||
);
|
||||
|
||||
const mqttStatusText = useMemo(
|
||||
() => (data?.mqtt_status ? LL.CONNECTED(0) : LL.INACTIVE(0)),
|
||||
[data?.mqtt_status, LL]
|
||||
);
|
||||
|
||||
const apStatusText = useMemo(
|
||||
() => (data?.ap_status ? LL.ACTIVE() : LL.INACTIVE(0)),
|
||||
[data?.ap_status, LL]
|
||||
);
|
||||
|
||||
const handleRestartClick = useCallback(() => {
|
||||
setConfirmRestart(true);
|
||||
}, []);
|
||||
|
||||
const content = useMemo(() => {
|
||||
if (!data || !LL) {
|
||||
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
|
||||
}
|
||||
const activeHighlight = (value: boolean) =>
|
||||
value ? theme.palette.success.main : theme.palette.info.main;
|
||||
|
||||
if (!data || !LL) {
|
||||
return (
|
||||
<>
|
||||
<List>
|
||||
<ListMenuItem
|
||||
icon={BuildIcon}
|
||||
bgcolor="#72caf9"
|
||||
label="EMS-ESP Firmware"
|
||||
text={firmwareVersion}
|
||||
to="version"
|
||||
/>
|
||||
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
<Avatar sx={{ bgcolor: '#c5572c', color: 'white' }}>
|
||||
<MonitorHeartIcon />
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary={LL.STATUS_OF(LL.SYSTEM(0))}
|
||||
secondary={`${systemStatus} (${LL.UPTIME()}: ${uptimeText})`}
|
||||
/>
|
||||
{me.admin && (
|
||||
<Button
|
||||
startIcon={<PowerSettingsNewIcon />}
|
||||
variant="outlined"
|
||||
color="error"
|
||||
onClick={handleRestartClick}
|
||||
>
|
||||
{LL.RESTART()}
|
||||
</Button>
|
||||
)}
|
||||
</ListItem>
|
||||
|
||||
<ListMenuItem
|
||||
disabled={!me.admin}
|
||||
icon={MemoryIcon}
|
||||
bgcolor="#68374d"
|
||||
label={LL.HARDWARE()}
|
||||
text={freeMemoryText}
|
||||
to="/status/hardwarestatus"
|
||||
/>
|
||||
|
||||
<ListMenuItem
|
||||
disabled={!me.admin}
|
||||
icon={DirectionsBusIcon}
|
||||
bgcolor={busStatusHighlight}
|
||||
label={LL.DATA_TRAFFIC()}
|
||||
text={busStatus}
|
||||
to="/status/activity"
|
||||
/>
|
||||
|
||||
<ListMenuItem
|
||||
disabled={!me.admin}
|
||||
icon={networkIcon}
|
||||
bgcolor={networkStatusHighlight}
|
||||
label={LL.NETWORK(1)}
|
||||
text={networkStatus}
|
||||
to="/status/network"
|
||||
/>
|
||||
|
||||
<ListMenuItem
|
||||
disabled={!me.admin}
|
||||
icon={DeviceHubIcon}
|
||||
bgcolor={activeHighlight(data.mqtt_status)}
|
||||
label="MQTT"
|
||||
text={mqttStatusText}
|
||||
to="/status/mqtt"
|
||||
/>
|
||||
|
||||
<ListMenuItem
|
||||
disabled={!me.admin}
|
||||
icon={AccessTimeIcon}
|
||||
bgcolor={ntpStatusHighlight}
|
||||
label="NTP"
|
||||
text={ntpStatus}
|
||||
to="/status/ntp"
|
||||
/>
|
||||
|
||||
<ListMenuItem
|
||||
disabled={!me.admin}
|
||||
icon={SettingsInputAntennaIcon}
|
||||
bgcolor={activeHighlight(data.ap_status)}
|
||||
label={LL.ACCESS_POINT(0)}
|
||||
text={apStatusText}
|
||||
to="/status/ap"
|
||||
/>
|
||||
|
||||
<ListMenuItem
|
||||
disabled={!me.admin}
|
||||
icon={LogoDevIcon}
|
||||
bgcolor="#40828f"
|
||||
label={LL.LOG_OF(LL.SYSTEM(0))}
|
||||
text={LL.VIEW_LOG()}
|
||||
to="/status/log"
|
||||
/>
|
||||
</List>
|
||||
|
||||
{renderRestartDialog}
|
||||
</>
|
||||
<SectionContent>
|
||||
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
|
||||
</SectionContent>
|
||||
);
|
||||
}, [
|
||||
data,
|
||||
LL,
|
||||
firmwareVersion,
|
||||
uptimeText,
|
||||
freeMemoryText,
|
||||
networkIcon,
|
||||
mqttStatusText,
|
||||
apStatusText,
|
||||
busStatus,
|
||||
busStatusHighlight,
|
||||
networkStatusHighlight,
|
||||
networkStatus,
|
||||
ntpStatusHighlight,
|
||||
ntpStatus,
|
||||
activeHighlight,
|
||||
me.admin,
|
||||
handleRestartClick,
|
||||
error,
|
||||
loadData,
|
||||
renderRestartDialog
|
||||
]);
|
||||
}
|
||||
|
||||
return restarting ? <SystemMonitor /> : <SectionContent>{content}</SectionContent>;
|
||||
return (
|
||||
<SectionContent>
|
||||
<List>
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
<Avatar sx={{ bgcolor: '#c5572c', color: 'white' }}>
|
||||
<MonitorHeartIcon />
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary={LL.STATUS_OF(LL.SYSTEM(0))}
|
||||
secondary={`${systemStatus} (${LL.UPTIME()}: ${formatDurationSec(data.uptime, LL)})`}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListMenuItem
|
||||
disabled={!me.admin}
|
||||
icon={MemoryIcon}
|
||||
bgcolor="#68374d"
|
||||
label={LL.HARDWARE()}
|
||||
text={`${formatNumber(data.free_heap)} KB ${LL.FREE_MEMORY()}`}
|
||||
to="/status/hardwarestatus"
|
||||
/>
|
||||
|
||||
<ListMenuItem
|
||||
disabled={!me.admin}
|
||||
icon={DirectionsBusIcon}
|
||||
bgcolor={busStatusHighlight}
|
||||
label={LL.DATA_TRAFFIC()}
|
||||
text={busStatus}
|
||||
to="/status/activity"
|
||||
/>
|
||||
|
||||
<ListMenuItem
|
||||
disabled={!me.admin}
|
||||
icon={
|
||||
data.network_status === NetworkConnectionStatus.WIFI_STATUS_CONNECTED
|
||||
? WifiIcon
|
||||
: RouterIcon
|
||||
}
|
||||
bgcolor={networkStatusHighlight}
|
||||
label={LL.NETWORK(1)}
|
||||
text={networkStatus}
|
||||
to="/status/network"
|
||||
/>
|
||||
|
||||
<ListMenuItem
|
||||
disabled={!me.admin}
|
||||
icon={DeviceHubIcon}
|
||||
bgcolor={activeHighlight(data.mqtt_status)}
|
||||
label="MQTT"
|
||||
text={data.mqtt_status ? LL.CONNECTED(0) : LL.INACTIVE(0)}
|
||||
to="/status/mqtt"
|
||||
/>
|
||||
|
||||
<ListMenuItem
|
||||
disabled={!me.admin}
|
||||
icon={AccessTimeIcon}
|
||||
bgcolor={ntpStatusHighlight}
|
||||
label="NTP"
|
||||
text={ntpStatus}
|
||||
to="/status/ntp"
|
||||
/>
|
||||
|
||||
<ListMenuItem
|
||||
disabled={!me.admin}
|
||||
icon={SettingsInputAntennaIcon}
|
||||
bgcolor={activeHighlight(data.ap_status)}
|
||||
label={LL.ACCESS_POINT(0)}
|
||||
text={data.ap_status ? LL.ACTIVE() : LL.INACTIVE(0)}
|
||||
to="/status/ap"
|
||||
/>
|
||||
|
||||
<ListMenuItem
|
||||
disabled={!me.admin}
|
||||
icon={LogoDevIcon}
|
||||
bgcolor="#40828f"
|
||||
label={LL.LOG_OF(LL.SYSTEM(0))}
|
||||
text={LL.VIEW_LOG()}
|
||||
to="/status/log"
|
||||
/>
|
||||
</List>
|
||||
</SectionContent>
|
||||
);
|
||||
};
|
||||
|
||||
export default SystemStatus;
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { memo, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
|
||||
import DownloadIcon from '@mui/icons-material/GetApp';
|
||||
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
||||
@@ -33,13 +25,12 @@ import {
|
||||
SectionContent,
|
||||
useLayoutTitle
|
||||
} from 'components';
|
||||
import { toast } from 'components/toast';
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
import type { LogEntry, LogSettings } from 'types';
|
||||
import { LogLevel } from 'types';
|
||||
import { updateValueDirty, useRest } from 'utils';
|
||||
|
||||
const MAX_LOG_ENTRIES = 1000; // Limit log entries to prevent memory issues
|
||||
|
||||
const TextColors: Record<LogLevel, string> = {
|
||||
[LogLevel.ERROR]: '#ff0000', // red
|
||||
[LogLevel.WARNING]: '#ff0000', // red
|
||||
@@ -187,8 +178,7 @@ const SystemLog = () => {
|
||||
};
|
||||
}, [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 handleLogMessage = (message: { data: string }) => {
|
||||
const rawData = message.data;
|
||||
const logentry = JSON.parse(rawData) as LogEntry;
|
||||
setLogEntries((log) => {
|
||||
@@ -200,13 +190,9 @@ const SystemLog = () => {
|
||||
}
|
||||
}
|
||||
const newLog = [...log, logentry];
|
||||
// Limit log entries to prevent memory issues - only slice when necessary
|
||||
if (newLog.length > MAX_LOG_ENTRIES) {
|
||||
return newLog.slice(-MAX_LOG_ENTRIES);
|
||||
}
|
||||
return newLog;
|
||||
});
|
||||
}, []);
|
||||
};
|
||||
|
||||
useSSE(fetchLogES, {
|
||||
immediate: true,
|
||||
@@ -217,7 +203,7 @@ const SystemLog = () => {
|
||||
toast.error('No connection to Log service');
|
||||
});
|
||||
|
||||
const onDownload = useCallback(() => {
|
||||
const onDownload = () => {
|
||||
const result = logEntries
|
||||
.map((i) => `${i.t} ${levelLabel(i.l)} ${i.i}: [${i.n}] ${i.m}`)
|
||||
.join('\n');
|
||||
@@ -231,11 +217,11 @@ const SystemLog = () => {
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
}, [logEntries]);
|
||||
};
|
||||
|
||||
const saveSettings = useCallback(async () => {
|
||||
const saveSettings = async () => {
|
||||
await saveData();
|
||||
}, [saveData]);
|
||||
};
|
||||
|
||||
// handle scrolling - optimized to only scroll when needed
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
@@ -252,7 +238,7 @@ const SystemLog = () => {
|
||||
}
|
||||
}, [logEntries.length, autoscroll]);
|
||||
|
||||
const sendReadCommand = useCallback(() => {
|
||||
const sendReadCommand = () => {
|
||||
if (readValue === '') {
|
||||
setReadOpen(!readOpen);
|
||||
return;
|
||||
@@ -263,7 +249,7 @@ const SystemLog = () => {
|
||||
setReadOpen(false);
|
||||
setReadValue('');
|
||||
}
|
||||
}, [readValue, readOpen, send]);
|
||||
};
|
||||
|
||||
const content = () => {
|
||||
if (!data) {
|
||||
@@ -272,7 +258,7 @@ const SystemLog = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Grid container spacing={2} alignItems="center">
|
||||
<Grid container spacing={2} sx={{ alignItems: 'center' }}>
|
||||
<Grid>
|
||||
<TextField
|
||||
name="level"
|
||||
@@ -308,6 +294,8 @@ const SystemLog = () => {
|
||||
<MenuItem value={50}>50</MenuItem>
|
||||
<MenuItem value={75}>75</MenuItem>
|
||||
<MenuItem value={100}>100</MenuItem>
|
||||
<MenuItem value={500}>500</MenuItem>
|
||||
<MenuItem value={1000}>1000</MenuItem>
|
||||
</TextField>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { useRef, useState } from 'react';
|
||||
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import { Box, Button, Typography } from '@mui/material';
|
||||
@@ -57,41 +57,31 @@ const SystemMonitor = () => {
|
||||
void send();
|
||||
}, 1000); // check every 1 second
|
||||
|
||||
const { statusMessage, isUploading, progressValue } = useMemo(() => {
|
||||
const status = data?.status;
|
||||
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 statusMessage =
|
||||
status && status >= SystemStatusCodes.SYSTEM_STATUS_UPLOADING
|
||||
? LL.WAIT_FIRMWARE()
|
||||
: status === SystemStatusCodes.SYSTEM_STATUS_PENDING_RESTART
|
||||
? LL.APPLICATION_RESTARTING()
|
||||
: status === SystemStatusCodes.SYSTEM_STATUS_NORMAL
|
||||
? LL.RESTARTING_PRE()
|
||||
: status === SystemStatusCodes.SYSTEM_STATUS_ERROR_UPLOAD
|
||||
? 'Upload Failed'
|
||||
: LL.RESTARTING_POST();
|
||||
|
||||
const uploading =
|
||||
status !== undefined && status >= SystemStatusCodes.SYSTEM_STATUS_UPLOADING;
|
||||
const progress =
|
||||
uploading && status
|
||||
? Math.round(status - SystemStatusCodes.SYSTEM_STATUS_UPLOADING)
|
||||
: 0;
|
||||
const isUploading =
|
||||
status !== undefined && status >= SystemStatusCodes.SYSTEM_STATUS_UPLOADING;
|
||||
const progressValue =
|
||||
isUploading && status
|
||||
? Math.round(status - SystemStatusCodes.SYSTEM_STATUS_UPLOADING)
|
||||
: 0;
|
||||
|
||||
return {
|
||||
statusMessage: message,
|
||||
isUploading: uploading,
|
||||
progressValue: progress
|
||||
};
|
||||
}, [data?.status, LL]);
|
||||
|
||||
const onCancel = useCallback(async () => {
|
||||
const onCancel = async () => {
|
||||
setErrorMessage(undefined);
|
||||
await setSystemStatus(String(SystemStatusCodes.SYSTEM_STATUS_NORMAL));
|
||||
document.location.href = '/';
|
||||
}, [setSystemStatus]);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -120,17 +110,15 @@ const SystemMonitor = () => {
|
||||
p: 3
|
||||
}}
|
||||
>
|
||||
<Box display="flex" alignItems="center" flexDirection="column">
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', flexDirection: 'column' }}>
|
||||
<img
|
||||
src="/app/icon.png"
|
||||
alt="EMS-ESP"
|
||||
style={{ width: '40px', height: '40px', marginBottom: '16px' }}
|
||||
/>
|
||||
<Typography
|
||||
color="secondary"
|
||||
sx={{ color: 'secondary', fontWeight: 400, textAlign: 'center' }}
|
||||
variant="h6"
|
||||
fontWeight={400}
|
||||
textAlign="center"
|
||||
>
|
||||
{statusMessage}
|
||||
</Typography>
|
||||
@@ -150,13 +138,28 @@ const SystemMonitor = () => {
|
||||
</MessageBox>
|
||||
) : (
|
||||
<>
|
||||
<Typography mt={2} variant="h6" fontWeight={400} textAlign="center">
|
||||
<Typography
|
||||
sx={{ mt: 2, fontWeight: 400, textAlign: 'center' }}
|
||||
variant="h6"
|
||||
>
|
||||
{LL.PLEASE_WAIT()}…
|
||||
</Typography>
|
||||
{isUploading && (
|
||||
<Box width="100%" pl={2} pr={2} py={2}>
|
||||
<LinearProgressWithLabel value={progressValue} />
|
||||
</Box>
|
||||
<>
|
||||
<Box sx={{ width: '100%', pl: 2, pr: 2, py: 2 }}>
|
||||
<LinearProgressWithLabel value={progressValue} />
|
||||
</Box>
|
||||
|
||||
<Button
|
||||
sx={{ ml: 2, mt: 2 }}
|
||||
startIcon={<CancelIcon />}
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={onCancel}
|
||||
>
|
||||
{LL.CANCEL()}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,874 +0,0 @@
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import CheckIcon from '@mui/icons-material/Done';
|
||||
import DownloadIcon from '@mui/icons-material/GetApp';
|
||||
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
|
||||
import WarningIcon from '@mui/icons-material/Warning';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Grid,
|
||||
IconButton,
|
||||
Link,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableRow,
|
||||
Typography
|
||||
} from '@mui/material';
|
||||
|
||||
import * as SystemApi from 'api/system';
|
||||
import { API, callAction } from 'api/app';
|
||||
import { getDevVersion, getStableVersion } from 'api/system';
|
||||
|
||||
import { dialogStyle } from 'CustomTheme';
|
||||
import { useRequest } from 'alova/client';
|
||||
import type { APIcall } from 'app/main/types';
|
||||
import SystemMonitor from 'app/status/SystemMonitor';
|
||||
import {
|
||||
FormLoader,
|
||||
SectionContent,
|
||||
SingleUpload,
|
||||
useLayoutTitle
|
||||
} from 'components';
|
||||
import { AuthenticatedContext } from 'contexts/authentication';
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
import type { TranslationFunctions } from 'i18n/i18n-types';
|
||||
import { prettyDateTime } from 'utils/time';
|
||||
|
||||
// Constants moved outside component to avoid recreation
|
||||
const STABLE_URL = 'https://github.com/emsesp/EMS-ESP32/releases/download/';
|
||||
const STABLE_RELNOTES_URL =
|
||||
'https://github.com/emsesp/EMS-ESP32/blob/main/CHANGELOG.md';
|
||||
const DEV_URL = 'https://github.com/emsesp/EMS-ESP32/releases/download/latest/';
|
||||
const DEV_RELNOTES_URL =
|
||||
'https://github.com/emsesp/EMS-ESP32/blob/dev/CHANGELOG_LATEST.md';
|
||||
|
||||
// Types for better type safety
|
||||
interface PartitionData {
|
||||
partition: string;
|
||||
version: string;
|
||||
install_date?: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
interface VersionData {
|
||||
emsesp_version: string;
|
||||
arduino_version: string;
|
||||
esp_platform: string;
|
||||
flash_chip_size: number;
|
||||
psram: boolean;
|
||||
build_flags?: string;
|
||||
partition: string;
|
||||
partitions: PartitionData[];
|
||||
developer_mode: boolean;
|
||||
}
|
||||
|
||||
interface UpgradeCheckData {
|
||||
emsesp_version: string;
|
||||
dev_upgradeable: boolean;
|
||||
stable_upgradeable: boolean;
|
||||
}
|
||||
|
||||
interface VersionInfo {
|
||||
name: string;
|
||||
published_at?: string;
|
||||
}
|
||||
|
||||
// Memoized components for better performance
|
||||
const VersionInfoDialog = memo(
|
||||
({
|
||||
showVersionInfo,
|
||||
latestVersion,
|
||||
latestDevVersion,
|
||||
partitionVersion,
|
||||
partition,
|
||||
currentPartition,
|
||||
size,
|
||||
locale,
|
||||
LL,
|
||||
onClose
|
||||
}: {
|
||||
showVersionInfo: number;
|
||||
latestVersion?: VersionInfo;
|
||||
latestDevVersion?: VersionInfo;
|
||||
partitionVersion?: VersionInfo | undefined;
|
||||
partition: string;
|
||||
currentPartition: string;
|
||||
size: number;
|
||||
locale: string;
|
||||
LL: TranslationFunctions;
|
||||
onClose: () => void;
|
||||
}) => {
|
||||
if (showVersionInfo === 0) return null;
|
||||
|
||||
const isStable = showVersionInfo === 1;
|
||||
const isDev = showVersionInfo === 2;
|
||||
const isPartition = showVersionInfo === 3;
|
||||
|
||||
const version = isStable
|
||||
? latestVersion
|
||||
: isDev
|
||||
? latestDevVersion
|
||||
: partitionVersion;
|
||||
const relNotesUrl = isStable
|
||||
? STABLE_RELNOTES_URL
|
||||
: isDev
|
||||
? DEV_RELNOTES_URL
|
||||
: '';
|
||||
|
||||
return (
|
||||
<Dialog sx={dialogStyle} open={showVersionInfo !== 0} onClose={onClose}>
|
||||
<DialogTitle>{LL.FIRMWARE_VERSION_INFO()}</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<Table size="small" sx={{ borderCollapse: 'collapse', minWidth: 0 }}>
|
||||
<TableBody>
|
||||
<TableRow sx={{ height: 24, borderBottom: 'none' }}>
|
||||
<TableCell
|
||||
component="th"
|
||||
scope="row"
|
||||
sx={{
|
||||
color: 'lightblue',
|
||||
borderBottom: 'none',
|
||||
pr: 1,
|
||||
py: 0.5,
|
||||
fontSize: 13
|
||||
}}
|
||||
>
|
||||
{LL.VERSION()}
|
||||
</TableCell>
|
||||
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
|
||||
{isPartition
|
||||
? typeof version === 'string'
|
||||
? version
|
||||
: version?.name
|
||||
: version?.name}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow sx={{ height: 24, borderBottom: 'none' }}>
|
||||
<TableCell
|
||||
component="th"
|
||||
scope="row"
|
||||
sx={{
|
||||
color: 'lightblue',
|
||||
borderBottom: 'none',
|
||||
pr: 1,
|
||||
py: 0.5,
|
||||
fontSize: 13,
|
||||
width: 140
|
||||
}}
|
||||
>
|
||||
{isPartition ? LL.TYPE(0) : LL.RELEASE_TYPE()}
|
||||
</TableCell>
|
||||
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
|
||||
{partition === currentPartition && LL.ACTIVE() + ' '}
|
||||
{isStable
|
||||
? LL.STABLE()
|
||||
: isDev
|
||||
? LL.DEVELOPMENT()
|
||||
: 'Partition ' + LL.VERSION()}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{isPartition && (
|
||||
<TableRow sx={{ height: 24, borderBottom: 'none' }}>
|
||||
<TableCell
|
||||
component="th"
|
||||
scope="row"
|
||||
sx={{
|
||||
color: 'lightblue',
|
||||
borderBottom: 'none',
|
||||
pr: 1,
|
||||
py: 0.5,
|
||||
fontSize: 13
|
||||
}}
|
||||
>
|
||||
Partition
|
||||
</TableCell>
|
||||
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
|
||||
{partition}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{isPartition && (
|
||||
<TableRow sx={{ height: 24, borderBottom: 'none' }}>
|
||||
<TableCell
|
||||
component="th"
|
||||
scope="row"
|
||||
sx={{
|
||||
color: 'lightblue',
|
||||
borderBottom: 'none',
|
||||
pr: 1,
|
||||
py: 0.5,
|
||||
fontSize: 13
|
||||
}}
|
||||
>
|
||||
Size
|
||||
</TableCell>
|
||||
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
|
||||
{size} KB
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{version?.published_at && (
|
||||
<TableRow sx={{ height: 24, borderBottom: 'none' }}>
|
||||
<TableCell
|
||||
component="th"
|
||||
scope="row"
|
||||
sx={{
|
||||
color: 'lightblue',
|
||||
borderBottom: 'none',
|
||||
pr: 1,
|
||||
py: 0.5,
|
||||
fontSize: 13
|
||||
}}
|
||||
>
|
||||
{isPartition ? 'Install Date' : 'Build Date'}
|
||||
</TableCell>
|
||||
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
|
||||
{prettyDateTime(locale, new Date(version.published_at))}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
{!isPartition && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
component="a"
|
||||
href={relNotesUrl}
|
||||
target="_blank"
|
||||
color="primary"
|
||||
>
|
||||
Changelog
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outlined" onClick={onClose} color="secondary">
|
||||
{LL.CLOSE()}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const InstallDialog = memo(
|
||||
({
|
||||
openInstallDialog,
|
||||
fetchDevVersion,
|
||||
latestVersion,
|
||||
latestDevVersion,
|
||||
downloadOnly,
|
||||
platform,
|
||||
LL,
|
||||
onClose,
|
||||
onInstall
|
||||
}: {
|
||||
openInstallDialog: boolean;
|
||||
fetchDevVersion: boolean;
|
||||
latestVersion?: VersionInfo;
|
||||
latestDevVersion?: VersionInfo;
|
||||
downloadOnly: boolean;
|
||||
platform: string;
|
||||
LL: TranslationFunctions;
|
||||
onClose: () => void;
|
||||
onInstall: (url: string) => void;
|
||||
}) => {
|
||||
const binURL = useMemo(() => {
|
||||
if (!latestVersion || !latestDevVersion) return '';
|
||||
|
||||
const version = fetchDevVersion ? latestDevVersion : latestVersion;
|
||||
const filename = `EMS-ESP-${version.name.replaceAll('.', '_')}-${platform}.bin`;
|
||||
|
||||
return fetchDevVersion
|
||||
? `${DEV_URL}${filename}`
|
||||
: `${STABLE_URL}v${version.name}/${filename}`;
|
||||
}, [fetchDevVersion, latestVersion, latestDevVersion, platform]);
|
||||
|
||||
return (
|
||||
<Dialog sx={dialogStyle} open={openInstallDialog} onClose={onClose}>
|
||||
<DialogTitle>
|
||||
{`${LL.INSTALL()} ${fetchDevVersion ? LL.DEVELOPMENT() : LL.STABLE()} Firmware`}
|
||||
</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<Typography mb={2}>
|
||||
{LL.INSTALL_VERSION(
|
||||
downloadOnly ? LL.DOWNLOAD(1) : LL.INSTALL(),
|
||||
fetchDevVersion ? latestDevVersion?.name : latestVersion?.name
|
||||
)}
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
startIcon={<CancelIcon />}
|
||||
variant="outlined"
|
||||
onClick={onClose}
|
||||
color="secondary"
|
||||
>
|
||||
{LL.CANCEL()}
|
||||
</Button>
|
||||
<Button
|
||||
startIcon={<DownloadIcon />}
|
||||
variant="outlined"
|
||||
onClick={onClose}
|
||||
color="primary"
|
||||
>
|
||||
<Link underline="none" target="_blank" href={binURL} color="primary">
|
||||
{LL.DOWNLOAD(0)}
|
||||
</Link>
|
||||
</Button>
|
||||
{!downloadOnly && (
|
||||
<Button
|
||||
startIcon={<WarningIcon color="warning" />}
|
||||
variant="outlined"
|
||||
onClick={() => onInstall(binURL)}
|
||||
color="primary"
|
||||
>
|
||||
{LL.INSTALL()}
|
||||
</Button>
|
||||
)}
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const InstallPartitionDialog = memo(
|
||||
({
|
||||
openInstallPartitionDialog,
|
||||
version,
|
||||
partition,
|
||||
LL,
|
||||
onClose,
|
||||
onInstall
|
||||
}: {
|
||||
openInstallPartitionDialog: boolean;
|
||||
version: string;
|
||||
partition: string;
|
||||
LL: TranslationFunctions;
|
||||
onClose: () => void;
|
||||
onInstall: (partition: string) => void;
|
||||
}) => {
|
||||
return (
|
||||
<Dialog sx={dialogStyle} open={openInstallPartitionDialog} onClose={onClose}>
|
||||
<DialogTitle>
|
||||
{LL.INSTALL()} {LL.STORED_VERSIONS()}
|
||||
</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<Typography mb={2}>{LL.INSTALL_VERSION(LL.INSTALL(), version)}</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
startIcon={<CancelIcon />}
|
||||
variant="outlined"
|
||||
onClick={onClose}
|
||||
color="secondary"
|
||||
>
|
||||
{LL.CANCEL()}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
startIcon={<WarningIcon color="warning" />}
|
||||
variant="outlined"
|
||||
onClick={() => onInstall(partition)}
|
||||
color="primary"
|
||||
>
|
||||
{LL.INSTALL()}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
// Helper function moved outside component
|
||||
const getPlatform = (data: VersionData): string => {
|
||||
return `${data.esp_platform}-${data.flash_chip_size >= 16384 ? '16MB' : '4MB'}${data.psram ? '+' : ''}`;
|
||||
};
|
||||
|
||||
const Version = () => {
|
||||
const { LL, locale } = useI18nContext();
|
||||
const { me } = useContext(AuthenticatedContext);
|
||||
|
||||
// State management
|
||||
const [restarting, setRestarting] = useState<boolean>(false);
|
||||
const [openInstallDialog, setOpenInstallDialog] = useState<boolean>(false);
|
||||
|
||||
const [partitionVersion, setPartitionVersion] = useState<VersionInfo | undefined>(
|
||||
undefined
|
||||
);
|
||||
const [partition, setPartition] = useState<string>('');
|
||||
const [openInstallPartitionDialog, setOpenInstallPartitionDialog] =
|
||||
useState<boolean>(false);
|
||||
|
||||
const [usingDevVersion, setUsingDevVersion] = useState<boolean>(false);
|
||||
const [fetchDevVersion, setFetchDevVersion] = useState<boolean>(false);
|
||||
const [devUpgradeAvailable, setDevUpgradeAvailable] = useState<boolean>(false);
|
||||
const [stableUpgradeAvailable, setStableUpgradeAvailable] =
|
||||
useState<boolean>(false);
|
||||
const [internetLive, setInternetLive] = useState<boolean>(false);
|
||||
const [downloadOnly, setDownloadOnly] = useState<boolean>(false);
|
||||
const [showVersionInfo, setShowVersionInfo] = useState<number>(0); // 1 = stable, 2 = dev, 3 = partition
|
||||
const [firmwareSize, setFirmwareSize] = useState<number>(0);
|
||||
|
||||
const { send: sendCheckUpgrade } = useRequest(
|
||||
(versions: string) => callAction({ action: 'checkUpgrade', param: versions }),
|
||||
{ immediate: false }
|
||||
).onSuccess((event) => {
|
||||
const data = event.data as UpgradeCheckData;
|
||||
setDevUpgradeAvailable(data.dev_upgradeable);
|
||||
setStableUpgradeAvailable(data.stable_upgradeable);
|
||||
});
|
||||
|
||||
const { send: sendSetPartition } = useRequest(
|
||||
(partition: string) => callAction({ action: 'setPartition', param: partition }),
|
||||
{ immediate: false }
|
||||
).onError((error) => {
|
||||
toast.error(String(error.error?.message || 'An error occurred'));
|
||||
});
|
||||
|
||||
const {
|
||||
data,
|
||||
send: loadData,
|
||||
error
|
||||
} = useRequest(SystemApi.readSystemStatus).onSuccess((event) => {
|
||||
const systemData = event.data as VersionData;
|
||||
if (systemData.arduino_version.startsWith('Tasmota')) {
|
||||
setDownloadOnly(true);
|
||||
}
|
||||
setUsingDevVersion(systemData.emsesp_version.includes('dev'));
|
||||
});
|
||||
|
||||
const { send: sendUploadURL } = useRequest(
|
||||
(url: string) => callAction({ action: 'uploadURL', param: url }),
|
||||
{ immediate: false }
|
||||
);
|
||||
|
||||
const { data: latestVersion } = useRequest(getStableVersion);
|
||||
const { data: latestDevVersion } = useRequest(getDevVersion);
|
||||
|
||||
const { send: sendAPI } = useRequest((data: APIcall) => API(data), {
|
||||
immediate: false
|
||||
});
|
||||
|
||||
// Memoized values
|
||||
const platform = useMemo(() => (data ? getPlatform(data) : ''), [data]);
|
||||
|
||||
// Memoize filtered partitions to avoid recomputing on every render
|
||||
const otherPartitions = useMemo(
|
||||
() => data?.partitions.filter((p) => p.partition !== data.partition) ?? [],
|
||||
[data]
|
||||
);
|
||||
|
||||
const setPartitionVersionInfo = useCallback(
|
||||
(partition: string) => {
|
||||
setShowVersionInfo(3);
|
||||
|
||||
// search for the partition in the data.partitions array
|
||||
const partitionData = data?.partitions.find((p) => p.partition === partition);
|
||||
if (partitionData) {
|
||||
setPartitionVersion({
|
||||
name: partitionData.version,
|
||||
published_at: partitionData.install_date ?? ''
|
||||
});
|
||||
setPartition(partitionData.partition);
|
||||
setFirmwareSize(partitionData.size);
|
||||
}
|
||||
},
|
||||
[data]
|
||||
);
|
||||
|
||||
const doRestart = useCallback(async () => {
|
||||
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
|
||||
(error: Error) => {
|
||||
toast.error(error.message);
|
||||
}
|
||||
);
|
||||
setRestarting(true);
|
||||
}, [sendAPI]);
|
||||
|
||||
const installFirmwareURL = useCallback(
|
||||
async (url: string) => {
|
||||
await sendUploadURL(url).catch((error: Error) => {
|
||||
toast.error(error.message);
|
||||
});
|
||||
await doRestart();
|
||||
},
|
||||
[sendUploadURL, doRestart]
|
||||
);
|
||||
|
||||
const installPartitionFirmware = useCallback(
|
||||
async (partition: string) => {
|
||||
await sendSetPartition(partition).catch((error: Error) => {
|
||||
toast.error(error.message);
|
||||
});
|
||||
setRestarting(true);
|
||||
},
|
||||
[sendSetPartition]
|
||||
);
|
||||
|
||||
const showPartitionDialog = useCallback(
|
||||
(version: string, partition: string, install_date: string) => {
|
||||
setOpenInstallPartitionDialog(true);
|
||||
setPartitionVersion({ name: version, published_at: install_date });
|
||||
setPartition(partition);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const showFirmwareDialog = useCallback((useDevVersion: boolean) => {
|
||||
setFetchDevVersion(useDevVersion);
|
||||
setOpenInstallDialog(true);
|
||||
}, []);
|
||||
|
||||
const closeInstallDialog = useCallback(() => {
|
||||
setOpenInstallDialog(false);
|
||||
}, []);
|
||||
|
||||
const closeInstallPartitionDialog = useCallback(() => {
|
||||
setOpenInstallPartitionDialog(false);
|
||||
}, []);
|
||||
|
||||
const handleVersionInfoClose = useCallback(() => {
|
||||
setShowVersionInfo(0);
|
||||
setPartitionVersion(undefined);
|
||||
setPartition('');
|
||||
}, []);
|
||||
|
||||
// check upgrades - only once when both versions are available
|
||||
const upgradeCheckedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (latestVersion && latestDevVersion && !upgradeCheckedRef.current) {
|
||||
upgradeCheckedRef.current = true;
|
||||
const versions = `${latestDevVersion.name},${latestVersion.name}`;
|
||||
sendCheckUpgrade(versions)
|
||||
.catch((error: Error) => {
|
||||
toast.error(`Failed to check for upgrades: ${error.message}`);
|
||||
})
|
||||
.finally(() => {
|
||||
setInternetLive(true);
|
||||
});
|
||||
}
|
||||
}, [latestVersion, latestDevVersion, sendCheckUpgrade]);
|
||||
|
||||
useLayoutTitle('EMS-ESP Firmware');
|
||||
|
||||
// Memoized button rendering logic
|
||||
const showButtons = useCallback(
|
||||
(showingDev: boolean) => {
|
||||
const choice = showingDev
|
||||
? !usingDevVersion
|
||||
? LL.SWITCH_RELEASE_TYPE(LL.DEVELOPMENT())
|
||||
: devUpgradeAvailable
|
||||
? LL.UPDATE_AVAILABLE()
|
||||
: undefined
|
||||
: usingDevVersion
|
||||
? LL.SWITCH_RELEASE_TYPE(LL.STABLE())
|
||||
: stableUpgradeAvailable
|
||||
? LL.UPDATE_AVAILABLE()
|
||||
: undefined;
|
||||
|
||||
if (!choice) {
|
||||
return (
|
||||
<>
|
||||
<CheckIcon
|
||||
color="success"
|
||||
sx={{ verticalAlign: 'middle', ml: 0.5, mr: 0.5 }}
|
||||
/>
|
||||
<span style={{ color: '#66bb6a', fontSize: '0.8em' }}>
|
||||
{LL.LATEST_VERSION(usingDevVersion ? LL.DEVELOPMENT() : LL.STABLE())}
|
||||
</span>
|
||||
<Button
|
||||
sx={{ ml: 1 }}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={() => showFirmwareDialog(showingDev)}
|
||||
>
|
||||
{LL.REINSTALL()}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (!me.admin) return null;
|
||||
|
||||
return (
|
||||
<Button
|
||||
sx={{ ml: 1 }}
|
||||
variant="outlined"
|
||||
color={choice === LL.UPDATE_AVAILABLE() ? 'success' : 'warning'}
|
||||
size="small"
|
||||
onClick={() => showFirmwareDialog(showingDev)}
|
||||
>
|
||||
{choice}
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
[
|
||||
usingDevVersion,
|
||||
devUpgradeAvailable,
|
||||
stableUpgradeAvailable,
|
||||
me.admin,
|
||||
LL,
|
||||
showFirmwareDialog
|
||||
]
|
||||
);
|
||||
|
||||
const content = useMemo(() => {
|
||||
if (!data) {
|
||||
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box p={2} border="1px solid grey" borderRadius={2}>
|
||||
<Typography mb={1} variant="h6" color="primary">
|
||||
{LL.THIS_VERSION()}
|
||||
</Typography>
|
||||
|
||||
<Grid
|
||||
container
|
||||
direction="row"
|
||||
sx={{
|
||||
justifyContent: 'flex-start',
|
||||
alignItems: 'baseline'
|
||||
}}
|
||||
>
|
||||
<Grid size={{ xs: 4, md: 2 }}>
|
||||
<Typography color="secondary">{LL.VERSION()}</Typography>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 8, md: 10 }}>
|
||||
<Typography>
|
||||
{data.emsesp_version}
|
||||
{data.build_flags && (
|
||||
<Typography variant="caption">
|
||||
({data.build_flags})
|
||||
</Typography>
|
||||
)}
|
||||
<IconButton
|
||||
onClick={() => setPartitionVersionInfo(data.partition)}
|
||||
aria-label={LL.FIRMWARE_VERSION_INFO()}
|
||||
>
|
||||
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 4, md: 2 }}>
|
||||
<Typography color="secondary">{LL.PLATFORM()}</Typography>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 8, md: 10 }}>
|
||||
<Typography>
|
||||
{platform}
|
||||
<Typography variant="caption">
|
||||
(
|
||||
{data.psram ? (
|
||||
<CheckIcon
|
||||
color="success"
|
||||
sx={{
|
||||
fontSize: '1.5em',
|
||||
verticalAlign: 'middle'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<CloseIcon
|
||||
color="error"
|
||||
sx={{
|
||||
fontSize: '1.5em',
|
||||
verticalAlign: 'middle'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
PSRAM)
|
||||
</Typography>
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{internetLive ? (
|
||||
<>
|
||||
<Typography mt={4} mb={1} variant="h6" color="primary">
|
||||
{LL.AVAILABLE_VERSION()}
|
||||
</Typography>
|
||||
|
||||
<Grid
|
||||
container
|
||||
direction="row"
|
||||
rowSpacing={1}
|
||||
sx={{
|
||||
justifyContent: 'flex-start',
|
||||
alignItems: 'baseline'
|
||||
}}
|
||||
>
|
||||
{otherPartitions.length > 0 && data.developer_mode && (
|
||||
<>
|
||||
<Grid size={{ xs: 4, md: 2 }}>
|
||||
<Typography color="secondary">
|
||||
{LL.STORED_VERSIONS()}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 8, md: 10 }}>
|
||||
{otherPartitions.map((partition) => (
|
||||
<Typography key={partition.partition} mb={1}>
|
||||
{partition.version}
|
||||
<IconButton
|
||||
onClick={() =>
|
||||
setPartitionVersionInfo(partition.partition)
|
||||
}
|
||||
aria-label={LL.FIRMWARE_VERSION_INFO()}
|
||||
>
|
||||
<InfoOutlinedIcon
|
||||
color="primary"
|
||||
sx={{ fontSize: 18 }}
|
||||
/>
|
||||
</IconButton>
|
||||
<Button
|
||||
sx={{ ml: 0 }}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={() =>
|
||||
showPartitionDialog(
|
||||
partition.version,
|
||||
partition.partition,
|
||||
partition.install_date ?? ''
|
||||
)
|
||||
}
|
||||
>
|
||||
{LL.INSTALL()}
|
||||
</Button>
|
||||
</Typography>
|
||||
))}
|
||||
</Grid>
|
||||
</>
|
||||
)}
|
||||
<Grid size={{ xs: 4, md: 2 }}>
|
||||
<Typography color="secondary">{LL.STABLE()}</Typography>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 8, md: 10 }}>
|
||||
<Typography>
|
||||
{latestVersion?.name}
|
||||
<IconButton
|
||||
onClick={() => setShowVersionInfo(1)}
|
||||
aria-label={LL.FIRMWARE_VERSION_INFO()}
|
||||
>
|
||||
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
{showButtons(false)}
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 4, md: 2 }}>
|
||||
<Typography color="secondary">{LL.DEVELOPMENT()}</Typography>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 8, md: 10 }}>
|
||||
<Typography>
|
||||
{latestDevVersion?.name}
|
||||
<IconButton
|
||||
onClick={() => setShowVersionInfo(2)}
|
||||
aria-label={LL.FIRMWARE_VERSION_INFO()}
|
||||
>
|
||||
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
{showButtons(true)}
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</>
|
||||
) : (
|
||||
<Typography mt={2} color="warning">
|
||||
<WarningIcon color="warning" sx={{ verticalAlign: 'middle', mr: 2 }} />
|
||||
{LL.INTERNET_CONNECTION_REQUIRED()}
|
||||
</Typography>
|
||||
)}
|
||||
{me.admin && (
|
||||
<>
|
||||
<VersionInfoDialog
|
||||
showVersionInfo={showVersionInfo}
|
||||
latestVersion={latestVersion}
|
||||
latestDevVersion={latestDevVersion}
|
||||
partitionVersion={partitionVersion}
|
||||
locale={locale}
|
||||
partition={partition}
|
||||
currentPartition={data?.partition ?? ''}
|
||||
size={firmwareSize}
|
||||
LL={LL}
|
||||
onClose={handleVersionInfoClose}
|
||||
/>
|
||||
<InstallDialog
|
||||
openInstallDialog={openInstallDialog}
|
||||
fetchDevVersion={fetchDevVersion}
|
||||
latestVersion={latestVersion}
|
||||
latestDevVersion={latestDevVersion}
|
||||
downloadOnly={downloadOnly}
|
||||
platform={platform}
|
||||
LL={LL}
|
||||
onClose={closeInstallDialog}
|
||||
onInstall={installFirmwareURL}
|
||||
/>
|
||||
<InstallPartitionDialog
|
||||
openInstallPartitionDialog={openInstallPartitionDialog}
|
||||
version={partitionVersion?.name || ''}
|
||||
partition={partition}
|
||||
LL={LL}
|
||||
onClose={closeInstallPartitionDialog}
|
||||
onInstall={installPartitionFirmware}
|
||||
/>
|
||||
<Typography sx={{ pt: 2, pb: 2 }} variant="h6" color="primary">
|
||||
{LL.UPLOAD()}
|
||||
</Typography>
|
||||
<SingleUpload text={LL.UPLOAD_DROP_TEXT()} doRestart={doRestart} />
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}, [
|
||||
data,
|
||||
error,
|
||||
loadData,
|
||||
LL,
|
||||
platform,
|
||||
internetLive,
|
||||
latestVersion,
|
||||
latestDevVersion,
|
||||
showVersionInfo,
|
||||
locale,
|
||||
openInstallDialog,
|
||||
fetchDevVersion,
|
||||
downloadOnly,
|
||||
me.admin,
|
||||
showButtons,
|
||||
handleVersionInfoClose,
|
||||
closeInstallDialog,
|
||||
installFirmwareURL,
|
||||
doRestart,
|
||||
otherPartitions,
|
||||
setPartitionVersionInfo,
|
||||
showPartitionDialog,
|
||||
partitionVersion,
|
||||
partition,
|
||||
firmwareSize,
|
||||
closeInstallPartitionDialog,
|
||||
installPartitionFirmware
|
||||
]);
|
||||
|
||||
return restarting ? <SystemMonitor /> : <SectionContent>{content}</SectionContent>;
|
||||
};
|
||||
|
||||
export default memo(Version);
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type FC, type PropsWithChildren, memo, useMemo } from 'react';
|
||||
import { type FC, type PropsWithChildren, memo } from 'react';
|
||||
|
||||
import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined';
|
||||
import ErrorIcon from '@mui/icons-material/Error';
|
||||
@@ -38,27 +38,30 @@ const MessageBox: FC<PropsWithChildren<MessageBoxProps>> = ({
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
|
||||
const { Icon, backgroundColor } = useMemo(() => {
|
||||
const Icon = LEVEL_ICONS[level];
|
||||
const palettePath = LEVEL_PALETTE_PATHS[level];
|
||||
const [key, shade] = palettePath.split('.') as [
|
||||
keyof typeof theme.palette,
|
||||
string
|
||||
];
|
||||
const paletteKey = theme.palette[key] as unknown as Record<string, string>;
|
||||
const backgroundColor = paletteKey[shade];
|
||||
|
||||
return { Icon, backgroundColor };
|
||||
}, [level, theme]);
|
||||
const Icon = LEVEL_ICONS[level];
|
||||
const palettePath = LEVEL_PALETTE_PATHS[level];
|
||||
const [paletteKeyName, shade] = palettePath.split('.') as [
|
||||
keyof typeof theme.palette,
|
||||
string
|
||||
];
|
||||
const paletteKey = theme.palette[paletteKeyName] as unknown as Record<
|
||||
string,
|
||||
string
|
||||
>;
|
||||
const backgroundColor = paletteKey[shade];
|
||||
|
||||
return (
|
||||
<Box
|
||||
p={2}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
borderRadius={1}
|
||||
sx={{ backgroundColor, color: 'white', ...sx }}
|
||||
{...rest}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
borderRadius: 1,
|
||||
backgroundColor,
|
||||
color: 'white',
|
||||
p: 2,
|
||||
...sx
|
||||
}}
|
||||
>
|
||||
<Icon />
|
||||
{(message || children) && (
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { memo, useCallback, useContext, useMemo } from 'react';
|
||||
import { memo, useContext } from 'react';
|
||||
import type { ChangeEventHandler } from 'react';
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
import { MenuItem, TextField } from '@mui/material';
|
||||
|
||||
import CZflag from 'i18n/CZ.svg';
|
||||
import CZflag from 'i18n/CS.svg';
|
||||
import DEflag from 'i18n/DE.svg';
|
||||
import FRflag from 'i18n/FR.svg';
|
||||
import GBflag from 'i18n/GB.svg';
|
||||
@@ -28,7 +28,7 @@ interface LanguageOption {
|
||||
}
|
||||
|
||||
const LANGUAGE_OPTIONS: LanguageOption[] = [
|
||||
{ key: 'cz', flag: CZflag, label: 'CZ' },
|
||||
{ key: 'cs', flag: CZflag, label: 'CS' },
|
||||
{ key: 'de', flag: DEflag, label: 'DE' },
|
||||
{ key: 'en', flag: GBflag, label: 'EN' },
|
||||
{ key: 'fr', flag: FRflag, label: 'FR' },
|
||||
@@ -44,27 +44,14 @@ const LANGUAGE_OPTIONS: LanguageOption[] = [
|
||||
const LanguageSelector = () => {
|
||||
const { setLocale, locale, LL } = useContext(I18nContext);
|
||||
|
||||
const onLocaleSelected: ChangeEventHandler<HTMLInputElement> = useCallback(
|
||||
async ({ target }) => {
|
||||
const loc = target.value as Locales;
|
||||
localStorage.setItem('lang', loc);
|
||||
await loadLocaleAsync(loc);
|
||||
setLocale(loc);
|
||||
},
|
||||
[setLocale]
|
||||
);
|
||||
|
||||
// Memoize menu items to prevent recreation on every render
|
||||
const menuItems = useMemo(
|
||||
() =>
|
||||
LANGUAGE_OPTIONS.map(({ key, flag, label }) => (
|
||||
<MenuItem key={key} value={key}>
|
||||
<img src={flag} style={flagStyle} alt={label} />
|
||||
{label}
|
||||
</MenuItem>
|
||||
)),
|
||||
[]
|
||||
);
|
||||
const onLocaleSelected: ChangeEventHandler<HTMLInputElement> = async ({
|
||||
target
|
||||
}) => {
|
||||
const loc = target.value as Locales;
|
||||
localStorage.setItem('lang', loc);
|
||||
await loadLocaleAsync(loc);
|
||||
setLocale(loc);
|
||||
};
|
||||
|
||||
return (
|
||||
<TextField
|
||||
@@ -76,7 +63,12 @@ const LanguageSelector = () => {
|
||||
size="small"
|
||||
select
|
||||
>
|
||||
{menuItems}
|
||||
{LANGUAGE_OPTIONS.map(({ key, flag, label }) => (
|
||||
<MenuItem key={key} value={key}>
|
||||
<img src={flag} style={flagStyle} alt={label} />
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useCallback, useState } from 'react';
|
||||
import { memo, useState } from 'react';
|
||||
import type { FC } from 'react';
|
||||
|
||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||
@@ -13,9 +13,9 @@ type ValidatedPasswordFieldProps = Omit<ValidatedTextFieldProps, 'type'>;
|
||||
const ValidatedPasswordField: FC<ValidatedPasswordFieldProps> = ({ ...props }) => {
|
||||
const [showPassword, setShowPassword] = useState<boolean>(false);
|
||||
|
||||
const togglePasswordVisibility = useCallback(() => {
|
||||
const togglePasswordVisibility = () => {
|
||||
setShowPassword((prev) => !prev);
|
||||
}, []);
|
||||
};
|
||||
|
||||
return (
|
||||
<ValidatedTextField
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { FC } from 'react';
|
||||
import { FormHelperText, TextField } from '@mui/material';
|
||||
import type { TextFieldProps } from '@mui/material';
|
||||
|
||||
import type { ValidateFieldsError } from 'async-validator';
|
||||
import type { ValidateFieldsError } from 'validators/schema';
|
||||
|
||||
interface ValidatedFieldProps {
|
||||
fieldErrors?: ValidateFieldsError;
|
||||
|
||||
@@ -18,7 +18,6 @@ const LayoutComponent: FC<RequiredChildrenProps> = ({ children }) => {
|
||||
const [title, setTitle] = useState(PROJECT_NAME);
|
||||
const { pathname } = useLocation();
|
||||
|
||||
// Memoize drawer toggle handler to prevent unnecessary re-renders
|
||||
const handleDrawerToggle = useCallback(() => {
|
||||
setMobileOpen((prev) => !prev);
|
||||
}, []);
|
||||
@@ -28,7 +27,6 @@ const LayoutComponent: FC<RequiredChildrenProps> = ({ children }) => {
|
||||
setMobileOpen(false);
|
||||
}, [pathname]);
|
||||
|
||||
// Memoize context value to prevent unnecessary re-renders
|
||||
const contextValue = useMemo(() => ({ title, setTitle }), [title]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
import { memo } from 'react';
|
||||
import { Link, useLocation, useNavigate } from 'react-router';
|
||||
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
@@ -39,14 +39,11 @@ const LayoutAppBarComponent = ({ title, onToggleDrawer }: LayoutAppBarProps) =>
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const pathnames = useMemo(
|
||||
() => location.pathname.split('/').filter((x) => x),
|
||||
[location.pathname]
|
||||
);
|
||||
const pathnames = location.pathname.split('/').filter((x) => x);
|
||||
|
||||
const handleBackClick = useCallback(() => {
|
||||
const handleBackClick = () => {
|
||||
void navigate('/' + pathnames[0]);
|
||||
}, [navigate, pathnames]);
|
||||
};
|
||||
|
||||
return (
|
||||
<AppBar position="fixed" sx={appBarStyles}>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { memo, useMemo } from 'react';
|
||||
import { memo } from 'react';
|
||||
|
||||
import { Box, Divider, Drawer, Toolbar, Typography, styled } from '@mui/material';
|
||||
|
||||
import { readSettings } from 'api/app';
|
||||
|
||||
import { useRequest } from 'alova/client';
|
||||
import { PROJECT_NAME } from 'env';
|
||||
|
||||
import { DRAWER_WIDTH } from './Layout';
|
||||
@@ -24,22 +27,34 @@ interface LayoutDrawerProps {
|
||||
}
|
||||
|
||||
const LayoutDrawerComponent = ({ mobileOpen, onClose }: LayoutDrawerProps) => {
|
||||
// Memoize drawer content to prevent unnecessary re-renders
|
||||
const drawer = useMemo(
|
||||
() => (
|
||||
<>
|
||||
<Toolbar disableGutters>
|
||||
<Box display="flex" alignItems="center" px={2}>
|
||||
<LayoutDrawerLogo src="/app/icon.png" alt={PROJECT_NAME} />
|
||||
<Typography variant="h6">{PROJECT_NAME}</Typography>
|
||||
const { data: settings } = useRequest(readSettings);
|
||||
const system_name = settings?.system_name;
|
||||
|
||||
const drawer = (
|
||||
<>
|
||||
<Toolbar disableGutters>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', p: 2 }}>
|
||||
<LayoutDrawerLogo src="/app/icon.png" alt={PROJECT_NAME} />
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
<Typography>{PROJECT_NAME}</Typography>
|
||||
{system_name && (
|
||||
<Typography color="secondary" variant="body2">
|
||||
{system_name}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<Divider absolute />
|
||||
</Toolbar>
|
||||
<Divider />
|
||||
<LayoutMenu />
|
||||
</>
|
||||
),
|
||||
[]
|
||||
</Box>
|
||||
<Divider absolute />
|
||||
</Toolbar>
|
||||
<Divider />
|
||||
<LayoutMenu />
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useCallback, useContext, useState } from 'react';
|
||||
import { memo, useContext, useState } from 'react';
|
||||
|
||||
import AccountCircleIcon from '@mui/icons-material/AccountCircle';
|
||||
import AssessmentIcon from '@mui/icons-material/Assessment';
|
||||
@@ -8,6 +8,7 @@ import KeyboardArrowDown from '@mui/icons-material/KeyboardArrowDown';
|
||||
import LiveHelpIcon from '@mui/icons-material/LiveHelp';
|
||||
import MoreTimeIcon from '@mui/icons-material/MoreTime';
|
||||
import PlaylistAddIcon from '@mui/icons-material/PlaylistAdd';
|
||||
import PlaylistPlayIcon from '@mui/icons-material/PlaylistPlay';
|
||||
import SensorsIcon from '@mui/icons-material/Sensors';
|
||||
import SettingsIcon from '@mui/icons-material/Settings';
|
||||
import StarIcon from '@mui/icons-material/Star';
|
||||
@@ -18,13 +19,15 @@ import { AuthenticatedContext } from 'contexts/authentication';
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
|
||||
const LayoutMenuComponent = () => {
|
||||
const { me } = useContext(AuthenticatedContext);
|
||||
const { me, versions } = useContext(AuthenticatedContext);
|
||||
const { LL } = useI18nContext();
|
||||
const [menuOpen, setMenuOpen] = useState(true);
|
||||
|
||||
const handleMenuToggle = useCallback(() => {
|
||||
const upgradeAvailable = versions?.current?.upgradeable ?? false;
|
||||
|
||||
const handleMenuToggle = () => {
|
||||
setMenuOpen((prev) => !prev);
|
||||
}, []);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -51,9 +54,7 @@ const LayoutMenuComponent = () => {
|
||||
sx={{ my: 0 }}
|
||||
slotProps={{
|
||||
primary: {
|
||||
fontWeight: '600',
|
||||
mb: '2px',
|
||||
color: 'lightblue'
|
||||
sx: { fontWeight: 600, mb: '2px', color: 'lightblue' }
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -80,6 +81,12 @@ const LayoutMenuComponent = () => {
|
||||
disabled={!me.admin}
|
||||
to={`/customizations`}
|
||||
/>
|
||||
<LayoutMenuItem
|
||||
icon={PlaylistPlayIcon}
|
||||
label={LL.COMMANDS()}
|
||||
disabled={!me.admin}
|
||||
to={`/commands`}
|
||||
/>
|
||||
<LayoutMenuItem
|
||||
icon={MoreTimeIcon}
|
||||
label={LL.SCHEDULER()}
|
||||
@@ -107,6 +114,7 @@ const LayoutMenuComponent = () => {
|
||||
label={LL.SETTINGS(0)}
|
||||
disabled={!me.admin}
|
||||
to="/settings"
|
||||
badge={upgradeAvailable}
|
||||
/>
|
||||
<LayoutMenuItem icon={LiveHelpIcon} label={LL.HELP()} to={`/help`} />
|
||||
<Divider />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { memo, useMemo } from 'react';
|
||||
import { memo } from 'react';
|
||||
import { Link, useLocation } from 'react-router';
|
||||
|
||||
import { ListItemButton, ListItemIcon, ListItemText } from '@mui/material';
|
||||
import { Box, ListItemButton, ListItemIcon, ListItemText } from '@mui/material';
|
||||
import type { SvgIconProps, SxProps, Theme } from '@mui/material';
|
||||
|
||||
import { routeMatches } from 'utils';
|
||||
@@ -11,60 +11,52 @@ interface LayoutMenuItemProps {
|
||||
label: string;
|
||||
to: string;
|
||||
disabled?: boolean;
|
||||
badge?: boolean;
|
||||
}
|
||||
|
||||
const LayoutMenuItemComponent = ({
|
||||
icon: Icon,
|
||||
label,
|
||||
to,
|
||||
disabled
|
||||
disabled,
|
||||
badge
|
||||
}: LayoutMenuItemProps) => {
|
||||
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 ? '3px' : '0px',
|
||||
backgroundColor: '#90caf9',
|
||||
transition: 'width 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)'
|
||||
}
|
||||
}),
|
||||
[selected]
|
||||
);
|
||||
const buttonStyles: SxProps<Theme> = {
|
||||
transition: 'all 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)',
|
||||
backgroundColor: selected ? 'rgba(144, 202, 249, 0.1)' : 'transparent',
|
||||
borderRadius: '8px',
|
||||
margin: '2px 8px',
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(68, 82, 211, 0.39)'
|
||||
},
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: selected ? '3px' : '0px',
|
||||
backgroundColor: '#90caf9',
|
||||
transition: 'width 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)'
|
||||
}
|
||||
};
|
||||
|
||||
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 iconStyles: SxProps<Theme> = {
|
||||
color: selected ? '#90caf9' : '#9e9e9e',
|
||||
transition: 'color 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)',
|
||||
transform: selected ? 'scale(1.1)' : 'scale(1)',
|
||||
transitionProperty: 'color, transform'
|
||||
};
|
||||
|
||||
const 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]
|
||||
);
|
||||
const textStyles: SxProps<Theme> = {
|
||||
color: selected ? '#90caf9' : '#f5f5f5',
|
||||
transition: 'color 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)',
|
||||
transitionProperty: 'color, font-weight'
|
||||
};
|
||||
|
||||
return (
|
||||
<ListItemButton
|
||||
@@ -78,6 +70,20 @@ const LayoutMenuItemComponent = ({
|
||||
<Icon />
|
||||
</ListItemIcon>
|
||||
<ListItemText sx={textStyles}>{label}</ListItemText>
|
||||
{badge && (
|
||||
<Box
|
||||
aria-label="update available"
|
||||
sx={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
ml: 1,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: '#ffeb3b',
|
||||
boxShadow: '0 0 6px rgba(255, 235, 59, 0.8)',
|
||||
flexShrink: 0
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</ListItemButton>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Link } from 'react-router';
|
||||
import NavigateNextIcon from '@mui/icons-material/NavigateNext';
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
ListItem,
|
||||
ListItemAvatar,
|
||||
ListItemButton,
|
||||
@@ -20,6 +21,7 @@ interface ListMenuItemProps {
|
||||
text: string;
|
||||
to?: string;
|
||||
disabled?: boolean;
|
||||
badge?: boolean;
|
||||
}
|
||||
|
||||
const iconStyles: CSSProperties = {
|
||||
@@ -28,15 +30,40 @@ const iconStyles: CSSProperties = {
|
||||
verticalAlign: 'middle'
|
||||
};
|
||||
|
||||
const Badge = () => (
|
||||
<Box
|
||||
component="span"
|
||||
aria-label="update available"
|
||||
sx={{
|
||||
display: 'inline-block',
|
||||
width: 8,
|
||||
height: 8,
|
||||
ml: 1,
|
||||
verticalAlign: 'middle',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: '#ffeb3b',
|
||||
boxShadow: '0 0 6px rgba(255, 235, 59, 0.8)'
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
const RenderIcon = memo(
|
||||
({ icon: Icon, bgcolor, label, text }: ListMenuItemProps) => (
|
||||
({ icon: Icon, bgcolor, label, text, badge }: ListMenuItemProps) => (
|
||||
<>
|
||||
<ListItemAvatar>
|
||||
<Avatar sx={{ bgcolor, color: 'white' }}>
|
||||
<Icon />
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText primary={label} secondary={text} />
|
||||
<ListItemText
|
||||
primary={
|
||||
<>
|
||||
{label}
|
||||
{badge && <Badge />}
|
||||
</>
|
||||
}
|
||||
secondary={text}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
);
|
||||
@@ -47,7 +74,8 @@ const LayoutMenuItem = ({
|
||||
label,
|
||||
text,
|
||||
to,
|
||||
disabled
|
||||
disabled,
|
||||
badge
|
||||
}: ListMenuItemProps) => (
|
||||
<>
|
||||
{to && !disabled ? (
|
||||
@@ -65,6 +93,7 @@ const LayoutMenuItem = ({
|
||||
{...(bgcolor && { bgcolor })}
|
||||
label={label}
|
||||
text={text}
|
||||
{...(badge && { badge })}
|
||||
/>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
@@ -75,6 +104,7 @@ const LayoutMenuItem = ({
|
||||
{...(bgcolor && { bgcolor })}
|
||||
label={label}
|
||||
text={text}
|
||||
{...(badge && { badge })}
|
||||
/>
|
||||
</ListItem>
|
||||
)}
|
||||
|
||||
@@ -32,8 +32,16 @@ const FormLoaderComponent = ({ errorMessage, onRetry }: FormLoaderProps) => {
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Box m={2} py={2} display="flex" alignItems="center" flexDirection="column">
|
||||
<Box py={2}>
|
||||
<Box
|
||||
sx={{
|
||||
m: 2,
|
||||
py: 2,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flexDirection: 'column'
|
||||
}}
|
||||
>
|
||||
<Box sx={{ p: 2 }}>
|
||||
<CircularProgress size={100} />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user