1002 Commits

Author SHA1 Message Date
Proddy
0e08334132 Merge pull request #3043 from MichaelDvP/core3
sync Core3
2026-04-22 21:44:19 +02:00
MichaelDvP
3d51acf9e7 Merge branch 'core3' of https://github.com/emsesp/EMS-ESP32 into core3 2026-04-22 16:51:25 +02:00
MichaelDvP
fd6ea5ed7e Merge branch 'dev' of https://github.com/emsesp/EMS-ESP32 into core3 2026-04-22 16:48:39 +02:00
proddy
db2be70d66 chore: update generated files for v3.8.2-dev.18 2026-04-22 14:22:25 +00:00
Proddy
c36f231990 Merge pull request #3042 from proddy/dev
minor updates
2026-04-22 16:10:20 +02:00
Proddy
d18e5b1f14 Merge pull request #3041 from proddy/core3
Core3 sync
2026-04-22 16:09:58 +02:00
proddy
20327d817d async-validator fixes 2026-04-22 16:07:59 +02:00
proddy
26102121e1 async-validator fixes 2026-04-22 16:07:56 +02:00
proddy
8e64c6303e package update 2026-04-22 15:43:58 +02:00
proddy
051c332426 package update 2026-04-22 15:43:46 +02:00
proddy
a09258325e remove YIELD 2026-04-22 15:43:36 +02:00
proddy
74c76eb90b remove YIELD 2026-04-22 15:43:29 +02:00
proddy
daffdcf58e https://github.com/emsesp/EMS-ESP32/issues/2686 2026-04-22 15:43:20 +02:00
proddy
61dca0cbda https://github.com/emsesp/EMS-ESP32/issues/2686 2026-04-22 15:43:10 +02:00
Proddy
2bff299193 Merge pull request #3037 from MichaelDvP/core3
Core3 update
2026-04-22 15:26:26 +02:00
Proddy
4bc4fa903f Merge pull request #3040 from MichaelDvP/dev
version checks prelease
2026-04-22 15:11:02 +02:00
MichaelDvP
1329b13db3 Merge branch 'dev' into core3 2026-04-22 15:05:39 +02:00
MichaelDvP
29380f0303 version checks prelease 2026-04-22 14:59:46 +02:00
MichaelDvP
9dd894f0fe Merge branch 'dev' of https://github.com/emsesp/EMS-ESP32 into core3 2026-04-22 10:14:34 +02:00
Proddy
6b2370b79d Merge pull request #3035 from mattreim/dev
Update German translation
2026-04-22 08:34:14 +02:00
Proddy
dbc636c9bf Merge pull request #3036 from MichaelDvP/dev
small fixes
2026-04-22 08:33:38 +02:00
MichaelDvP
30d1ae5642 update otadata when littlefs fails 2026-04-22 08:21:29 +02:00
MichaelDvP
79aceef382 Merge branch 'core3' of https://github.com/emsesp/EMS-ESP32 into core3 2026-04-22 07:51:58 +02:00
MichaelDvP
a28e52210a Merge branch 'dev' into core3 2026-04-22 07:50:43 +02:00
MichaelDvP
0c0660c04b Merge branch 'dev' of https://github.com/emsesp/EMS-ESP32 into dev 2026-04-22 07:41:29 +02:00
MichaelDvP
08eb294213 update readymail 2026-04-22 07:26:17 +02:00
mattreim
c9fd076394 Update German translation 2026-04-22 01:05:05 +02:00
MichaelDvP
888baed81a pkg update 2026-04-21 21:39:10 +02:00
MichaelDvP
4de3955db2 set partition after update 2026-04-21 21:38:52 +02:00
MichaelDvP
25f08c7624 Merge branch 'dev' into core3 2026-04-21 20:44:02 +02:00
MichaelDvP
35550553be check fetch length for custom entities, dev17 2026-04-21 19:51:48 +02:00
MichaelDvP
06ff219385 version check order 2026-04-21 18:58:42 +02:00
MichaelDvP
e705a5629f fetch length of holiday to 18 2026-04-21 18:58:13 +02:00
proddy
1e8013100c rename build-webUI with build_webUI 2026-04-20 15:46:54 +02:00
proddy
62c8f55568 package update (vite fix) 2026-04-20 15:46:39 +02:00
Proddy
cb3c9653ce Merge pull request #3032 from proddy/dev
rename build_webUI for Python
2026-04-20 15:38:52 +02:00
proddy
0b5a83f6ae package update (vite fix) 2026-04-20 15:37:50 +02:00
MichaelDvP
a079169005 backup nvs1 if exist 2026-04-20 13:18:50 +02:00
proddy
845c51d5f9 rename build_webUI for Python 2026-04-19 21:23:59 +02:00
Proddy
c40d828749 Merge pull request #3031 from proddy/core3
build_webUI -> build-webUI
2026-04-19 19:15:28 +02:00
Proddy
d6d3a034ad Merge pull request #3030 from proddy/dev
build_webUI -> build-webUI
2026-04-19 19:14:31 +02:00
proddy
84ad08887a build_webUI -> build-webUI 2026-04-19 19:13:15 +02:00
proddy
ece08d96ee build_webUI -> build-webUI 2026-04-19 19:09:45 +02:00
MichaelDvP
ed0a678020 Merge branch 'dev' of https://github.com/emsesp/EMS-ESP32 into core3 2026-04-19 15:49:36 +02:00
MichaelDvP
854f4d559a Merge branch 'dev' of https://github.com/emsesp/EMS-ESP32 into dev 2026-04-19 15:40:15 +02:00
Proddy
f186f2a8f2 Merge pull request #3028 from proddy/dev
heap memory optimizations
2026-04-19 15:15:45 +02:00
Proddy
37107d8500 Merge pull request #3029 from proddy/core3
sync memory optimizations from dev-16 to core3
2026-04-19 15:15:14 +02:00
proddy
6b68cb7c61 store UTC epoch time and convert to localtime when render (fixes bug as TZ not set) 2026-04-19 15:12:22 +02:00
proddy
a1e0288e09 close dialog after downloading 2026-04-19 15:11:41 +02:00
proddy
e6c173bdf9 don't show system backup as it's the same page! 2026-04-19 15:11:29 +02:00
proddy
dde6a8c5db close dialog after download 2026-04-19 15:10:51 +02:00
proddy
e2750b8572 don't show system backup as its the same page! 2026-04-19 15:10:41 +02:00
proddy
acd23925b5 download text changes 2026-04-19 15:10:20 +02:00
proddy
b0db054e11 fix firmware install date (was using UTC as TZ not initialised) 2026-04-19 14:17:31 +02:00
MichaelDvP
d9b6de0652 Merge branch 'dev' of https://github.com/proddy/EMS-ESP32 into dev 2026-04-19 13:41:01 +02:00
MichaelDvP
c54da18822 remove pr#3021 2026-04-19 13:40:51 +02:00
proddy
51cea8e757 no need to call ntp on begin as its in the loop 2026-04-19 13:15:11 +02:00
proddy
bbb086ea41 add back NTP 2026-04-19 13:14:54 +02:00
proddy
539e6ed080 remove lazy loading 2026-04-19 10:17:10 +02:00
proddy
555801dc5c remove lazy loading, optimize chunking 2026-04-19 10:05:47 +02:00
proddy
1d33a26318 fix dns name being set to "tasmota" 2026-04-18 19:42:45 +02:00
proddy
86a20fc97a sync with dev-16 2026-04-18 18:54:33 +02:00
proddy
d6e00c4534 UART_FIFO_LEN is deprecated 2026-04-18 18:50:08 +02:00
proddy
6f81945da6 typo 2026-04-18 18:43:03 +02:00
proddy
865c309475 remove c++17 2026-04-18 18:39:45 +02:00
proddy
77b8b21aea use C++ 20 (espressif32@6.13.0 still uses GCC 8 so only 2a supported) 2026-04-18 18:34:47 +02:00
proddy
2f5edffec6 update changelog 2026-04-18 18:29:30 +02:00
proddy
71de64502e include cstdint for uint8_t on new GCC 2026-04-18 17:52:05 +02:00
proddy
6994d3559a package update 2026-04-18 17:43:31 +02:00
proddy
a7d484d218 3.8.2-dev.16 2026-04-18 17:43:23 +02:00
proddy
a810c41acd exclude js 2026-04-18 17:43:14 +02:00
proddy
2fbfdf94ab minor optimizations, use EMSESP_Version, only call esp_image_verify() and store the entry for partitions that actually have a value 2026-04-18 17:40:21 +02:00
proddy
2d7c8f0863 remove semver 2026-04-18 17:36:08 +02:00
proddy
c3b734ab47 add back LTO, remove semver 2026-04-18 17:35:57 +02:00
proddy
644abf105d replace semver with home grown simplier alternative 2026-04-18 17:35:11 +02:00
proddy
5a8a451774 improve chunking 2026-04-18 17:34:48 +02:00
proddy
dae139aa01 single static-content handler serving all assets 2026-04-18 17:33:13 +02:00
proddy
b13fcd8939 single static-content handler serving all assets 2026-04-18 17:32:54 +02:00
Proddy
26b42b4eea Merge pull request #3027 from proddy/dev
update github actions
2026-04-18 10:22:39 +02:00
proddy
c9005e8aa9 upgrade github actions 2026-04-18 10:02:25 +02:00
proddy
6658b11adf use c++20 2026-04-17 18:01:51 +02:00
proddy
e542f5809f remove bogus file 2026-04-17 18:01:44 +02:00
proddy
ce1dd6233d update 2026-04-17 18:01:37 +02:00
proddy
fe488443da package update 2026-04-17 18:01:26 +02:00
Proddy
b264a39780 Merge pull request #3026 from MichaelDvP/dev 2026-04-17 16:41:59 +02:00
MichaelDvP
d2302eaa85 dev15, rollback mbedlt change for memory saving 2026-04-17 15:59:13 +02:00
Proddy
a813d38108 Merge pull request #3025 from MichaelDvP/core3
Core3
2026-04-17 15:44:51 +02:00
MichaelDvP
685a49c212 Merge branch 'dev' into core3, formatting, add back sendmail settings 2026-04-17 12:28:41 +02:00
Proddy
994706c9f2 Merge pull request #3023 from MichaelDvP/dev
fetch telegrams with length, dev14
2026-04-16 12:22:06 +02:00
Proddy
2c8eb534af Merge pull request #3022 from proddy/core3
fix merge errors
2026-04-16 08:46:06 +02:00
proddy
5210fab4cb switch to C++20 for string find commands 2026-04-16 08:45:07 +02:00
proddy
49787d27f1 add missing code lost in merge 2026-04-16 08:44:55 +02:00
proddy
dfe7b46461 remove unused http 2026-04-16 08:44:44 +02:00
MichaelDvP
8a72ab42cb dev 14, changelog 2026-04-16 08:18:17 +02:00
MichaelDvP
c4db8e3914 set length for more fetch telegrams 2026-04-16 08:10:43 +02:00
MichaelDvP
8d0225e595 Merge branch 'dev' of https://github.com/emsesp/EMS-ESP32 into dev 2026-04-16 08:09:58 +02:00
Proddy
f8257de0dd Merge pull request #3009 from MichaelDvP/core3
https client for scheduler/shuntingYard
2026-04-15 21:34:03 +02:00
Proddy
3b3ecc9f1d Merge branch 'core3' into core3 2026-04-15 21:33:27 +02:00
Proddy
966049d0c9 Merge pull request #3006 from proddy/dev
sync with core3 features
2026-04-15 21:29:24 +02:00
proddy
907a65a701 show link to backup page 2026-04-15 21:19:09 +02:00
proddy
f97b8e14e7 package update 2026-04-15 21:18:51 +02:00
proddy
e65f634b21 3.8.2 2026-04-15 21:18:38 +02:00
proddy
fc71ed2b9d 3.8.2 2026-04-15 21:18:27 +02:00
proddy
84105acf5d 3.9.0-dev.0 2026-04-15 20:48:11 +02:00
proddy
def5173692 fix merge issues 2026-04-15 20:37:33 +02:00
proddy
6b31fef1af build on mac osx 2026-04-15 20:37:27 +02:00
Proddy
c9c059ca65 Merge pull request #3020 from proddy/core3
sync with dev
2026-04-15 09:26:32 +02:00
proddy
4d3b31e5a1 sync with dev 2026-04-15 09:25:38 +02:00
proddy
5a8195d430 auto-formatting 2026-04-15 08:12:22 +02:00
proddy
24a7a607f3 add test data 2026-04-15 08:08:49 +02:00
proddy
061f9ffc52 update prettier 2026-04-15 08:08:45 +02:00
proddy
9e17936bfc fix lint 2026-04-14 21:13:25 +02:00
Proddy
18bb2c4f39 Merge branch 'emsesp:dev' into dev 2026-04-14 21:09:31 +02:00
proddy
7c3782a43f upload warnings 2026-04-14 21:08:45 +02:00
proddy
3ac807bdd5 text change 2026-04-14 21:08:04 +02:00
proddy
1111458863 upgrade message warnings 2026-04-14 09:31:50 +02:00
proddy
99c5e2230c fix link 2026-04-14 09:31:35 +02:00
proddy
3317aa845a package update 2026-04-14 09:31:19 +02:00
proddy
97cd657336 fix links 2026-04-14 09:31:05 +02:00
MichaelDvP
3338f919bd Merge branch 'dev' of https://github.com/proddy/EMS-ESP32 into dev 2026-04-14 08:09:50 +02:00
proddy
7dd13bcab7 mui upgrade 2026-04-13 23:30:36 +02:00
MichaelDvP
f226cb359f Merge branch 'dev' of https://github.com/MichaelDvP/EMS-ESP32 into dev 2026-04-13 21:04:23 +02:00
MichaelDvP
abbba0aa42 telegram length for fetched telegrams 2026-04-13 20:57:20 +02:00
MichaelDvP
39b5a52b01 chore: update generated files for v3.8.2-dev.13 2026-04-13 12:12:42 +00:00
MichaelDvP
b6c3fc5bee read fragmented telegram 0x484, #3017 2026-04-13 14:00:12 +02:00
MichaelDvP
909bea00df update espressif32 6.13.0 2026-04-13 13:59:12 +02:00
MichaelDvP
9522945e06 uart buffer size 2026-04-13 13:58:30 +02:00
MichaelDvP
d6a9f2a731 prepare for translations #3015, update pkg 2026-04-13 13:58:10 +02:00
proddy
0f30c81554 fix compile on linux/osx 2026-04-12 20:32:36 +02:00
MichaelDvP
e514ba4bb5 Merge branch 'dev' of https://github.com/emsesp/EMS-ESP32 into dev 2026-04-12 09:46:43 +02:00
Proddy
38e63e3eaa Merge pull request #3016 from misa1515/patch-30
Update index.ts
2026-04-11 22:44:28 +02:00
misa1515
0058324edd Update index.ts 2026-04-10 21:10:36 +02:00
MichaelDvP
ac143d607a http-client to heap 2026-04-10 14:20:17 +02:00
MichaelDvP
e9e3759db3 add solar ts3 2026-04-10 14:19:39 +02:00
MichaelDvP
51d90095aa https client for scheduler/shuntingYard 2026-04-04 11:39:43 +02:00
proddy
fb09e10f19 sync with core3 features 2026-03-30 23:26:04 +02:00
Proddy
16c0370443 Merge pull request #3005 from proddy/core3
Core3 updates
2026-03-30 23:25:22 +02:00
proddy
67bb38dcf4 updated test data 2026-03-30 23:24:35 +02:00
proddy
049231a36e minor text changes 2026-03-30 23:15:18 +02:00
proddy
349d6b7375 package update 2026-03-30 23:15:07 +02:00
proddy
b72b368d3c ArduinoJson @ 7.4.3 2026-03-30 23:15:00 +02:00
proddy
7f9fd44a02 C14 2026-03-30 21:27:26 +02:00
proddy
a400c5974c comment change 2026-03-30 21:09:59 +02:00
proddy
afca995fe5 remove poll check 2026-03-30 21:06:08 +02:00
proddy
81504fedc5 package update 2026-03-30 21:05:55 +02:00
proddy
3da3345683 fix poll_match_count 2026-03-29 16:30:38 +02:00
Proddy
c6c2889306 Merge branch 'dev' into core3 2026-03-29 15:58:45 +02:00
proddy
b60f0d260a updated tests 2026-03-29 15:55:02 +02:00
proddy
cd750e4777 text change 2026-03-29 15:44:39 +02:00
proddy
4e5d503b35 remove mbedtls, update ArduinoJson 2026-03-29 15:44:27 +02:00
proddy
bd09e17e49 3.8.2-dev.C13 2026-03-29 15:43:44 +02:00
proddy
835eb743bb backup/restore #3002 2026-03-29 15:43:35 +02:00
proddy
69a129d80e move NVS initisalisation higher, add check for poll_id == bus ID 2026-03-29 15:43:03 +02:00
proddy
434bf483fd added comment 2026-03-29 15:41:57 +02:00
proddy
2b8e170b40 text changes 2026-03-29 15:41:46 +02:00
proddy
dc9b95f3e7 updated to prevent warning on WiFI mode (shown in debug mode) 2026-03-29 15:41:23 +02:00
proddy
1616b0da0a package update 2026-03-29 15:41:03 +02:00
proddy
91c457b22b don't show EMS bus error on Dashboard, only devices 2026-03-29 15:40:40 +02:00
proddy
70c60647c7 refresh changed from 3 to 5 seconds 2026-03-29 15:40:19 +02:00
Proddy
c0bea66d27 Merge pull request #2996 from MichaelDvP/core3 2026-03-22 10:12:08 +01:00
MichaelDvP
ed7cc078ed fix testdata 2026-03-22 09:53:21 +01:00
Proddy
60b7d6d795 Merge pull request #2995 from MichaelDvP/core3
Core3 updates, adapt to c6 and c3 chips
2026-03-21 18:02:49 +01:00
MichaelDvP
947f29cca0 Merge branch 'core3' of https://github.com/emsesp/EMS-ESP32 into core3 2026-03-21 16:47:05 +01:00
MichaelDvP
d2a13ec0da core3 adaptions for c3 and c6, compiles for all chips 2026-03-21 16:23:07 +01:00
proddy
cc39ba409e package update 2026-03-21 15:49:14 +01:00
Proddy
09473f17a0 Merge pull request #2994 from MichaelDvP/dev
dev.12, add dhw4, 5,.. circuits, #2991
2026-03-21 15:46:21 +01:00
MichaelDvP
ac9db6256e Merge branch 'dev' into core3 2026-03-21 11:19:05 +01:00
MichaelDvP
096f628d97 Merge branch 'core3' of https://github.com/emsesp/EMS-ESP32 into core3 2026-03-21 11:02:41 +01:00
MichaelDvP
bbc2de08a5 support dhw5... 2026-03-21 09:52:04 +01:00
proddy
22312812bb system backup 2026-03-20 17:14:58 +01:00
MichaelDvP
df808a2bcf dev.12, add dhw4 circuit, #2991 2026-03-20 14:53:07 +01:00
MichaelDvP
d04e7c36f3 reset reason 2026-03-20 14:50:58 +01:00
MichaelDvP
205d826adb asyncWebserver 3.10.3, remove C6 (no core 2 support) 2026-03-20 14:48:34 +01:00
MichaelDvP
3584975acb env: c6 2026-03-19 16:43:18 +01:00
MichaelDvP
30b9ca4e6c Merge branch 'core3' of https://github.com/emsesp/EMS-ESP32 into core3 2026-03-19 16:41:21 +01:00
MichaelDvP
7c6ff01ebe reset reason, uart adapt for C6 2026-03-19 16:39:27 +01:00
proddy
a54edcaf5b formatting 2026-03-18 20:51:40 +01:00
proddy
e446954844 #2971 2026-03-18 20:45:36 +01:00
proddy
4a2d0d6787 package update 2026-03-18 20:35:55 +01:00
proddy
9725314135 Platform 2026.03.50 Tasmota Arduino Core 3.3.7 based on IDF 5.5.3.260313 and ESP32Async/ESPAsyncWebServer @ 3.10.3 2026-03-18 20:18:59 +01:00
Proddy
4db8e43648 Merge pull request #2990 from MichaelDvP/dev
dev.11, fix #2988, asyncWebserver 3.10.2
2026-03-18 20:18:11 +01:00
MichaelDvP
e610f0d57f Merge branch 'core3' of https://github.com/emsesp/EMS-ESP32 into core3 2026-03-18 14:21:59 +01:00
MichaelDvP
8244af2940 Merge branch 'dev' into core3, fix #2988 2026-03-18 11:47:16 +01:00
MichaelDvP
cc60062678 dev.11, fix #2988, asyncWebserver 3.10.2 2026-03-18 10:53:27 +01:00
proddy
40f371d23b remove check for downloadOnly 2026-03-17 22:01:38 +01:00
proddy
817b791e59 remove flto 2026-03-17 21:47:12 +01:00
proddy
25a7aac360 lint warning fix 2026-03-17 21:47:01 +01:00
proddy
37115a174d show size of firmware not partition 2026-03-17 21:46:51 +01:00
proddy
1397f81fd0 add C in version so we know its Core 2026-03-17 21:46:27 +01:00
proddy
56365cb403 formatting 2026-03-17 21:46:15 +01:00
proddy
dfd245ee7b rename common.h 2026-03-17 21:45:12 +01:00
proddy
9c81e4b34d optimize for vite v8 2026-03-17 21:44:38 +01:00
proddy
67676df131 package update 2026-03-17 21:44:29 +01:00
proddy
a73b129596 update vite v8 2026-03-17 21:44:17 +01:00
Proddy
4600d886b5 Merge pull request #2986 from MichaelDvP/core3
update Core3, change TLS library
2026-03-17 20:27:45 +01:00
Proddy
e3305ab9db Merge pull request #2985 from MichaelDvP/dev
devcie class #2980 and version update #2981, dev10
2026-03-17 20:17:29 +01:00
MichaelDvP
0fe45a2405 use ESP_SSLClient for mqtt, add sendmail command (using readymail) 2026-03-17 18:53:37 +01:00
MichaelDvP
db87213242 Merge branch 'dev' into core3 2026-03-17 10:19:26 +01:00
MichaelDvP
b0157f288e update changelog, dev10 2026-03-17 10:16:01 +01:00
MichaelDvP
5c3c010d5a Merge branch 'dev' into core3 2026-03-16 14:02:02 +01:00
MichaelDvP
c804cedd7a update changelog 2026-03-16 14:01:45 +01:00
MichaelDvP
a9f50d9371 update version number fixes #2981 2026-03-16 12:55:27 +01:00
MichaelDvP
65a3226404 Merge branch 'dev' of https://github.com/emsesp/EMS-ESP32 into dev 2026-03-16 10:05:59 +01:00
Proddy
45690f5418 Merge pull request #2982 from misa1515/patch-29
Update locale_translations.h
2026-03-15 22:16:51 +01:00
MichaelDvP
aa30ca99bf update core 2026-03-15 21:41:18 +01:00
misa1515
6836b6197f Update locale_translations.h 2026-03-15 21:27:58 +01:00
MichaelDvP
c0ca9d1069 update pkg 2026-03-15 16:13:50 +01:00
MichaelDvP
5e79e1d57f more messages on network connect 2026-03-15 16:13:21 +01:00
MichaelDvP
8c732f9f1e allow modbus start/stop without reboot 2026-03-15 16:12:50 +01:00
MichaelDvP
5e94c2f636 Merge branch 'dev' into core3 2026-03-15 16:11:36 +01:00
MichaelDvP
69d4163b9d fix device_class #2980 2026-03-15 14:12:47 +01:00
proddy
b1e974a82c chore: update generated files for v3.8.2-dev.9 2026-03-13 19:09:14 +00:00
Proddy
34a2b20be8 Merge pull request #2978 from MichaelDvP/dev
add basflowtemp #2969, add pumpkick #2965, add reset HP #2933, fix custom brand
2026-03-13 19:58:03 +01:00
MichaelDvP
f1fc8d9aae update testdata 2026-03-13 16:52:48 +01:00
MichaelDvP
b04355e3e1 update asyncwebserver 2026-03-13 10:32:33 +01:00
MichaelDvP
cd3ae5cdf2 Merge branch 'dev' of https://github.com/emsesp/EMS-ESP32 into dev 2026-03-13 10:22:24 +01:00
MichaelDvP
a261ca23af add baseflowtemp #2969 2026-03-13 10:15:51 +01:00
MichaelDvP
cb96904a5c fix custom brand use after free of c_str() in json. 2026-03-13 10:15:00 +01:00
MichaelDvP
4a2d78f8e1 minflowtemp taken from offset 13 or 8 2026-03-11 18:43:25 +01:00
Proddy
f5af4fb52f Merge pull request #2975 from mrkev-gh/esp32s3-no-psram
fix allowed pins for S32S3 without PSRAM
2026-03-11 17:36:38 +01:00
MichaelDvP
2037bc3a10 add reset of HP errors #2933, dev9 2026-03-07 12:02:09 +01:00
MichaelDvP
64d17d7c65 Test for minflowtemp 2026-03-07 11:43:12 +01:00
MichaelDvP
92e2633342 typo 2026-03-07 11:42:27 +01:00
mrkev-gh
96a7ea8a02 fix allowed pins for S32S3 without PSRAM
Some S32S3 do not have PSRAM (e.g. ESP32-S3FN8) and use those GPIO pins
2026-02-28 11:44:53 +01:00
MichaelDvP
5c4aaa4510 add pumpkick #2965, dev.8 2026-02-20 09:56:08 +01:00
Proddy
c05e1cb77b Merge pull request #2966 from MichaelDvP/dev
fixes for #2960 and #2962
2026-02-19 21:58:20 +01:00
MichaelDvP
5879ce4090 fix SRC mode setting from HA #2960 2026-02-18 08:14:47 +01:00
MichaelDvP
ac3e5c793c fix typo for SRC ha-climate creation 2026-02-17 10:09:22 +01:00
MichaelDvP
64e5d29996 fix typo in HA-climate creation 2026-02-17 09:23:20 +01:00
Proddy
b320d8ded2 Merge pull request #2963 from MichaelDvP/core3
Core3 network fixes
2026-02-16 18:12:33 +01:00
MichaelDvP
4326fb931b add prometheus metrics for analog/scheduler/custom #2962 2026-02-16 15:56:23 +01:00
MichaelDvP
ced7051ce7 add prometheus metrics for temperaturesensors 2026-02-16 12:05:45 +01:00
MichaelDvP
0be1b20996 statemachine for network connection replaces onEvent 2026-02-16 10:37:28 +01:00
MichaelDvP
6c55460622 Merge branch 'dev' into core3 2026-02-16 08:34:23 +01:00
MichaelDvP
421da246ed fix SRC seltemp offset for auto mode #2960 2026-02-16 07:51:10 +01:00
MichaelDvP
d627404dc2 skip onEvent for AP, MQTT, NTP 2026-02-16 07:47:13 +01:00
MichaelDvP
148a721e17 read connect seltemp after mode/icon to create HA-climate 2026-02-15 16:49:21 +01:00
proddy
f317123c26 fix standalone 2026-02-15 15:27:11 +01:00
proddy
e4df1887b0 remove 2026-02-15 14:16:55 +01:00
proddy
34142c3e85 use Tasmota everywhere 2026-02-15 14:16:50 +01:00
proddy
6e7f8bdf02 add -DNO_TLS_SUPPORT 2026-02-15 14:16:27 +01:00
proddy
3dd9fcfb58 update 2026-02-15 14:16:04 +01:00
proddy
35e2954b8b fix lint errors 2026-02-15 14:15:55 +01:00
proddy
59aa63db0f package update 2026-02-15 14:15:43 +01:00
proddy
7a41a190f8 support s3 2026-02-15 14:15:13 +01:00
proddy
6741232450 WIP: ESP-IDF Core 3 migration - mbedtls SSL, module library, board configs, MQTT and network updates 2026-02-15 13:53:13 +01:00
MichaelDvP
a811670c5a 3.8.2-dev.6, changelog 2026-02-15 12:03:33 +01:00
MichaelDvP
72f08a86cf fix SRC climate, #2960 2026-02-15 12:03:07 +01:00
MichaelDvP
27c471f45f set model for ems-esp devices, #2958 2026-02-15 12:02:36 +01:00
MichaelDvP
e303972d26 update AsyncWebserver and pkg 2026-02-15 12:01:50 +01:00
MichaelDvP
97bb03d703 add missing check for number mode change 2026-02-15 12:01:10 +01:00
Proddy
e9f77c1bde Merge pull request #2954 from MichaelDvP/dev
fix brand in HA
2026-02-12 17:54:41 +01:00
MichaelDvP
81cba6c0a8 fix brand in HA 2026-02-12 17:41:10 +01:00
Proddy
89029df25e Merge pull request #2953 from MichaelDvP/dev
customize device brand #2784
2026-02-12 13:19:46 +01:00
MichaelDvP
3463b6818d update testdata 2026-02-12 12:14:13 +01:00
MichaelDvP
349843e666 Merge branch 'dev' of https://github.com/emsesp/EMS-ESP32 into dev 2026-02-12 12:02:45 +01:00
MichaelDvP
96ae3bbbba customze device brand #2784 2026-02-12 12:01:39 +01:00
Proddy
b153364b60 Merge pull request #2947 from MichaelDvP/dev
save scheduler, custom to nvs, fix #2946
2026-02-10 19:39:59 +01:00
MichaelDvP
ccc40937fb chore: update generated files for v3.8.2-dev.3 2026-02-09 12:02:38 +00:00
MichaelDvP
909edf394d add back 4wayValve as bool, #2844 2026-02-09 09:36:13 +01:00
MichaelDvP
ac8ef646e9 fix standalone test 2026-02-08 14:46:54 +01:00
MichaelDvP
3ef279ea10 3.8.2-dev.3, changelog, update pkg 2026-02-08 14:17:20 +01:00
MichaelDvP
769beeda37 save scheduler active flag to nvs 2026-02-08 13:53:57 +01:00
MichaelDvP
f83404c216 add custom entity type NVS 2026-02-08 13:53:32 +01:00
MichaelDvP
c239658131 Merge branch 'dev' of https://github.com/emsesp/EMS-ESP32 into dev 2026-02-02 18:52:29 +01:00
MichaelDvP
be82afd778 add translated heat/eco modes to mode_str_tpl 2026-02-02 18:52:21 +01:00
Proddy
2156c96368 Merge pull request #2942 from misa1515/patch-28
Update locale_translations.h
2026-02-02 17:48:39 +01:00
misa1515
f7f078d82a Update locale_translations.h 2026-02-02 14:15:09 +01:00
Proddy
95168cf514 Add DeepWiki badge to README 2026-02-01 12:57:10 +01:00
Proddy
6dc601c4a2 Merge pull request #2940 from proddy/dev
set range for comfort point temp + offset - #2935
2026-02-01 12:03:22 +01:00
proddy
6b87bbb882 set range for comfort point temp + offset - #2935 2026-02-01 12:02:40 +01:00
proddy
abdf2c5037 package update 2026-02-01 12:02:04 +01:00
Proddy
7beec1b80f Merge pull request #2939 from MichaelDvP/dev
fixes and additions: #2918, #2931, #2933, #2935, #2936
2026-01-30 11:30:34 +01:00
MichaelDvP
3a0e46f064 update expected test data 2026-01-30 11:21:44 +01:00
MichaelDvP
85cc85a923 remove MAX_LOG_ENTRIES check in logger web page 2026-01-30 09:56:38 +01:00
MichaelDvP
ca0079c0df Merge branch 'dev' of https://github.com/MichaelDvP/EMS-ESP32 into dev 2026-01-30 08:58:21 +01:00
MichaelDvP
28b662ad43 update to ESPAsyncWebServer 3.9.6 2026-01-30 08:57:14 +01:00
MichaelDvP
6bac6bbfeb chore: update generated files for v3.8.2-dev.2 2026-01-30 07:08:12 +00:00
MichaelDvP
958ec1002b dev.2 2026-01-30 07:56:39 +01:00
MichaelDvP
438852ecaf Merge branch 'dev' of https://github.com/emsesp/EMS-ESP32 into dev 2026-01-30 07:51:12 +01:00
MichaelDvP
92d82c0a00 remove burnMinPower, #2918 2026-01-30 07:50:54 +01:00
Proddy
7d37267f57 Merge pull request #2934 from proddy/dev
a collection of small changes
2026-01-29 21:03:23 +01:00
MichaelDvP
5641d53cc3 revert syslog buffer (still in heap) 2026-01-29 18:26:03 +01:00
MichaelDvP
8fc6752290 HA climate mode and icon check 2026-01-29 09:53:20 +01:00
proddy
cca6f87500 package update 2026-01-28 21:47:20 +01:00
proddy
758d76051f update discord URL 2026-01-28 21:47:13 +01:00
proddy
95f7e66cff update discord URL and tidy up 2026-01-28 21:46:58 +01:00
MichaelDvP
4e194287c9 remove SRC climate for test 2026-01-28 18:07:47 +01:00
MichaelDvP
3545830552 chore: update generated files for v3.8.2-dev.1 2026-01-28 14:20:04 +00:00
MichaelDvP
074f4c32ed dev.1, changelog 2026-01-28 15:08:28 +01:00
MichaelDvP
b3fec5ed7d fix SRC climate creation, #2936 2026-01-28 15:02:28 +01:00
MichaelDvP
ffb90b8f9a comfortpoint temperature and offset, #2935 2026-01-28 15:01:57 +01:00
MichaelDvP
584618043d weblog buffer max 1000 messages with psram, syslog buffer 250 with psram 2026-01-28 15:01:15 +01:00
MichaelDvP
d702c485b7 validate custom entity writes, #2931 2026-01-25 19:37:34 +01:00
MichaelDvP
d3561da331 Merge branch 'dev' of https://github.com/emsesp/EMS-ESP32 into dev 2026-01-25 19:22:07 +01:00
MichaelDvP
0e0aaf37df add climate modes night/eco as off 2026-01-25 19:21:59 +01:00
proddy
5ec068409f package update 2026-01-24 12:10:52 +01:00
proddy
8796b6d340 update dictionary 2026-01-24 12:10:45 +01:00
proddy
bfbb18655d memory optimzations 2026-01-23 19:37:21 +01:00
proddy
9088651e53 asyncwebserver update, improved caching 2026-01-23 19:35:53 +01:00
proddy
3e8f379502 package update 2026-01-23 19:35:37 +01:00
proddy
265c2c4231 3.8.2-dev.1 2026-01-23 12:52:55 +01:00
proddy
f671d79280 package update 2026-01-22 16:40:37 +01:00
proddy
97c89d1d13 add psram 2026-01-22 16:40:15 +01:00
proddy
e0a26a38fa replace unordered_map with map, less heap 2026-01-22 16:40:05 +01:00
proddy
038f06e59f show psram on startup 2026-01-22 16:39:14 +01:00
proddy
f4d2bae04f add psram 2026-01-22 16:38:52 +01:00
proddy
d443e275ea Merge branch 'dev' of https://github.com/proddy/EMS-ESP32 into dev 2026-01-21 14:46:10 +01:00
Proddy
30d2057e01 Merge pull request #2930 from jvhaarst/patch-1
Update Dutch translations for various terms
2026-01-20 08:49:45 +00:00
Jan van Haarst
d952b9aaae Update Dutch translations for various terms
As I don't have the board yet, I saw the error message "Als deze waarschuwing blijft staan na een paar seconden dan loop de instellingen na en in het bijzonder het apparaat type profiel na."
That sentence didn't really flow right for me, so I had a look at the rest of the text, with this result.
2026-01-20 08:27:24 +01:00
proddy
3402215e8d optimizations 2026-01-18 15:49:35 +00:00
proddy
f01031dc26 optimize 2026-01-18 15:00:43 +00:00
proddy
01d4d116b9 cspell updates 2026-01-18 12:36:34 +00:00
proddy
bc7f82eef1 package update 2026-01-18 12:36:20 +00:00
Proddy
efdb355033 Merge pull request #2923 from proddy/dev
v3.8.2
2026-01-12 12:07:02 +01:00
proddy
87c9fd010f v3.8.2 2026-01-11 19:49:01 +01:00
Proddy
930f0e615a Merge pull request #2921 from MichaelDvP/dev
set gpios when switching to custom board
2026-01-11 19:00:24 +01:00
MichaelDvP
4dbbdb3290 dangling Divider 2026-01-11 16:27:49 +01:00
MichaelDvP
8379b3f5aa Merge branch 'dev' of https://github.com/emsesp/EMS-ESP32 into dev 2026-01-11 16:26:00 +01:00
MichaelDvP
08124fa4db set gpio when switching to custom board 2026-01-11 16:25:42 +01:00
Proddy
39414db732 Merge pull request #2920 from proddy/dev
add SECURITY
2026-01-11 16:13:29 +01:00
proddy
da57a08005 add 2026-01-11 16:12:28 +01:00
Proddy
b6b8700c3f Merge pull request #2919 from proddy/dev
rollback ApplicationSettings for CUSTOM
2026-01-11 14:23:07 +01:00
proddy
8ee0789dad emsesp.org ownership 2026-01-11 14:17:49 +01:00
proddy
1eabe86015 package update 2026-01-11 13:17:15 +01:00
proddy
5fe4b315f3 make reference to emsesp docs 2026-01-11 13:13:40 +01:00
proddy
03ec96bf96 rollback 2026-01-11 13:13:27 +01:00
Proddy
aa05e37fbb Merge pull request #2916 from proddy/dev
webUI changes and comments
2026-01-10 22:21:48 +01:00
Proddy
959a00c19a Merge branch 'emsesp:dev' into dev 2026-01-10 22:10:23 +01:00
Proddy
b42060be3a Merge pull request #2917 from MichaelDvP/dev 2026-01-10 20:23:37 +01:00
MichaelDvP
33bb433d7e always set valid gpio in load board profile 2026-01-10 19:47:03 +01:00
proddy
28a5d4ef1a fix comment 2026-01-10 19:00:15 +01:00
proddy
b78d47cbd0 minor ui change to how board profile is shown 2026-01-10 19:00:15 +01:00
proddy
8a7a1383a7 added comment reference to HA 2026-01-10 19:00:15 +01:00
Proddy
3f5163c1e4 Merge branch 'emsesp:dev' into dev 2026-01-10 18:39:48 +01:00
Proddy
fad82c8c68 Merge pull request #2915 from MichaelDvP/dev
fix board change ignore old gpio settings
2026-01-10 18:39:17 +01:00
MichaelDvP
6fc3bf30b6 HA uom L, formatting 2026-01-10 18:19:14 +01:00
proddy
43b3e74c08 input number format 2026-01-10 18:09:38 +01:00
proddy
64c9882d8c add children to avoid linting errors 2026-01-10 18:09:17 +01:00
proddy
a690510903 update 2026-01-10 18:09:02 +01:00
proddy
c732ec301a update 2026-01-10 18:08:54 +01:00
proddy
cc1f16596a add ha_number_node 2026-01-10 18:08:44 +01:00
proddy
66c74f85a4 formatting 2026-01-10 18:08:17 +01:00
proddy
db667b9437 package update 2026-01-10 18:08:06 +01:00
MichaelDvP
8799015f59 fix board change ignore old gpio settings 2026-01-10 16:06:43 +01:00
Proddy
d71b3c64e1 Merge pull request #2914 from proddy/dev
remove build section, refer to docs
2026-01-10 09:43:15 +01:00
proddy
6d3083fff4 remove build section, refer to docs 2026-01-10 09:39:23 +01:00
Proddy
56b2c111b8 Merge pull request #2912 from MichaelDvP/dev
update testdata (no `.0`-decimales), fix c3 compile issue
2026-01-09 21:25:29 +01:00
MichaelDvP
12ce736580 remove eth fixed pins 2026-01-09 21:03:23 +01:00
MichaelDvP
debe90eb8d formatting, update gpios for C3, S2 2026-01-09 18:57:25 +01:00
Proddy
8d318143a4 Merge pull request #2913 from proddy/dev
fix ha climate mode to use bool format
2026-01-09 18:48:45 +01:00
proddy
59f90f5d1c comment change 2026-01-09 18:47:34 +01:00
proddy
0efbd0528e fix ha mode, boolean to match setting bool format 2026-01-09 18:47:14 +01:00
proddy
3218620a0e update dictionary 2026-01-09 18:46:00 +01:00
proddy
a93921c875 adding missing board to settings 2026-01-09 18:45:50 +01:00
MichaelDvP
fb77b455be testdata without decimals 2026-01-09 17:21:44 +01:00
MichaelDvP
b64c392c58 rename uom::HZ to HERTZ, avoids compile error on ESP32C3 2026-01-09 17:21:26 +01:00
Proddy
7bc6cf3910 Merge pull request #2911 from MichaelDvP/dev
day schedule defaults to all days, allow gpio 1,3 for custom #2908
2026-01-09 17:00:53 +01:00
MichaelDvP
e19e76546e update changelog, dev7 2026-01-09 16:45:18 +01:00
MichaelDvP
b9aaaae90a allow uart0 pins for custom boards 2026-01-09 16:30:07 +01:00
MichaelDvP
86b395d612 don't allow day schedule without selecting a day, default to daily 2026-01-09 16:29:12 +01:00
Proddy
6204b9acc7 Merge pull request #2909 from MichaelDvP/dev
HA number mode selection #2900, board specific gpio settings
2026-01-09 12:48:52 +01:00
MichaelDvP
0777f420b0 Merge branch 'dev' of https://github.com/emsesp/EMS-ESP32 into dev 2026-01-09 11:52:42 +01:00
MichaelDvP
4a6a662aa0 known board gpio settings, custom with less restriction 2026-01-09 11:52:18 +01:00
Proddy
66837d399f Merge pull request #2907 from MichaelDvP/dev
add gpios for system sensors on first start
2026-01-09 09:43:46 +01:00
MichaelDvP
34ff5f12ea mqtt decimals: remove trailing zeros 2026-01-08 21:18:16 +01:00
MichaelDvP
82d160cabb merge mqtt ha number mode 2026-01-08 21:17:14 +01:00
MichaelDvP
2c85d3829b Merge branch 'dev' of https://github.com/emsesp/EMS-ESP32 into dev 2026-01-08 21:05:59 +01:00
MichaelDvP
cc258dae16 add gpios for system sensors on first start 2026-01-08 19:15:48 +01:00
Proddy
577964befe Merge pull request #2904 from MichaelDvP/dev
GPIOs depends on board profile, #2901
2026-01-08 17:42:34 +01:00
MichaelDvP
f2500013ab test fix 2026-01-08 16:55:57 +01:00
MichaelDvP
98fb6941d2 add E32V2_2 single led as system sensor 2026-01-08 16:32:22 +01:00
MichaelDvP
7308b8e73d fix another test 2026-01-08 16:31:36 +01:00
Proddy
e97cfaf9ee Merge pull request #2905 from proddy/dev
min/max fixes
2026-01-08 14:50:13 +01:00
MichaelDvP
fd0734d8d8 custom board option only in developer mode 2026-01-08 14:43:35 +01:00
MichaelDvP
739f32f045 update pkg 2026-01-08 13:10:56 +01:00
MichaelDvP
b66b49e812 remove blank line 2026-01-08 12:25:23 +01:00
MichaelDvP
d4b81a2909 Merge branch 'dev' of https://github.com/proddy/EMS-ESP32 into dev2 2026-01-08 12:24:46 +01:00
MichaelDvP
fb57537e88 fix standalone 2026-01-08 12:19:18 +01:00
MichaelDvP
335b1274cf test version 2026-01-08 12:08:14 +01:00
MichaelDvP
f6d1c87eaf skip trailing zeros 2026-01-08 12:07:00 +01:00
MichaelDvP
5e07e9a11b add ha number mode selection #2900 2026-01-08 12:06:48 +01:00
MichaelDvP
dd0ea5df0e fix test gpios for S32 gateway 2026-01-08 11:16:32 +01:00
proddy
2b7f592957 use max version for dummy data, don't show min/max range for non writeable entries. fixes entity_dump.xls 2026-01-07 22:02:19 +01:00
proddy
db7b5df85d auto-gen 2026-01-07 22:01:13 +01:00
proddy
1c2534ed8f package update 2026-01-07 22:01:06 +01:00
MichaelDvP
90535d7b94 changelog, dev5 2026-01-07 11:43:02 +01:00
MichaelDvP
0d3a8fc719 Merge branch 'dev' of https://github.com/emsesp/EMS-ESP32 into dev 2026-01-07 10:30:40 +01:00
MichaelDvP
d624b9eac9 GPIOs depends on board profile, #2901 2026-01-07 10:29:59 +01:00
Proddy
068cbf757c Merge pull request #2903 from MichaelDvP/dev
HP entities #2883, Mqtt queue to psram #2889
2026-01-07 09:54:11 +01:00
MichaelDvP
738d6f0b0f chore: update generated files for v3.8.1-dev.4 2026-01-07 08:17:16 +00:00
MichaelDvP
d9551bc4c3 Merge branch 'dev' of https://github.com/emsesp/EMS-ESP32 into dev 2026-01-07 08:38:01 +01:00
Proddy
722659325a Merge pull request #2884 from proddy/dev
some small fixes and changes
2026-01-06 22:19:40 +01:00
proddy
927e7c80f4 update 2026-01-06 22:18:35 +01:00
proddy
39919b4ad8 update 2026-01-06 22:17:54 +01:00
proddy
b5defa552e improve chunking and fix circular refs 2026-01-06 22:15:22 +01:00
proddy
e8fbbe5a1c skip blured paper component when in systemMonitor 2026-01-06 22:04:54 +01:00
proddy
949172128f update packages 2026-01-06 22:04:26 +01:00
proddy
35a8db4581 fix missing progress bar on WebUI upload 2026-01-06 22:04:15 +01:00
proddy
3a74abb4db fix blue dots appearing when transitioning 2026-01-06 22:03:53 +01:00
proddy
bcc7687b1b pins 22-25 don't exist on S2+S3 2026-01-06 21:01:26 +01:00
MichaelDvP
13aa544214 mqtt queue: prefere PSram 2026-01-06 18:50:50 +01:00
proddy
696141721a updated with BBQKees pins 2026-01-06 11:57:21 +01:00
proddy
4781eea665 gpios 2026-01-06 10:56:44 +01:00
proddy
2fd6bed485 update test 2026-01-05 21:37:40 +01:00
proddy
db40d1d381 set default values, just to be sure 2026-01-05 21:36:21 +01:00
proddy
33bde8b407 fix default version just to be sure 2026-01-05 21:36:10 +01:00
proddy
bf5990a992 fix setting default version 2026-01-05 21:35:39 +01:00
proddy
cff4bd0a71 add debug statement 2026-01-05 21:35:18 +01:00
proddy
28ee0834d8 don't log debug messages if nothing connected 2026-01-05 21:35:03 +01:00
proddy
9be1cb1d3e always set fresh flag 2026-01-05 21:34:50 +01:00
proddy
81d46fede2 check for USB uploads and set 'fresh' flag 2026-01-05 21:34:32 +01:00
proddy
664a8e9f5f remove defaults 2026-01-05 21:26:58 +01:00
proddy
def7501c62 dictionary update 2026-01-05 20:12:21 +01:00
proddy
1cc4dc52d4 package update 2026-01-05 20:12:12 +01:00
MichaelDvP
978c738f27 add hp entities, +2883 2026-01-05 17:32:49 +01:00
proddy
feeb8500ac #2896 2026-01-05 13:34:10 +01:00
proddy
18a55d4622 more gpio pin updates 2026-01-05 13:31:27 +01:00
Proddy
29ea67f438 Merge branch 'emsesp:dev' into dev 2026-01-05 10:10:27 +01:00
Proddy
a3df77171b Merge pull request #2898 from g6094199/patch-1
Update mqtt.cpp: use the same nomenclature as Tasmota and OpenBK uses…
2026-01-05 10:10:14 +01:00
g6094199
aa6f5c50b2 Update mqtt.cpp: use the same nomenclature as Tasmota and OpenBK uses for Homeassistant
to be compliant to the HA typical nomenclatura use the same wording.

since we are anyway connecting via wifi or ethernet the wording of "WIFI rssi" is redundant. just use "RSSI" as other platforms do.
2026-01-05 09:03:12 +01:00
proddy
53a43ca147 update gpio to not conflict with board profile 2026-01-04 21:56:37 +01:00
proddy
da76fe3871 asyncTCP change 2026-01-04 21:51:57 +01:00
proddy
84af132e2c fix mix-up of GPIOs 2026-01-04 21:33:30 +01:00
proddy
712a8537c9 remove unused struct 2026-01-04 21:32:32 +01:00
proddy
bb22386f7f updated 2026-01-04 21:32:15 +01:00
proddy
89dfe11ee3 fix tests 2026-01-04 12:45:54 +01:00
proddy
be1e08af9c update 2026-01-04 12:21:17 +01:00
proddy
68ebcdded4 gpio exclusion, add name 2026-01-04 11:44:28 +01:00
Proddy
4afe041880 Merge branch 'emsesp:dev' into dev 2026-01-04 11:41:07 +01:00
Proddy
8b690d23da Merge pull request #2892 from MichaelDvP/dev
Junkers wwcharge offset #2860, fix minflowtemp #2890
2026-01-04 11:39:32 +01:00
MichaelDvP
62c7fb671b Junkers wwcharge offset #2860, fix minflowtemp #2890 2026-01-04 11:05:38 +01:00
proddy
5a82064a88 add name to gpios 2026-01-04 00:08:17 +01:00
proddy
4ae4000944 asyncTCP fix 2026-01-03 22:48:17 +01:00
proddy
616c73f658 first test to exclude gpios 2026-01-03 22:16:33 +01:00
Proddy
b698485814 Merge branch 'emsesp:dev' into dev 2026-01-03 19:00:11 +01:00
Proddy
b4036bf8cd Merge pull request #2888 from MichaelDvP/dev
don't add HA uom/classes for bool values, fix #2885
2026-01-03 18:59:44 +01:00
MichaelDvP
4b457d6cdb don't add HA uom/classes for bool values, fix #2885 2026-01-03 18:24:54 +01:00
proddy
425b44e334 init settings 2026-01-03 14:13:54 +01:00
proddy
41bf293db3 AsyncTCP change 2026-01-03 13:58:25 +01:00
proddy
af349edd54 text changes 2026-01-03 13:58:10 +01:00
proddy
5b303bd58a package update 2026-01-03 13:58:00 +01:00
proddy
b992f90fe2 AsyncTCP changes 2026-01-03 13:56:27 +01:00
proddy
92c34dddba add comment 2026-01-03 13:56:11 +01:00
proddy
1475fc094d ESPAsyncWebServer @ 3.9.4 2026-01-03 13:55:57 +01:00
Proddy
48f7b48216 Merge pull request #2882 from MichaelDvP/dev 2026-01-02 16:54:15 +01:00
MichaelDvP
cd054b293a dev3 2026-01-02 16:13:52 +01:00
MichaelDvP
ea6b7c0be0 revert commit 1a03b98, used fixed buffer length 2026-01-02 16:13:14 +01:00
MichaelDvP
a49a5537d3 fix minflowtemp #2879 2026-01-02 16:04:02 +01:00
MichaelDvP
c407ad04bf Merge branch 'dev' of https://github.com/emsesp/EMS-ESP32 into dev 2026-01-01 18:58:45 +01:00
MichaelDvP
480e0951b8 update asyncTCP 2026-01-01 18:58:20 +01:00
Proddy
a06c5bb297 Merge pull request #2878 from MichaelDvP/dev
fix selflowtemp #2876 and other
2026-01-01 17:04:00 +01:00
Proddy
77a54792dd Merge pull request #2877 from proddy/dev
fix modbus md doc
2026-01-01 13:17:36 +01:00
proddy
d0d49397ca fix modbus table for emsesp.org doc 2026-01-01 13:00:18 +01:00
proddy
b51abeabac auto-gen 2026-01-01 13:00:06 +01:00
MichaelDvP
f0e4f17ab8 safe update time in nvs 2026-01-01 11:50:14 +01:00
MichaelDvP
205da33fe5 snapshot gpios in temporarly ram 2026-01-01 11:49:38 +01:00
MichaelDvP
9aa78111be fix selflowtemp #2876 2026-01-01 10:48:21 +01:00
proddy
c3f93d4aae fix for demo building 2026-01-01 10:47:54 +01:00
Proddy
5a5c0d7179 Merge pull request #2875 from proddy/dev
fixes #1953 #2874
2026-01-01 10:10:10 +01:00
proddy
ba57942b7d txpause in system info (#1953), added Wemos S3 pins (#2874) 2026-01-01 10:08:57 +01:00
proddy
c782deb581 package udpate 2026-01-01 10:07:11 +01:00
Proddy
566edfcd7b Merge pull request #2872 from proddy/dev
3.8.1-dev-0
2025-12-31 22:01:08 +01:00
proddy
c3cf38c330 3.8.1-dev-0 2025-12-31 21:54:36 +01:00
Proddy
4953a41330 Merge pull request #2871 from proddy/dev
overcome strange chars in header files
2025-12-31 17:05:04 +01:00
proddy
7ff4ed640d overcome strange chars in header files 2025-12-31 16:53:32 +01:00
Proddy
898a13fcb5 Merge pull request #2870 from proddy/dev
minor fixes
2025-12-31 15:31:16 +01:00
proddy
3fdc370466 clean up check_upgrade, remove OTA onProgress setting status each time 2025-12-31 15:25:05 +01:00
proddy
39055ad0d2 show firmware size in KB 2025-12-31 15:23:38 +01:00
Proddy
21e73c973a Merge pull request #2869 from MichaelDvP/dev
fix minflowtemp #2781
2025-12-31 12:13:44 +01:00
MichaelDvP
4d03976032 Merge branch 'dev' of https://github.com/emsesp/EMS-ESP32 into dev 2025-12-31 11:45:59 +01:00
MichaelDvP
eb7587270f fix minflowtemp #2781 2025-12-31 11:45:29 +01:00
Proddy
3ea88a2be0 Merge pull request #2867 from proddy/dev
last minute 3.8 cleanup
2025-12-31 09:25:51 +01:00
proddy
55778ba0b5 update test data to 3.8 2025-12-31 09:22:53 +01:00
proddy
5d3694abd3 update package manager 2025-12-31 09:22:43 +01:00
proddy
21d9ba0182 update test data to 3.8 2025-12-31 09:22:28 +01:00
Proddy
27848feddd Merge pull request #2865 from proddy/dev
force 3.8.0-dev.1
2025-12-30 10:28:37 +01:00
proddy
ce261eeb65 Merge branch 'dev' of https://github.com/proddy/EMS-ESP32 into dev 2025-12-30 10:28:10 +01:00
proddy
8de3ae5468 package update 2025-12-30 10:28:09 +01:00
proddy
367d27d48f 3.8.0-dev.1 2025-12-30 10:28:03 +01:00
Proddy
1a9b6ab2a5 Merge pull request #2864 from proddy/dev
fixes #2862
2025-12-30 10:26:44 +01:00
Proddy
323b8ee67d Merge branch 'emsesp:dev' into dev 2025-12-30 10:23:51 +01:00
Proddy
5b95e1d41f Merge pull request #2863 from MichaelDvP/dev
fixes #2844, #2856
2025-12-30 10:23:32 +01:00
MichaelDvP
a272d8e253 chore: update generated files for v3.8.0-dev.0 2025-12-30 09:07:17 +00:00
MichaelDvP
79285ca12e remove wrong 4wayValve, #2844 2025-12-30 09:55:42 +01:00
proddy
f90f676faf fixes #2862 2025-12-29 21:30:33 +01:00
MichaelDvP
3224d8823d fix HA config topic to basename 2025-12-29 18:35:34 +01:00
MichaelDvP
1a03b98670 avoid variabe length array 2025-12-29 18:35:06 +01:00
Proddy
80f32bfeb4 Merge pull request #2859 from proddy/dev
fix modbus md, so its compatible with docusaurus markup
2025-12-29 16:35:28 +01:00
proddy
1b4693b981 fix show md compatible with docusaurus markup 2025-12-29 16:34:01 +01:00
proddy
535b760dd7 auto-generated 2025-12-29 16:33:44 +01:00
proddy
14775f6503 added comments 2025-12-29 16:33:36 +01:00
proddy
a856d249c9 update screenshots 2025-12-29 16:31:37 +01:00
proddy
484b547df5 fix typo 2025-12-29 12:31:42 +01:00
Proddy
d73ca2c890 Merge pull request #2858 from proddy/dev
rename docs.emsesp.org to emsesp.org
2025-12-29 10:45:26 +01:00
Proddy
6211bd8c69 Merge branch 'emsesp:dev' into dev 2025-12-29 10:44:46 +01:00
proddy
e638a471d1 rename docs.emsesp.org to emsesp.org 2025-12-29 10:44:32 +01:00
Proddy
42ee21e883 Merge pull request #2840 from proddy/dev
a selection of minor fixes and some new features
2025-12-29 10:39:13 +01:00
proddy
263af58dc0 package update 2025-12-29 10:37:55 +01:00
proddy
6727c0655a update dictionary 2025-12-28 11:20:46 +01:00
proddy
cba249938a add missing translations (thanks to AI) 2025-12-27 17:35:58 +01:00
proddy
9fbed47617 txenabled to txpause 2025-12-27 17:34:19 +01:00
proddy
b5dd722888 rename txenabled to txpause 2025-12-27 17:34:04 +01:00
proddy
11bef52568 fix HA mapping for uA and l/min 2025-12-27 17:33:46 +01:00
proddy
d22a369333 add tests for txmode 2025-12-27 11:20:38 +01:00
proddy
dfe95296d9 fix txmode on/off logic 2025-12-27 11:20:30 +01:00
proddy
8d39893e5e use Yellow RGB for flash and button 2025-12-27 10:58:08 +01:00
proddy
b8b8a501e1 package update 2025-12-27 10:24:43 +01:00
proddy
2a36f378b4 auto-formatting 2025-12-27 10:24:35 +01:00
proddy
6746df37a1 updated logic on fresh firmware install 2025-12-26 17:10:26 +01:00
proddy
364f66b7d4 updated comment 2025-12-26 16:55:05 +01:00
proddy
84bbd93216 fix standalone 2025-12-26 16:24:46 +01:00
proddy
85ef8d7d50 text changes 2025-12-26 15:28:03 +01:00
proddy
2b6606d8ad disable uart when uploading, show when uploading, store flag showing its a new firmware 2025-12-26 15:25:41 +01:00
proddy
4772a61e7c use EMS_TXMODE_INIT 2025-12-26 15:24:56 +01:00
proddy
d30375f3c4 set_partition_install_date - when NVS connects 2025-12-26 13:34:02 +01:00
proddy
8c831ac0e9 only update partition info on boot 2025-12-26 13:33:38 +01:00
proddy
1ede7028a5 add back missing webLogService.loop() 2025-12-26 12:06:31 +01:00
proddy
5b8dd0a693 msgpack update 2025-12-26 11:56:48 +01:00
proddy
cc041510be fixes install time in NVS 2025-12-26 10:18:58 +01:00
proddy
05baec85b7 implement txenabled system command - #2850 2025-12-26 10:10:27 +01:00
proddy
fb698fd029 if LED flashing skip other chores 2025-12-26 09:34:25 +01:00
proddy
bbfec136e8 use EMS_TXMODE_OFF 2025-12-26 09:33:59 +01:00
proddy
36271a2c24 add comments 2025-12-26 09:33:50 +01:00
proddy
bbe1f133dc add EMS_TXMODE_INIT and EMS_TXMODE_NONE 2025-12-26 09:33:17 +01:00
proddy
d7b0614556 add comment 2025-12-26 09:32:57 +01:00
proddy
7e9f27a613 tidy up comments 2025-12-26 09:32:46 +01:00
proddy
3bdc97d4d5 rollback changes to get_metrics_prometheus() 2025-12-24 17:56:07 +01:00
proddy
8a7511a941 default no sleep is true in standalone 2025-12-24 17:55:27 +01:00
proddy
a9511e6a29 implements #2848 2025-12-24 16:59:18 +01:00
Proddy
ac37ead419 Merge branch 'emsesp:dev' into dev 2025-12-24 13:25:26 +01:00
proddy
39fcda59da update 3.7.3->3.8.0 2025-12-24 13:24:48 +01:00
Proddy
9c44e104bb Merge pull request #2847 from VlastiBroucek/dev
Added some missing Czech translations
2025-12-24 13:18:34 +01:00
proddy
18f8db7942 text changes 2025-12-24 13:06:28 +01:00
proddy
71281bc82d rename to stored version, use same infoDialog 2025-12-24 12:54:08 +01:00
proddy
0557def0b6 add install date to firmware versions 2025-12-24 11:18:22 +01:00
proddy
1a3f7fbbee don't run pio as default 2025-12-24 11:17:55 +01:00
Vlasti Broucek
7bed8bf84e Added some missing Czech translations 2025-12-24 12:22:11 +11:00
proddy
790371a9e2 minor optimizations 2025-12-23 23:28:10 +01:00
proddy
537cf19e97 only show previous if in developer mode and there are something to show 2025-12-23 23:28:02 +01:00
proddy
35ad43b7b3 package update 2025-12-23 23:27:22 +01:00
proddy
3d70e8c1e6 clear_snapshot_gpios 2025-12-23 19:32:25 +01:00
proddy
7bb30d37ce rollback to other partitions - #2837 2025-12-23 18:08:50 +01:00
proddy
0267f00b48 package update 2025-12-23 18:08:05 +01:00
Proddy
5a7cec91c5 Merge branch 'emsesp:dev' into dev 2025-12-23 08:25:22 +01:00
Proddy
642bf63abc Merge pull request #2842 from misa1515/patch-27
Update locale_translations.h
2025-12-23 08:25:11 +01:00
misa1515
6f3197f482 Update locale_translations.h 2025-12-22 23:03:16 +01:00
proddy
5c88968879 reset data and remove Apply button when error 2025-12-22 17:43:11 +01:00
proddy
c4a43183b3 GPIOs not checked when board profile is adjusted
#2841
2025-12-22 17:18:37 +01:00
Proddy
94b583d7f3 Merge branch 'emsesp:dev' into dev 2025-12-22 15:46:23 +01:00
Proddy
37012e55e3 Merge pull request #2839 from MichaelDvP/dev
add back uom and factor inputs for some analogsensor types
2025-12-22 15:45:40 +01:00
proddy
ea4d613d12 updates to add_ha_classes 2025-12-22 15:41:14 +01:00
proddy
0e6108b5a9 3.7.3-dev.40 2025-12-22 15:40:27 +01:00
MichaelDvP
fdbaf7509f add back uom and factor inputs for some analogsensor types 2025-12-22 15:13:54 +01:00
Proddy
74182031ae Merge pull request #2838 from proddy/dev
formatting and EMS bus logging
2025-12-22 11:28:59 +01:00
proddy
6c80a34578 don't output Rx errors in log if no bus connected 2025-12-22 09:07:04 +01:00
proddy
09f1c13d28 formatting 2025-12-22 09:06:27 +01:00
proddy
5668fe13ae package update 2025-12-22 08:57:29 +01:00
proddy
78a02b6d85 run unit tests 2025-12-22 08:57:22 +01:00
proddy
ccab932e8d dont long rx garbage if ems bus not connected 2025-12-21 22:30:03 +01:00
proddy
eaea1f383b formatting 2025-12-21 22:29:28 +01:00
Proddy
de8309de4a Merge pull request #2836 from MichaelDvP/dev
force publish single on connect
2025-12-21 14:45:49 +01:00
MichaelDvP
bc3269037f chore: update generated files for v3.7.3-dev.39 2025-12-21 11:05:49 +00:00
MichaelDvP
31131427b8 fix RC120RF check 2025-12-21 11:53:39 +01:00
MichaelDvP
9957bff62b check Mqtt::enabled 2025-12-21 11:17:31 +01:00
MichaelDvP
f1841347a7 set lastresponse also if not connected 2025-12-19 18:50:51 +01:00
MichaelDvP
1b8b72c443 publish mqtt emsesp on-change messages on connect 2025-12-19 17:14:50 +01:00
MichaelDvP
b4affbff6d Merge branch 'dev' of https://github.com/emsesp/EMS-ESP32 into dev 2025-12-19 16:53:36 +01:00
Proddy
c8b4a38bb6 Merge pull request #2829 from proddy/dev
HA fixes part 3
2025-12-19 16:36:17 +01:00
proddy
eec373e2ae publish ha only if ha is enabled 2025-12-19 16:35:55 +01:00
proddy
48b261317d 3.7.3-dev.39 2025-12-19 16:35:40 +01:00
proddy
e6beb01075 package update 2025-12-19 16:35:29 +01:00
proddy
ecc6e9286a tidy up add_ha_dev_section 2025-12-19 08:55:33 +01:00
proddy
179351cb6b add back force, fix for non-HA 2025-12-19 08:54:59 +01:00
proddy
778fe43012 package update 2025-12-19 08:54:30 +01:00
Proddy
d84d52df4b Merge pull request #2827 from proddy/dev
fix HA custom entities
2025-12-18 21:53:22 +01:00
proddy
11d4109915 package update 2025-12-18 21:40:53 +01:00
proddy
0eddbac150 remove force in HA MQTT 2025-12-18 21:40:41 +01:00
proddy
99afeb221a changes to HA 2025-12-18 21:40:08 +01:00
proddy
39d18b78a1 formatting 2025-12-18 21:39:43 +01:00
MichaelDvP
4ebe8cc0cc dallas dev_cla 2025-12-18 17:17:00 +01:00
MichaelDvP
6dabfb7fe2 analogsensor: add_ha_classes 2025-12-18 13:31:10 +01:00
MichaelDvP
611b1d9aca add RC120RF as remote 2025-12-18 13:30:41 +01:00
Proddy
2821f8e750 Merge pull request #2824 from MichaelDvP/dev
some more fixes
2025-12-18 11:23:37 +01:00
MichaelDvP
1cccd8dc2c Merge branch 'dev' of https://github.com/emsesp/EMS-ESP32 into dev 2025-12-18 09:18:03 +01:00
Proddy
4d13982594 Merge pull request #2823 from proddy/dev
fix HA sensors not been added to HA device
2025-12-18 08:43:57 +01:00
MichaelDvP
10c63640c0 Merge branch 'dev' of https://github.com/proddy/EMS-ESP32 into dev 2025-12-18 07:20:50 +01:00
proddy
14ad1239db don't need to add name to ids each time for HA 2025-12-18 06:45:48 +01:00
proddy
2a16cb6e64 3.7.3-dev.38 2025-12-18 06:45:25 +01:00
proddy
89fa5947bd fix standalone 2025-12-17 22:10:17 +01:00
proddy
a81e56e3bf package update 2025-12-17 22:10:05 +01:00
proddy
90ad2dde54 minor optimization 2025-12-17 22:09:55 +01:00
proddy
9c243cbe8d sort types 2025-12-17 22:09:32 +01:00
proddy
c1b444541f fix HA 2025-12-17 22:09:17 +01:00
MichaelDvP
8527b16e9d dev 38 2025-12-17 18:14:09 +01:00
MichaelDvP
a728420010 small fix 2025-12-17 18:13:35 +01:00
MichaelDvP
378d9e8634 Merge branch 'dev' of https://github.com/proddy/EMS-ESP32 into dev 2025-12-17 18:11:53 +01:00
MichaelDvP
1973081529 ha_dev_section for custom and scheduler 2025-12-17 18:11:43 +01:00
MichaelDvP
45ae6d802c use (const char *) for PSRAM-stored sensor.name in ArduinoJson 2025-12-17 16:42:38 +01:00
MichaelDvP
c4297e2996 Merge branch 'dev' of https://github.com/emsesp/EMS-ESP32 into dev 2025-12-17 12:34:54 +01:00
proddy
310afe3ab8 fix HA sensor referencing 2025-12-17 11:37:46 +01:00
MichaelDvP
3215f36530 fix typo in analogsensor:remove_ha_topic 2025-12-17 11:36:50 +01:00
proddy
55621e12d9 package update - fixes vite-tsconfig-paths 2025-12-17 11:26:57 +01:00
Proddy
458dc516f4 Merge pull request #2822 from proddy/dev
fix HA shower sensor
2025-12-16 23:15:53 +01:00
Proddy
f2ae84bd22 Merge branch 'emsesp:dev' into dev 2025-12-16 23:15:20 +01:00
proddy
f093df1cb9 fix build for windows 2025-12-16 23:14:48 +01:00
proddy
05f15f7876 package update 2025-12-16 23:14:36 +01:00
proddy
12f4a74094 fix mqtt base for shower 2025-12-16 23:14:02 +01:00
Proddy
15d895fd0a Merge pull request #2821 from MichaelDvP/dev
mqtt publish on change: wait for queue empty
2025-12-16 22:35:30 +01:00
MichaelDvP
1890948924 mqtt-queue check also for publish-on-change analog/temperature 2025-12-16 11:36:58 +01:00
MichaelDvP
fec246127f Merge branch 'dev' of https://github.com/emsesp/EMS-ESP32 into dev 2025-12-16 08:27:45 +01:00
MichaelDvP
ecab30d4ac do not flood mqtt queue if publish on change is set 2025-12-16 08:27:21 +01:00
Proddy
f8cc688241 Merge pull request #2820 from proddy/dev
fix HA modes
2025-12-15 21:13:11 +01:00
proddy
f51b3528d5 ESPAsyncWebServer @ 3.9.3 2025-12-15 21:12:20 +01:00
proddy
42c94a1017 formatting 2025-12-15 21:12:09 +01:00
proddy
acccb56f07 add comment 2025-12-15 20:47:07 +01:00
proddy
3a2d3ac985 fix mixed up logic with HA modes 2025-12-15 19:34:48 +01:00
proddy
0ab18e6e08 package update 2025-12-15 19:34:36 +01:00
proddy
44db5991e7 package update 2025-12-15 19:34:22 +01:00
Proddy
15a6c50326 Merge pull request #2819 from MichaelDvP/dev
dev37, fix mqtt booleans, add SRC climate icon
2025-12-15 19:29:21 +01:00
MichaelDvP
911aa40ca1 fix mqtt bool output for analog, scheduler 2025-12-15 16:48:33 +01:00
MichaelDvP
2b679daabc dev 37, add SRC thermostat icons to climate 2025-12-15 11:56:54 +01:00
MichaelDvP
71b956e613 fix analog mqtt 2025-12-15 10:58:32 +01:00
Proddy
79089a93bc Merge pull request #2817 from proddy/dev
SRC for HA climate, "show gpio" command
2025-12-14 21:11:54 +01:00
proddy
da3ac1794e add telnet command show gpio 2025-12-14 21:10:45 +01:00
proddy
bc870b2aa2 add HA climate control with cool for SRC thermostat controls 2025-12-14 21:10:29 +01:00
Proddy
6bc40ce2e1 Merge pull request #2815 from proddy/dev
package update
2025-12-14 12:44:48 +01:00
Proddy
84544979fa Merge branch 'emsesp:dev' into dev 2025-12-14 12:44:23 +01:00
proddy
2446e4d1fd package update 2025-12-14 12:44:04 +01:00
Proddy
4a15b39945 Merge pull request #2814 from MichaelDvP/dev
remove unused parameter
2025-12-14 12:12:19 +01:00
MichaelDvP
df080bbad9 Merge branch 'dev' of https://github.com/emsesp/EMS-ESP32 into dev 2025-12-14 11:32:46 +01:00
Proddy
8632af8820 Merge pull request #2813 from proddy/dev
fix HA modes (#2812)
2025-12-14 11:07:58 +01:00
Proddy
27047c0f39 Merge branch 'emsesp:dev' into dev 2025-12-14 11:07:33 +01:00
proddy
95de3e339d fix HA modes 2025-12-14 11:07:16 +01:00
MichaelDvP
99f44aece5 remove unused parameter 2025-12-14 09:23:28 +01:00
MichaelDvP
4e589aecbf Merge branch 'dev' of https://github.com/emsesp/EMS-ESP32 into dev 2025-12-14 09:20:58 +01:00
Proddy
ab013554bd Merge pull request #2805 from gr3enk/dev
Fix system/metrics endpoint duplicate labels & add enum support for metrics
2025-12-13 19:28:56 +01:00
Jakob
5c966e291b chore: avoid dangling references while processing json objects recursively 2025-12-13 15:50:04 +01:00
MichaelDvP
068744b681 config check 2025-12-13 15:01:14 +01:00
Jakob
8c794baa7b Merge branch 'dev' into dev 2025-12-13 14:34:05 +01:00
Proddy
f6a23723f8 Merge pull request #2811 from proddy/dev
minor HA improvements
2025-12-13 11:38:06 +01:00
proddy
4b3cc2e3ec update 2025-12-13 11:30:43 +01:00
proddy
4ee743d309 fix standalone 2025-12-13 11:29:01 +01:00
proddy
d3b4bab40a dynamically add HA thermostat modes based on model 2025-12-13 11:22:22 +01:00
proddy
c71b54fb5b auto-formatting 2025-12-13 11:21:57 +01:00
proddy
ecbdf01bdf add HA dev section linking all devices to ems-esp 2025-12-13 11:21:45 +01:00
proddy
c2e8a6c73d added get_ip_or_hostname() 2025-12-13 11:20:48 +01:00
proddy
29037d19fb auto-formatting 2025-12-13 11:20:24 +01:00
proddy
67d242d210 package update 2025-12-13 11:19:52 +01:00
proddy
91a67def99 minor fix 2025-12-13 11:19:45 +01:00
MichaelDvP
c8ab89ef37 fix pl translation 2025-12-13 10:52:58 +01:00
proddy
17a9b7eb0a send HA modes based on actual thermostat modes 2025-12-12 21:58:35 +01:00
proddy
cdb592d744 package update 2025-12-12 21:58:15 +01:00
proddy
af19941a07 https://github.com/emsesp/EMS-ESP32/discussions/2761 2025-12-12 21:58:06 +01:00
Jakob
00dce83096 Merge branch 'dev' of https://github.com/gr3enk/EMS-ESP32 into dev 2025-12-12 17:57:13 +01:00
Jakob
597e60d6f1 chore: filter out entities with no values 2025-12-12 17:53:50 +01:00
Jakob
4e4311cac0 Merge branch 'dev' into dev 2025-12-12 14:42:59 +01:00
Jakob
c11402195f chore: reserve string capacity for prometheus metrics 2025-12-12 14:08:47 +01:00
Proddy
31ec15811f Merge pull request #2806 from MichaelDvP/dev
small fixes,update changelog
2025-12-12 11:53:04 +01:00
MichaelDvP
739ac7b42e update changelog 2025-12-12 11:02:47 +01:00
MichaelDvP
ad3049ab0a smal fixes 2025-12-12 10:47:06 +01:00
MichaelDvP
d96c3f3ed7 removelast topic/payload 2025-12-12 10:46:26 +01:00
Jakob
dcfd0d5b11 test: add unit tests for metrics enum outputs 2025-12-12 10:06:02 +01:00
Jakob
b05712cf83 fix: check for duplicate labels 2025-12-12 10:04:51 +01:00
Jakob
df15485d7c feat: add enum support for metrics endpoint 2025-12-12 10:03:52 +01:00
Proddy
bb15fcdc46 Merge pull request #2804 from proddy/dev
various minor fixes to dev-35
2025-12-12 00:03:08 +01:00
proddy
e34291fbd5 replace VLAs with standard C++ 2025-12-12 00:00:49 +01:00
proddy
55b8c2d04c fix standalone/make, fix HA avty, fix deprecated arduinojson, update packages 2025-12-11 23:52:44 +01:00
Proddy
8bd1cd03b4 Merge pull request #2793 from MichaelDvP/dev
PSram memory
2025-12-11 22:09:41 +01:00
MichaelDvP
bafb7c35fb Merge branch 'dev' of https://github.com/emsesp/EMS-ESP32 into dev 2025-12-11 16:26:16 +01:00
MichaelDvP
9425558ff7 Merge branch 'dev' of https://github.com/MichaelDvP/EMS-ESP32 into dev 2025-12-11 16:22:18 +01:00
MichaelDvP
f20aad3813 add Phy RTL8201 2025-12-11 16:10:41 +01:00
MichaelDvP
7a683d3637 more to psram, names for sensors, schedule, custom as char[20] 2025-12-11 16:00:24 +01:00
MichaelDvP
ac982cbb15 fix #2800 2025-12-09 21:11:36 +01:00
Proddy
9889b1b5c4 Merge pull request #2796 from gr3enk/dev
Adding api/system/metrics endpoint for Prometheus integration
2025-12-08 18:49:42 +01:00
MichaelDvP
7cb647e5f1 chore: update generated files for v3.7.3-dev.35 2025-12-08 15:09:51 +00:00
MichaelDvP
515b75160c stub to remove #ifndef EMSESP_STANDALONE 2025-12-08 12:42:11 +01:00
MichaelDvP
a365dc7519 remove syslog debug level 2025-12-07 18:25:12 +01:00
MichaelDvP
764520714b fix syslog quality calculation 2025-12-07 18:24:41 +01:00
MichaelDvP
43e087ae91 allow uart0 pin for eth (check eth first) #2794 2025-12-07 13:02:25 +01:00
Jakob
bffccc585a test: add tests for /api/system/metrics endpoint 2025-12-07 11:32:37 +01:00
Jakob
a01f10b042 feat: add /api/system/metrics endpoint 2025-12-07 11:31:55 +01:00
MichaelDvP
26a5f98aae testdata 2025-12-06 14:30:45 +01:00
MichaelDvP
67280546af weblog queue complete in psram, save weblog settings in one call 2025-12-06 14:06:15 +01:00
Proddy
64058b0f61 Merge pull request #2792 from proddy/dev
small updates
2025-12-04 20:32:21 +01:00
proddy
d7b5c81b0e package update 2025-12-04 20:29:20 +01:00
MichaelDvP
1d03056784 move some vectors to psram, fix syslog start/stop 2025-12-04 19:57:01 +01:00
MichaelDvP
dd0ab8f962 Merge branch 'dev' of https://github.com/proddy/EMS-ESP32 into dev 2025-12-04 09:22:47 +01:00
proddy
02e8dba971 fix max size to 32MB 2025-12-03 22:16:39 +01:00
proddy
59878fb190 remove vscode settings and package.json 2025-12-03 22:11:44 +01:00
proddy
9ff0f83af9 remove obsolete double auto-commit 2025-12-03 22:06:06 +01:00
proddy
e6f825371e fix make for Windows 2025-12-03 22:05:45 +01:00
proddy
45f3f23033 package update 2025-12-03 21:53:33 +01:00
proddy
ffd27db208 show elapsed time 2025-12-03 21:53:26 +01:00
proddy
a452d6131b DiffTemp ranged to 4-15 K - fix #2783 2025-12-03 21:53:16 +01:00
MichaelDvP
0bc478f9d3 Merge branch 'dev' of https://github.com/emsesp/EMS-ESP32 into dev 2025-12-02 21:08:49 +01:00
Proddy
03ef981765 Merge pull request #2788 from proddy/dev
fix mem issue
2025-12-02 19:42:05 +01:00
proddy
9ca9f25fd3 rollback modbus hash map - fix #2752 2025-12-02 19:41:17 +01:00
proddy
41122dddb2 dev-34 2025-12-02 19:40:45 +01:00
proddy
1e0c94d007 package update 2025-12-02 19:40:38 +01:00
proddy
3e42a7fb4c package update 2025-12-01 19:56:59 +01:00
proddy
a8fcc1fb44 show mem 2025-12-01 19:56:53 +01:00
proddy
e43416019d package update 2025-12-01 17:40:53 +01:00
Proddy
9f467ecec1 Merge pull request #2782 from proddy/dev
MQTT base fixes
2025-11-30 23:28:16 +01:00
proddy
273d87dbf1 use ~ as MQTT base 2025-11-30 23:27:28 +01:00
proddy
befd21f8cb fixe #2780 2025-11-30 23:27:18 +01:00
MichaelDvP
882e3bc1cd Merge branch 'dev' of https://github.com/emsesp/EMS-ESP32 into dev 2025-11-30 21:25:07 +01:00
Proddy
15e05c4abc Merge pull request #2778 from proddy/dev
update to show heap
2025-11-30 20:51:42 +01:00
proddy
748a2f5fcf update to show heap 2025-11-30 20:51:17 +01:00
Proddy
37ba42faf8 Merge pull request #2775 from proddy/dev
loop tests
2025-11-30 17:24:56 +01:00
proddy
19e343e517 loop tests 2025-11-30 17:23:58 +01:00
Proddy
8f39129bf8 Merge pull request #2773 from proddy/dev
a collection of changes
2025-11-30 15:43:18 +01:00
proddy
9c3521caf2 add target native-test-create 2025-11-30 15:39:36 +01:00
proddy
40fc0fd2f9 anti-rollback! 2025-11-30 15:39:13 +01:00
proddy
ff8566498f typo 2025-11-30 15:15:03 +01:00
proddy
dd06882860 auto-formatting 2025-11-30 14:05:50 +01:00
proddy
fb2294c945 added Prometheus metrics 2025-11-30 14:05:02 +01:00
proddy
26ea8320ce rollback for #2752 2025-11-30 14:04:45 +01:00
proddy
0f4963d91e formatting 2025-11-30 14:04:14 +01:00
proddy
91020abc90 formatting 2025-11-30 14:04:05 +01:00
Proddy
64906f3ea0 Merge branch 'emsesp:dev' into dev 2025-11-30 13:54:26 +01:00
Proddy
28f85b4c5a Merge pull request #2774 from gr3enk/dev
Adding api/metrics endpoint for Prometheus integration
2025-11-30 13:51:47 +01:00
Jakob
b44a0d6813 test: add tests for api/metrics endpoint 2025-11-30 10:37:34 +01:00
Jakob
8af7cde2d6 feat: add api/metrics endpoint 2025-11-30 10:35:19 +01:00
proddy
3fcd656bb6 package update with new alova 2025-11-30 10:22:20 +01:00
proddy
76c827257e mqtt heartbeat, no need to check if connected 2025-11-30 10:22:08 +01:00
proddy
8ca9f7ee30 change ip 2025-11-29 15:36:24 +01:00
proddy
da7a06646a rollback AsyncWS fix #2752 2025-11-29 15:35:57 +01:00
proddy
0a36f1df7a updated 2025-11-29 15:14:33 +01:00
proddy
80e5d30781 dev-33 2025-11-29 15:14:26 +01:00
proddy
9c4beba3b1 add LWT to HA discovery config topic 2025-11-29 15:12:07 +01:00
proddy
0cf932f57e comment change 2025-11-29 14:49:08 +01:00
proddy
5cb9f3b014 init board profile correctly 2025-11-29 14:48:57 +01:00
proddy
3eb581142a default keep alive 60 seconds 2025-11-29 14:48:42 +01:00
proddy
d6c460e7fd ESP32Async/ESPAsyncWebServer @ 3.9.2 2025-11-29 14:48:24 +01:00
proddy
a2baa50530 show users reports error if not admin 2025-11-29 14:48:13 +01:00
proddy
6569b8c038 enable TLS for test data 2025-11-29 14:47:42 +01:00
proddy
48b4bf02a3 wider box for TLS 2025-11-29 14:47:35 +01:00
proddy
693054a92a package update 2025-11-29 14:47:27 +01:00
MichaelDvP
1472e53410 update asyncwebserver 3.9.1 2025-11-28 09:09:12 +01:00
MichaelDvP
5ed4970d62 change uart pins without restart 2025-11-28 09:08:53 +01:00
MichaelDvP
056cf3cbd6 settingservice gpio initialization for custom boards 2025-11-26 18:17:40 +01:00
MichaelDvP
2bcd548747 add counter 0..2 for short pulses, high frequency, #2758 2025-11-26 18:14:58 +01:00
MichaelDvP
9edcf47073 tx read, wait 2 sec brfore 3rd. retry 2025-11-26 18:12:24 +01:00
MichaelDvP
084d90e714 solar heat assistance, rounding custom entities #2763 2025-11-26 18:11:48 +01:00
Proddy
a738cc36dd Merge pull request #2762 from proddy/dev
gpio update
2025-11-26 17:03:09 +01:00
proddy
9b6f9aeda3 dev-32 2025-11-26 16:52:54 +01:00
proddy
8ea8c1821d Merge branch 'dev' of https://github.com/proddy/EMS-ESP32 into dev 2025-11-26 14:00:08 +01:00
proddy
53a1a8826e update 2025-11-26 14:00:06 +01:00
proddy
237551d9f6 remove eth power gpio if set 2025-11-26 14:00:02 +01:00
proddy
f7c7bc65f2 keep loop even if there is gpio errors 2025-11-26 13:59:46 +01:00
proddy
badbd9c6fe show system status in status page 2025-11-26 13:59:34 +01:00
proddy
cc7ac5b911 package update 2025-11-26 13:59:08 +01:00
proddy
5957300c4e change (c) date to 2025 2025-11-24 23:27:01 +01:00
proddy
2e343ce0c3 optimize modbus 2025-11-24 23:25:53 +01:00
proddy
3113a4be2b remove old todos 2025-11-24 23:25:43 +01:00
proddy
7d9cb2932f update tests 2025-11-24 23:17:37 +01:00
proddy
026c2caea0 fix when manually setting board type 2025-11-24 23:17:27 +01:00
proddy
eb058b688d lint warnings 2025-11-24 22:37:51 +01:00
proddy
2a7a0ce3f6 formatting 2025-11-24 22:37:41 +01:00
proddy
a330110eef update 2025-11-24 22:37:30 +01:00
proddy
feed534be5 package update 2025-11-24 22:37:23 +01:00
proddy
2375633bca don't show confusing release type checkboxes 2025-11-24 22:35:07 +01:00
proddy
7018139289 update version data 2025-11-24 22:34:34 +01:00
proddy
5f67934f26 updated 2025-11-24 22:12:46 +01:00
proddy
574cc8ad33 error message text change 2025-11-24 22:12:41 +01:00
proddy
658c613c4e init data with 0, no difference really 2025-11-24 22:12:24 +01:00
proddy
c2ce54c2a7 adjust gpio for ESP32 boards 2025-11-24 16:58:06 +01:00
proddy
58da0d9778 update 2025-11-24 16:57:55 +01:00
proddy
8b90036c11 fixed uart0 for esp chips 2025-11-24 16:44:13 +01:00
proddy
65678ea739 package update 2025-11-24 16:44:03 +01:00
Proddy
34ddc9b7ff Merge pull request #2750 from proddy/dev
adjustments to invalid sensors
2025-11-23 20:35:46 +01:00
proddy
97f9914d33 grey out entities with no value 2025-11-23 20:32:25 +01:00
proddy
8b64851f6f renaming 2025-11-23 20:07:36 +01:00
proddy
c00f50238e add/remove analog gpio from list - fixes #2758 2025-11-23 20:07:22 +01:00
proddy
25ea57e02f don't allow gpio 1 and 3, as will conflict with Serial/UART0 2025-11-23 20:06:50 +01:00
proddy
a951afe205 rename valid_gpio_list to available_gpios 2025-11-23 20:06:26 +01:00
proddy
44c7954ce7 package update 2025-11-23 20:05:17 +01:00
proddy
ef315b6dde consistent borders and removed some comments 2025-11-23 16:55:46 +01:00
proddy
935a9dcbb7 fix remove_gpio 2025-11-23 16:55:25 +01:00
proddy
2bda432d70 show in log if we're autodetecting board 2025-11-23 16:54:56 +01:00
proddy
d567ea3cf0 adjusted remove pin 2025-11-23 16:34:06 +01:00
proddy
7b5e386595 update tests 2025-11-23 14:54:31 +01:00
proddy
1c65a7caba fix count of temp sensors - #2756 2025-11-23 14:46:57 +01:00
proddy
12ebacf1a7 typo 2025-11-23 14:13:29 +01:00
proddy
f2c176111f allow some strapping pins (Michael's fix) 2025-11-23 13:19:09 +01:00
proddy
cc242e5eba get rid of scrollbar 2025-11-23 13:12:59 +01:00
proddy
4888808ad0 skip if CUSTOM 2025-11-23 13:05:06 +01:00
proddy
cd1b5e1d57 skip if CUSTOM 2025-11-23 13:04:54 +01:00
proddy
9661c9a0eb typo 2025-11-23 13:04:31 +01:00
proddy
f6ccf6da44 formatting 2025-11-23 13:04:10 +01:00
proddy
b691488240 fix standalone 2025-11-22 23:05:58 +01:00
proddy
eb71996e6a align signon username/password boxes 2025-11-22 22:58:17 +01:00
proddy
b9566ae1d6 changed debug text 2025-11-22 22:57:59 +01:00
proddy
8016fc4287 gpio checking 2025-11-22 22:38:46 +01:00
proddy
c95b43ea69 remove emsesp namespace 2025-11-22 22:38:20 +01:00
proddy
d767a503cd remove log out of loop if we find a gpio error 2025-11-22 22:37:12 +01:00
proddy
221131f9d3 restart after setting board profile in console 2025-11-22 22:36:43 +01:00
proddy
f307cccabb don't check gpio 2025-11-22 22:36:15 +01:00
proddy
2f21d0d94c remove emsesp namespace 2025-11-22 22:34:28 +01:00
proddy
132d1292ce remove emsesp namespace 2025-11-22 22:34:14 +01:00
proddy
df9a10cb53 remove esmesp:: 2025-11-22 22:33:31 +01:00
proddy
95d564901b formatting 2025-11-22 22:33:17 +01:00
proddy
6424d9b1c0 package update 2025-11-22 22:33:06 +01:00
proddy
38a3d20acf prevent double calling api 2025-11-22 22:32:49 +01:00
proddy
89b117bbc2 formatting 2025-11-22 22:32:39 +01:00
proddy
eeba7a3a6b fix 2025-11-20 23:03:06 +01:00
proddy
23a660aabb updated gpio test logic 2025-11-20 22:58:26 +01:00
proddy
c9bddba446 fix standalone 2025-11-20 15:21:38 +01:00
proddy
3a508a3ec4 fixes #2752 2025-11-20 14:58:54 +01:00
proddy
6bf33f6447 update test API 2025-11-20 14:57:57 +01:00
proddy
a02054ceb6 new test for #2752 2025-11-20 14:35:58 +01:00
proddy
8422521975 formatting of error message box to align buttons 2025-11-20 14:25:25 +01:00
proddy
fbfacc5ed5 3.7.3-dev.31 2025-11-20 14:24:57 +01:00
proddy
b9d96620a4 show restart needed after changing board profile. also no need to restart sensors. 2025-11-20 14:24:40 +01:00
proddy
8c61735579 package update 2025-11-20 14:12:04 +01:00
proddy
56e8ccdfc4 remove gpio checks, as they are performed in supporting functions 2025-11-18 21:38:19 +01:00
proddy
5454e7dd16 warning message formatting so its same as Dashboard 2025-11-18 21:37:55 +01:00
proddy
901a27140c only dallas and led use GPIO 0 for disabled 2025-11-18 20:41:30 +01:00
proddy
37db2b9504 add comment 2025-11-18 20:40:07 +01:00
proddy
5e7768f912 package update 2025-11-18 20:39:51 +01:00
proddy
80dd16740d some fixes on https://github.com/emsesp/EMS-ESP32/pull/2750 2025-11-17 23:00:45 +01:00
proddy
24421a0224 package update 2025-11-17 17:50:43 +01:00
proddy
26d26cf088 formatting 2025-11-17 17:50:34 +01:00
proddy
615c5e8439 addition 2025-11-17 17:49:37 +01:00
proddy
090387ef37 replace disabled sensor gpio (99) with code logic 2025-11-17 17:49:30 +01:00
Proddy
25ea7d8b0c Merge pull request #2749 from proddy/dev
updated doc
2025-11-17 14:36:08 +01:00
proddy
68f067f2c4 added MQTT Refresh button 2025-11-17 14:35:32 +01:00
proddy
e20fa5ab39 package update 2025-11-17 14:35:21 +01:00
proddy
bee307a91d formatting 2025-11-17 14:35:14 +01:00
Proddy
f85226ce55 Merge pull request #2748 from MichaelDvP/dev
fix gpio check
2025-11-17 14:32:17 +01:00
MichaelDvP
f112e6f6cc exclude disabled sensors from dashboard 2025-11-17 13:57:30 +01:00
MichaelDvP
5df82b7e2c Merge branch 'dev' of https://github.com/emsesp/EMS-ESP32 into dev 2025-11-17 13:29:20 +01:00
MichaelDvP
ea75a34c82 fix gpio check 2025-11-17 13:28:00 +01:00
Proddy
07d0de0151 Merge pull request #2747 from proddy/dev
MQTT reset button & label color for TextFields when disabled
2025-11-17 12:27:11 +01:00
proddy
0a75dd7e3c new action resetMQTT, called from MQTT Settings page 2025-11-17 12:26:14 +01:00
proddy
88afd3f453 use ValidatedTextField 2025-11-17 12:25:52 +01:00
proddy
9669a044ba comment out log msg 2025-11-17 12:25:20 +01:00
proddy
e31ebab12b grey out label if disabled (for system sensors) 2025-11-17 12:24:58 +01:00
proddy
e68a3a0d3a update 2025-11-17 12:23:30 +01:00
Proddy
4ae09c8766 Merge pull request #2746 from MichaelDvP/dev
validate board gpios in system
2025-11-17 09:35:03 +01:00
MichaelDvP
a693e96248 validate board gpios in system 2025-11-16 20:41:45 +01:00
Proddy
60b0de79b3 Merge pull request #2744 from proddy/dev
webUI gpio improvements
2025-11-15 23:01:50 +01:00
proddy
86277396f7 fix 2025-11-15 22:05:35 +01:00
proddy
0b65d55601 update test data 2025-11-15 22:05:29 +01:00
proddy
23782ec773 show gpio for system sensors 2025-11-15 20:45:33 +01:00
proddy
0b33497842 update sensor names 2025-11-15 20:45:18 +01:00
proddy
6cd4bcd41c update 2025-11-15 20:22:52 +01:00
proddy
8d8db3ba85 update test data 2025-11-15 20:22:47 +01:00
proddy
d9c2066035 update gpios https://github.com/emsesp/EMS-ESP32/pull/2744 2025-11-15 20:22:37 +01:00
proddy
af5cbf045d update 2025-11-15 16:36:55 +01:00
proddy
99d67cdd42 auto-formatting 2025-11-15 16:36:13 +01:00
proddy
a74910ddf6 remove header for AsyncWS 3.9.0 2025-11-15 16:36:02 +01:00
proddy
d9678d04dd Merge branch 'dev' of https://github.com/proddy/EMS-ESP32 into dev 2025-11-15 16:27:40 +01:00
proddy
c7acf89d84 standardizing is_valid_gpio 2025-11-15 16:27:39 +01:00
Proddy
24f1fe13cc Merge branch 'emsesp:dev' into dev 2025-11-15 16:26:53 +01:00
Proddy
11fcd8bcfe Merge pull request #2745 from MichaelDvP/dev
process_telegram in single loop, update AsyncWebServer, dev30
2025-11-15 16:26:23 +01:00
proddy
bcde5bad63 build_webUI command does it all 2025-11-15 14:21:50 +01:00
proddy
d2a8fbaf1e formatting 2025-11-15 14:21:35 +01:00
proddy
f9d2b18959 package update 2025-11-15 14:21:28 +01:00
proddy
bdba2ae6e4 add clang's .cache folder 2025-11-15 14:21:16 +01:00
proddy
f068ed97f1 determine list of valid gpios in backend code 2025-11-15 14:20:43 +01:00
MichaelDvP
7ab2e782ac process_telegram in single loop, update AsyncWebServer, dev30 2025-11-15 12:49:30 +01:00
Proddy
88a7d12306 Merge pull request #2743 from MichaelDvP/dev
fix #2726
2025-11-14 21:47:03 +01:00
MichaelDvP
dffe8b2648 Merge branch 'dev' of https://github.com/emsesp/EMS-ESP32 into dev 2025-11-14 18:46:20 +01:00
MichaelDvP
daa98b106e update pkg 2025-11-14 18:43:11 +01:00
MichaelDvP
83796341c5 fix #2726 2025-11-14 17:46:13 +01:00
Proddy
ea24cd8a0f Merge pull request #2741 from proddy/dev
factor rendering in webUI
2025-11-13 21:09:24 +01:00
proddy
0e7ff32d02 package update 2025-11-13 21:08:28 +01:00
proddy
31706bfc21 https://github.com/emsesp/EMS-ESP32/discussions/2728#discussioncomment-14957216 2025-11-13 21:08:20 +01:00
Proddy
993e2fdc22 Merge pull request #2739 from proddy/dev
update tests
2025-11-12 21:28:13 +01:00
proddy
0d05cf489b update tests 2025-11-12 21:27:44 +01:00
Proddy
0961b814d5 Merge pull request #2738 from proddy/dev
updates
2025-11-12 21:25:22 +01:00
proddy
999c407f12 update 2025-11-12 21:24:52 +01:00
proddy
7c137d71ca update 2025-11-12 21:24:48 +01:00
Proddy
ad26768d6d Merge pull request #2737 from MichaelDvP/dev
fixes and changes for #2732, #2733, #2735
2025-11-12 21:15:12 +01:00
Proddy
5ed7d60ae8 Merge branch 'dev' into dev 2025-11-12 21:14:57 +01:00
Proddy
a67379fcb2 Merge pull request #2734 from proddy/dev
minor updates
2025-11-12 21:11:34 +01:00
proddy
cccae3fbcc fixes #2728 2025-11-12 21:10:24 +01:00
proddy
10d85c6547 formatting 2025-11-12 19:07:22 +01:00
MichaelDvP
21af1adefe chore: update generated files 2025-11-12 17:30:35 +00:00
MichaelDvP
fe02258a00 dev 28, changelog 2025-11-12 18:19:11 +01:00
MichaelDvP
3d8ec8e295 wwprio to mixer, boiler 2025-11-12 16:32:46 +01:00
MichaelDvP
d5b496aa67 add pid characteristic #2735 2025-11-12 16:32:17 +01:00
MichaelDvP
8458cfc468 alanogsensor pulse,add mqtt/HA output #2732 2025-11-12 13:38:26 +01:00
MichaelDvP
f10d2ef48d allow syslog mark intervall up to 1 hour #2733 2025-11-12 13:36:24 +01:00
proddy
2ee433bb89 ui small changes 2025-11-12 09:59:50 +01:00
proddy
237cf91f5a package update 2025-11-12 09:59:38 +01:00
proddy
8db7e4a5b9 updated 2025-11-11 22:07:42 +01:00
proddy
a3703514b1 package update 2025-11-11 21:43:11 +01:00
proddy
db8ed34dc4 update dictionary 2025-11-11 21:43:05 +01:00
proddy
21b89cf84d default test data is all 2025-11-11 21:41:18 +01:00
proddy
fc0f29337b Syslog: allow longer intervals than 10 sec for "Mark interval" #2733 2025-11-11 21:41:03 +01:00
Proddy
84b9e6ae18 Merge pull request #2730 from mattreim/dev
Update German translation
2025-11-11 21:31:35 +01:00
mattreim
cbabaa7d91 Update German translation 2025-11-10 23:13:49 +01:00
Proddy
4cfe704242 Merge pull request #2729 from proddy/dev
web UI improvements and more fixes for system sensors
2025-11-10 22:38:04 +01:00
proddy
25cae61cc1 updated tests 2025-11-10 22:37:12 +01:00
proddy
d96b3e6d05 updated 2025-11-10 22:37:03 +01:00
Proddy
1f261476d5 Merge branch 'emsesp:dev' into dev 2025-11-10 22:31:07 +01:00
Proddy
3171fc7375 Merge pull request #2727 from MichaelDvP/dev
pumpmode enum for HT3 boilers #2721, add commands manual defrost, chimneysweeper
2025-11-10 22:30:52 +01:00
proddy
ba843c1589 changed HA entity_format text so its clearer 2025-11-10 22:27:11 +01:00
proddy
f7f01ea875 fix factory log message 2025-11-10 22:09:35 +01:00
proddy
8ea1dfc7d2 dev-27 2025-11-10 22:09:25 +01:00
proddy
9616a113b0 fixes to #2725 2025-11-10 21:50:47 +01:00
proddy
4aaf6e95cf update tests 2025-11-10 21:35:35 +01:00
proddy
edfdfb1016 fix standalone 2025-11-10 21:27:50 +01:00
proddy
3067149357 remove all from customizations only removes the entities, not the sensors ('as' and 'ts') 2025-11-10 21:25:20 +01:00
proddy
d5548c8bdc fix count for analog sensors 2025-11-10 21:24:41 +01:00
proddy
cf66dc99b4 tidy count_entities() 2025-11-10 21:24:03 +01:00
proddy
891619fa26 move factory log down so it gets displayed in log 2025-11-10 21:23:35 +01:00
proddy
1f6462be38 rename reset to "remove all", move top of screen and fix messages when auto-restarting 2025-11-10 21:22:24 +01:00
proddy
e74dc1fd78 new translations 2025-11-10 21:20:37 +01:00
proddy
f59768ce17 added new route for userprofile 2025-11-10 21:20:22 +01:00
proddy
22d015615d fixed jumpy buttons, moved user profile to its own component 2025-11-10 21:20:10 +01:00
proddy
0ef0ca8518 package update 2025-11-10 21:19:43 +01:00
proddy
1917c5d7cb package update 2025-11-10 21:19:31 +01:00
proddy
ff0fe593d3 show "no data" if there is no data and move help tooltip 2025-11-10 21:19:20 +01:00
proddy
12e8d64ec2 is_system fixes 2025-11-10 11:28:49 +01:00
MichaelDvP
c0c13eb687 chore: update generated files 2025-11-10 09:05:42 +00:00
MichaelDvP
91b78f9a23 pumpmode enum for HT3 boilers #2721, add commands manual defrost, chimneysweeper 2025-11-10 09:54:33 +01:00
Proddy
32474d10ce Merge pull request #2724 from proddy/dev
fixes to system sensors
2025-11-09 15:00:34 +01:00
proddy
7c3de25c20 update unit tests 2025-11-09 14:58:27 +01:00
proddy
f75b7b1a59 add is_system to analog and temperature sensors, highlight in Sensors page, remove from Dashboard, change button icon to be consistent when updating 2025-11-09 14:56:50 +01:00
Proddy
a3e01b8a3b Merge pull request #2717 from proddy/dev
fix some bugs, add some text
2025-11-09 09:39:25 +01:00
proddy
6c9a9b8632 updated for dev-26 2025-11-09 09:35:48 +01:00
proddy
3fba75868f increase size of factor textfield 2025-11-09 08:29:26 +01:00
proddy
8dee390d75 update test 2025-11-08 16:42:47 +01:00
proddy
dc838639b2 add is_system to analog sensor so internal sensors cannot be removed 2025-11-08 16:30:40 +01:00
proddy
3fd05c8eb7 fix lint error 2025-11-08 16:28:26 +01:00
proddy
5f0df140b0 factory reset show system monitor 2025-11-08 16:28:04 +01:00
proddy
b98cbd3ec5 add mock sensor data 2025-11-08 16:27:25 +01:00
proddy
18d67d088e update 2025-11-08 15:27:16 +01:00
proddy
0bf60394fe dev-26 2025-11-08 15:27:10 +01:00
proddy
cec5ffd547 package update 2025-11-08 15:27:03 +01:00
proddy
026ea4450e error is color red 2025-11-08 15:26:54 +01:00
proddy
9a1dd5bb98 update standalone and test output 2025-11-08 12:47:00 +01:00
proddy
fbc42fbb15 double press resets wifi to AP (https://github.com/emsesp/EMS-ESP32/discussions/2720) 2025-11-08 12:41:25 +01:00
proddy
5613cde00f update defaults 2025-11-08 12:27:08 +01:00
proddy
d65d6f49cd fix FS format, move AP* from Network system info to own node 2025-11-08 11:30:40 +01:00
proddy
c9bc18cf4b fix, attempt #3 2025-11-06 18:29:39 +01:00
Proddy
9179127bce Merge branch 'emsesp:dev' into dev 2025-11-06 14:48:08 +01:00
Proddy
16b6bef393 Merge pull request #2719 from misa1515/patch-26
Update locale_translations.h
2025-11-06 14:47:49 +01:00
misa1515
52159ac596 Update locale_translations.h 2025-11-06 13:46:47 +01:00
proddy
0dc3fd43e9 remove comment, rollback changes 2025-11-06 12:57:13 +01:00
proddy
b0d490036f fix bug, only find dest 2025-11-06 12:56:58 +01:00
proddy
c28b098c65 show ntp in info 2025-11-06 12:56:42 +01:00
proddy
4a6ccce09a add ntp for completeness and testing 2025-11-06 12:56:20 +01:00
proddy
5053ad08dd add comment 2025-11-06 12:56:00 +01:00
proddy
72b4809ed8 update tests 2025-11-06 12:55:49 +01:00
proddy
6799fe5189 rename Initialising to Booting 2025-11-06 12:55:40 +01:00
proddy
12635ff4a5 optimizations and show # entities and languages 2025-11-05 22:29:15 +01:00
proddy
5908fd9d9c show language count 2025-11-05 20:50:29 +01:00
proddy
5c07a2c0cc remove specific commands like ntp/enabled (ap/enabled didn't work) and replace with just service name 2025-11-05 16:54:08 +01:00
proddy
f5048abae7 formatting 2025-11-05 16:53:25 +01:00
Proddy
76acffdb2e Merge pull request #2715 from MichaelDvP/dev
add SRC climate humidity #2714
2025-11-05 15:03:01 +01:00
MichaelDvP
1ac96aa02e add SRC climate humidity #2714 2025-11-05 14:50:32 +01:00
Proddy
3386ac7f8a Merge pull request #2701 from proddy/dev
web optimizations
2025-11-05 14:06:26 +01:00
proddy
1e4c157c28 package update 2025-11-05 14:04:50 +01:00
proddy
a1c5297eef add aria-label to buttons and text fields with no label #2710 2025-11-05 14:02:00 +01:00
proddy
cda04bef26 don't print out file names in bundle 2025-11-05 09:12:36 +01:00
proddy
1edf60b617 formatting 2025-11-05 09:01:35 +01:00
proddy
32cf4dd6c9 3.7.3-dev.25 2025-11-05 09:01:29 +01:00
proddy
f0caeb089d package update 2025-11-05 08:58:04 +01:00
proddy
23f1e7569c formatting 2025-11-05 08:57:56 +01:00
proddy
5087fdb5d6 prevent flicker when refreshing 2025-11-04 18:17:35 +01:00
proddy
b654a42229 package update 2025-11-04 18:06:09 +01:00
proddy
d312c8e592 update 2025-11-04 18:06:04 +01:00
proddy
319787bae3 switch our dialog with box 2025-11-04 18:05:57 +01:00
proddy
d124b04a2c tidy up error page 2025-11-04 18:05:41 +01:00
proddy
4ef8a3a163 package update 2025-11-04 01:00:46 +01:00
proddy
41b5cdddf2 smaller window 2025-11-04 01:00:39 +01:00
proddy
e10453b2fd don't show border 2025-11-04 01:00:27 +01:00
proddy
d2f665ab70 fix name 2025-11-04 01:00:17 +01:00
proddy
4083478b65 smaller font 2025-11-04 01:00:08 +01:00
proddy
01136a19a5 make wait uint16_t to allow max 2000 2025-11-03 18:18:08 +01:00
proddy
0b2df96461 add message test 2025-11-03 18:15:29 +01:00
Proddy
f66c3ff322 Merge branch 'dev' into dev 2025-11-03 18:02:43 +01:00
proddy
5d8fe89e5a python optimizations 2025-11-03 18:01:09 +01:00
proddy
17bdd87576 optimized window size detection 2025-11-03 17:51:51 +01:00
proddy
8f7c0a1d97 all values 2025-11-03 17:51:04 +01:00
proddy
3b453b18bc add section for all values 2025-11-03 17:50:53 +01:00
proddy
a1fc5bf54b fix lint warning 2025-11-03 17:50:43 +01:00
proddy
0cb413a579 package update 2025-11-03 17:50:30 +01:00
proddy
f2e38330ea fix window positioning 2025-11-02 19:46:11 +01:00
proddy
9f1cd04d45 auto-formatting 2025-11-02 13:01:03 +01:00
proddy
fe67f3a982 fix syslog, remove filter 2025-11-02 13:00:55 +01:00
proddy
2fd6e9ebf5 package update 2025-11-02 13:00:45 +01:00
proddy
67655c4b06 package updates 2025-11-01 16:12:09 +01:00
Proddy
2970aa5ba3 Merge branch 'dev' into dev 2025-11-01 16:10:21 +01:00
proddy
99a3ffcf17 optimizations, use md5 for hash 2025-11-01 16:04:47 +01:00
proddy
0edb844225 remove pnpm build 2025-10-31 18:41:37 +01:00
proddy
e3feb8f11e webUI target also builds 2025-10-31 18:41:27 +01:00
proddy
6b7534b7fb optimizations 2025-10-31 18:38:38 +01:00
proddy
ca1506de8b add custom error page 2025-10-31 18:36:18 +01:00
proddy
1cb535dea3 optimized for speed 2025-10-31 18:35:01 +01:00
proddy
5d32b6d383 package update 2025-10-31 18:34:39 +01:00
proddy
e6dbe020c1 package update 2025-10-29 12:40:28 +01:00
proddy
63cf1603b0 modules menuitem resized 2025-10-29 12:40:21 +01:00
proddy
aadb67fa79 speed up mock logging 2025-10-28 22:19:43 +01:00
proddy
4949471518 package update 2025-10-28 22:19:43 +01:00
proddy
9504723ef2 add back security 2025-10-28 22:19:43 +01:00
proddy
3abfb7bb9c optimizations 2025-10-28 22:19:43 +01:00
383 changed files with 34222 additions and 22370 deletions

View File

@@ -21,8 +21,8 @@ _Make sure your have performed every step and checked the applicable boxes befor
- [ ] Searched the issue in [issues](https://github.com/emsesp/EMS-ESP32/issues)
- [ ] Searched the issue in [discussions](https://github.com/emsesp/EMS-ESP32/discussions)
- [ ] Searched the issue in the [docs](https://docs.emsesp.org/Troubleshooting/)
- [ ] Searched the issue in the [chat](https://discord.gg/3J3GgnzpyT)
- [ ] Searched the issue in the [docs](https://emsesp.org/Troubleshooting/)
- [ ] 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

View File

@@ -1,11 +1,11 @@
blank_issues_enabled: false
contact_links:
- name: EMS-ESP Docs
url: https://docs.emsesp.org
url: https://emsesp.org
about: All the information related to EMS-ESP.
- name: EMS-ESP Discussions and Support
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.

View File

@@ -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,35 +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"
- name: Configure Git
run: |
git config --local user.email "action@github.com"
git config --local user.name "GitHub Action"
- name: Check for changes and commit
run: |
if [ -n "$(git status --porcelain)" ]; then
echo "Changes detected, committing..."
git add .
git commit -m "Auto-commit build artifacts and configuration updates
- Updated build configurations
- Generated build artifacts
- Version: ${{steps.build_info.outputs.VERSION}}"
echo "Pushing changes to repository..."
git push origin dev
else
echo "No changes to commit"
fi
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}}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -26,7 +26,7 @@ jobs:
node-version: 24
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Enable Corepack
run: corepack enable pnpm
@@ -39,7 +39,7 @@ jobs:
- name: Build webUI
run: |
platformio run -e build_webUI
platformio run -e build-webUI
- name: Build modbus
run: |
@@ -54,7 +54,7 @@ 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

View File

@@ -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}}

5
.gitignore vendored
View File

@@ -2,7 +2,6 @@
.vscode/c_cpp_properties.json
.vscode/extensions.json
.vscode/launch.json
.vscode/settings.json
# c++ compiling
.clang_complete
@@ -73,4 +72,6 @@ logs/*
sdkconfig.*
sdkconfig_tasmota_esp32
pnpm-lock.yaml
package.json
.cache/
interface/.tsbuildinfo
test/test_api/package-lock.json

View File

@@ -5,6 +5,120 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [3.8.1] 11 January 2026
## Added
- update time saved in nvs
- heatpump entities [#2883](https://github.com/emsesp/EMS-ESP32/issues/2883)
- HA input number format (mode) selectable box/slider (slider for max range 100) [#2900](https://github.com/emsesp/EMS-ESP32/discussions/2900)
## Fixed
- fix EMS bus disconnected errors on some systems [#2881](https://github.com/emsesp/EMS-ESP32/issues/2881)
- selflowtemp fix [#2876](https://github.com/emsesp/EMS-ESP32/issues/2876)
- updated valid GPIOs for ESP32S2, ESP32S3 and ESP32 that caused custom systems to block gpios [#2887](https://github.com/emsesp/EMS-ESP32/issues/2887)
- Junkers wwcharge offset [#2860](https://github.com/emsesp/EMS-ESP32/issues/2860)
- fixed minflowtemp [#2890](https://github.com/emsesp/EMS-ESP32/issues/2890)
- don't add HA uom/classes for bool values [#2885](https://github.com/emsesp/EMS-ESP32/issues/2885)
- fixed missing progress bar on web firmware uploads
## Changed
- snapshot gpios stored in temporary ram
- GPIOs stored along with the name and reported in log if conflicting
- free GPIOs depend on board profile [#2901](https://github.com/emsesp/EMS-ESP32/issues/2901)
- prefer PSram for mqtt queue [#2889](https://github.com/emsesp/EMS-ESP32/issues/2889)
- day schedule defult to all days, no day selected is not allowed
- board profile `CUSTOM` can only be selected in developer mode
- mqtt sends round values without decimals (`28` instead of `28.0`)
## [3.8.0] 31 December 2025
## Added
- analogsensor types: NTC and RGB-Led
- Flag for HMC310 [#2465](https://github.com/emsesp/EMS-ESP32/issues/2465)
- boiler auxheatersource [#2489](https://github.com/emsesp/EMS-ESP32/discussions/2489)
- thermostat last error for RC100/300 [#2501](https://github.com/emsesp/EMS-ESP32/issues/2501)
- boiler 0xC6 telegram [#1963](https://github.com/emsesp/EMS-ESP32/issues/1963)
- CS6800i changes [#2448](https://github.com/emsesp/EMS-ESP32/issues/2448), [#2449](https://github.com/emsesp/EMS-ESP32/issues/2449)
- charging pump [#2544](https://github.com/emsesp/EMS-ESP32/issues/2544)
- hybrid CSH5800iG [#2569](https://github.com/emsesp/EMS-ESP32/issues/2569)
- added EMS Device details to Home Assistant MQTT Discovery
- disinfection command [#2601](https://github.com/emsesp/EMS-ESP32/issues/2601)
- added new board profile for upcoming BBQKees E32V2.2
- set differential pressure entity in Mixer device
- set set climate action cooling/heating in HA [#2583](https://github.com/emsesp/EMS-ESP32/issues/2583)
- Internal sensors of E32V2_2
- FW200 display options [#2610](https://github.com/emsesp/EMS-ESP32/discussions/2610)
- CR11 mode settings OFF/MANUAL depends on selTemp [#2437](https://github.com/emsesp/EMS-ESP32/issues/2437)
- implemented eFuse settings for BBQKees boards to store model type and ESP chipset
- analogsensors for pulse output [#2624](https://github.com/emsesp/EMS-ESP32/discussions/2624)
- analogsensors frequency input [#2631](https://github.com/emsesp/EMS-ESP32/discussions/2631)
- SRC plus thermostats [#2636](https://github.com/emsesp/EMS-ESP32/issues/2636)
- Greenstar 2000 [#2645](https://github.com/emsesp/EMS-ESP32/issues/2645)
- RC3xx `dhw modetype` [#2659](https://github.com/emsesp/EMS-ESP32/discussions/2659)
- new boiler entities VR0,VR1, compressor speed [#2669](https://github.com/emsesp/EMS-ESP32/issues/2669)
- solar temperature TS16 [#2690](https://github.com/emsesp/EMS-ESP32/issues/2690)
- pumpmode enum for HT3 boilers, add commands for manual defrost, chimneysweeper [#2727](https://github.com/emsesp/EMS-ESP32/issues/2727)
- pid settings [#2735](https://github.com/emsesp/EMS-ESP32/issues/2735)
- refresh MQTT button added to MQTT Settings page
- heating assistance, rounding custum settings [#2763](https://github.com/emsesp/EMS-ESP32/discussions/2763)
- added counter 0..2 for short pulses, high frequency [#2758](https://github.com/emsesp/EMS-ESP32/issues/2758)
- added LWT (Last Will and Testament) to MQTT entities in Home Assistant
- added api/metrics endpoint for prometheus integration by @gr3enk [#2774](https://github.com/emsesp/EMS-ESP32/pull/2774)
- added RTL8201 to eth phy list [#2800](https://github.com/emsesp/EMS-ESP32/issues/2800)
- added partitions to Web UI Version page, so previous firmware versions can be installed [#2837](https://github.com/emsesp/EMS-ESP32/issues/2837)
- button pressures show LED. On a long press (10 seconds) the LED flashes for 5 seconds to indicate a factory reset is about to happen. [#2848](https://github.com/emsesp/EMS-ESP32/issues/2848)
- added `txpause` command to pause the TX, by setting Txmode to 0 (disabled) [#2850](https://github.com/emsesp/EMS-ESP32/issues/2850)
## Fixed
- dhw/switchtime [#2490](https://github.com/emsesp/EMS-ESP32/issues/2490)
- switch to secure mqtt [#2492](https://github.com/emsesp/EMS-ESP32/issues/2492)
- update link buttons [#2497](https://github.com/emsesp/EMS-ESP32/issues/2497)
- refresh scheduler states [#2502](https://github.com/emsesp/EMS-ESP32/discussions/2502)
- also rebuild HA config on mqtt connect for scheduler, custom and shower
- FB100 controls the hc, not the master [#2510](https://github.com/emsesp/EMS-ESP32/issues/2510)
- IPM DHW module, [#2524](https://github.com/emsesp/EMS-ESP32/issues/2524)
- charge optimization [#2543](https://github.com/emsesp/EMS-ESP32/issues/2543)
- shower active state retained, shows correctly in HA
- MQTT Command Topic with slashes [#2571](https://github.com/emsesp/EMS-ESP32/issues/2571)
- Add pulsed water meter input to V1.3 gateway with Lilygo S3 [#2550](https://github.com/emsesp/EMS-ESP32/issues/2550)
- fix missing long 10-second press of Button to perform a factory reset
- fix wwMaxPower on Junkers ZBS14 [#2609](https://github.com/emsesp/EMS-ESP32/issues/2609)
- ventilation bypass state from telegram 0x55C [#1197](https://github.com/emsesp/EMS-ESP32/issues/1197)
- set selflowtemp for ems+ boilers [#2641](https://github.com/emsesp/EMS-ESP32/discussions/2641)
- syslog timestamp [#2704](https://github.com/emsesp/EMS-ESP32/issues/2704)
- fixed FS format command [#2720](https://github.com/emsesp/EMS-ESP32/discussions/2720)
- dhw priority setting to boiler and mixer, telegrams 0x2CC, 0x2CD, etc.
- check for valid GPIOs when board profile is changed [#2841](https://github.com/emsesp/EMS-ESP32/issues/2841)
## Changed
- show console log with ISO date/time [#2533](https://github.com/emsesp/EMS-ESP32/discussions/2533)
- removed ESP32 CPU temperature
- updated core libraries like AsyncTCP, AsyncWebServer and Modbus
- remove command `scan deep`
- ignore repeated `forceheatingoff` commands [#2641](https://github.com/emsesp/EMS-ESP32/discussions/2641)
- optimized web for better performance by adding lazy loading and caching
- internal system analog sensors (core_voltage, supply_voltage and gateway_temperature) cannot be accidentally removed
- double click button reconnects EMS-ESP to AP
- place system message command in side scheduler loop to reduce stack memory usage by 2KB
- syslog mark interval set to 1 hour
- handle process_telegram in oneloop
- improved GPIO validation for Analog Sensors and System GPIOs
- entities with no values are greyed out in the Web UI in the Customization page
- added System Status to Web Status page
- show number on entities and supported languages in log on boot
- on tx read fail delay the 3rd. retry 2 sec
- move vectors and lists to PSRAM
- removed unused last topic/payload echo-check
- added Home Assistant device details to MQTT Discovery for all devices
- device_class and state_class changes for HA MQTT Discovery [#2825](https://github.com/emsesp/EMS-ESP32/issues/2825)
## [3.7.2] 22 March 2025
## Added
@@ -85,7 +199,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- The automatically generated temperature sensor ID has replaced dashes (`-`) with underscores (`_`) to be compatible with Home Assistant.
- `api/system/info` has it's JSON key names changed to camelCase syntax.
For more details go to [docs.emsesp.org](https://docs.emsesp.org/).
For more details go to [emsesp.org](https://emsesp.org/).
## Added

View File

@@ -1,59 +1,39 @@
# Changelog
For more details go to [docs.emsesp.org](https://docs.emsesp.org/).
For more details go to [emsesp.org](https://emsesp.org/).
## [3.7.3]
## [3.8.2]
## Added
- analogsensor types: NTC and RGB-Led
- Flag for HMC310 [#2465](https://github.com/emsesp/EMS-ESP32/issues/2465)
- boiler auxheatersource [#2489](https://github.com/emsesp/EMS-ESP32/discussions/2489)
- thermostat last error for RC100/300 [#2501](https://github.com/emsesp/EMS-ESP32/issues/2501)
- boiler 0xC6 telegram [#1963](https://github.com/emsesp/EMS-ESP32/issues/1963)
- CS6800i changes [#2448](https://github.com/emsesp/EMS-ESP32/issues/2448), [#2449](https://github.com/emsesp/EMS-ESP32/issues/2449)
- charging pump [#2544](https://github.com/emsesp/EMS-ESP32/issues/2544)
- hybrid CSH5800iG [#2569](https://github.com/emsesp/EMS-ESP32/issues/2569)
- add EMS Device details to Home Assistant MQTT Discovery
- disinfection command [#2601](https://github.com/emsesp/EMS-ESP32/issues/2601)
- added new board profile for upcoming BBQKees E32V2.2
- set differential pressure entity in Mixer device
- set set climate action cooling/heating in HA [#2583](https://github.com/emsesp/EMS-ESP32/issues/2583)
- Internal sensors of E32V2_2
- FW200 display options [#2610](https://github.com/emsesp/EMS-ESP32/discussions/2610)
- CR11 mode settings OFF/MANUAL depends on selTemp [#2437](https://github.com/emsesp/EMS-ESP32/issues/2437)
- Fuse settings for BBQKees boards
- Analogsensors for pulse output [#2624](https://github.com/emsesp/EMS-ESP32/discussions/2624)
- Analogsensors frequency input [#2631](https://github.com/emsesp/EMS-ESP32/discussions/2631)
- SRC plus thermostats [#2636](https://github.com/emsesp/EMS-ESP32/issues/2636)
- Greenstar 2000 [#2645](https://github.com/emsesp/EMS-ESP32/issues/2645)
- RC3xx `dhw modetype` [#2659](https://github.com/emsesp/EMS-ESP32/discussions/2659)
- new boiler entities VR0,VR1, compressor speed [#2669](https://github.com/emsesp/EMS-ESP32/issues/2669)
- solar temperature TS16 [#2690](https://github.com/emsesp/EMS-ESP32/issues/2690)
- 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)
- e-mail notification using ReadyMail Client
- 2.nd freshwater module (dhw4, dhw5) [#2991](https://github.com/emsesp/EMS-ESP32/issues/2991)
- full system backup and restore
## Fixed
- dhw/switchtime [#2490](https://github.com/emsesp/EMS-ESP32/issues/2490)
- switch to secure mqtt [#2492](https://github.com/emsesp/EMS-ESP32/issues/2492)
- update link buttons [#2497](https://github.com/emsesp/EMS-ESP32/issues/2497)
- refresh scheduler states [#2502](https://github.com/emsesp/EMS-ESP32/discussions/2502)
- also rebuild HA config on mqtt connect for scheduler, custom and shower
- FB100 controls the hc, not the master [#2510](https://github.com/emsesp/EMS-ESP32/issues/2510)
- IPM DHW module, [#2524](https://github.com/emsesp/EMS-ESP32/issues/2524)
- charge optimization [#2543](https://github.com/emsesp/EMS-ESP32/issues/2543)
- shower active state retained, shows correctly in HA
- MQTT Command Topic with slashes [#2571](https://github.com/emsesp/EMS-ESP32/issues/2571)
- Add pulsed water meter input to V1.3 gateway with Lilygo S3 [#2550](https://github.com/emsesp/EMS-ESP32/issues/2550)
- fix missing long 10-second press of Button to perform a factory reset
- fix wwMaxPower on Junkers ZBS14 [#2609](https://github.com/emsesp/EMS-ESP32/issues/2609)
- ventilation bypass state from telegram 0x55C [#1197](https://github.com/emsesp/EMS-ESP32/issues/1197)
- set selflowtemp for ems+ boilers [#2641](https://github.com/emsesp/EMS-ESP32/discussions/2641)
- syslog timestamp [#2704](https://github.com/emsesp/EMS-ESP32/issues/2704)
- 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
## Changed
- show console log with ISO date/time [#2533](https://github.com/emsesp/EMS-ESP32/discussions/2533)
- remove ESP32 CPU temperature
- updated core libraries like AsyncTCP, AsyncWebServer and Modbus
- remove command `scan deep`
- ignore repeated `forceheatingoff` commands [#2641](https://github.com/emsesp/EMS-ESP32/discussions/2641)
- 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)
- use tasmota core 2026.03.30
- secure mqtt uses ESP_SSLClient
- fetch telegrams: set length to fetch [#3017](https://github.com/emsesp/EMS-ESP32/issues/3017)
- move http client from stack to heap
- heap optimizations [#3021](https://github.com/emsesp/EMS-ESP32/discussions/3021)

View File

@@ -6,7 +6,7 @@ Everybody is welcome and invited to contribute to the EMS-ESP Project by:
- providing Pull Requests (Features, Fixes, suggestions)
- testing new released features and report issues on your EMS equipment
- contributing to missing [documentation](https://docs.emsesp.org)
- contributing to missing [documentation](https://emsesp.org)
This document describes rules that are in effect for this repository, meant for handling issues by contributors in the issue tracker and PRs.

View File

@@ -21,13 +21,14 @@ endif
# Optimize parallel build configuration
UNAME_S := $(shell uname -s)
JOBS ?= 1
ifeq ($(UNAME_S),Linux)
EXTRA_CPPFLAGS = -D LINUX
JOBS ?= $(shell nproc)
JOBS := $(shell nproc)
endif
ifeq ($(UNAME_S),Darwin)
EXTRA_CPPFLAGS = -D OSX -Wno-tautological-constant-out-of-range-compare
JOBS ?= $(shell sysctl -n hw.ncpu)
JOBS := $(shell sysctl -n hw.ncpu)
endif
# Set optimal parallel build settings
@@ -46,27 +47,28 @@ 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/* lib_standalone lib/semver lib/espMqttClient/src lib/espMqttClient/src/Transport lib/ArduinoJson/src lib/uuid-common/src lib/uuid-console/src lib/uuid-log/src lib/uuid-telnet/src lib/uuid-syslog/src
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 += -DNO_TLS_SUPPORT
DEFINES += $(ARGS)
DEFAULTS = -DEMSESP_DEFAULT_LOCALE=\"en\" -DEMSESP_DEFAULT_TX_MODE=8 -DEMSESP_DEFAULT_VERSION=\"3.7.3-dev\" -DEMSESP_DEFAULT_BOARD_PROFILE=\"S32S3\"
DEFAULTS = -DEMSESP_DEFAULT_LOCALE=\"en\" -DEMSESP_DEFAULT_BOARD_PROFILE=\"S32S3\"
#----------------------------------------------------------------------
# Sources & Files
@@ -78,6 +80,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)))
@@ -137,6 +143,7 @@ DEPFLAGS += -MF $(BUILD)/$*.d -MT $@
LINK.o = $(LD) $(LDFLAGS) $(LDLIBS) $^ -o $@
COMPILE.c = $(CC) $(C_STANDARD) $(CFLAGS) $(DEPFLAGS) -c $< -o $@
COMPILE.cpp = $(CXX) $(CXX_STANDARD) $(CXXFLAGS) $(DEPFLAGS) -c $< -o $@
COMPILE.s = $(CC) $(CFLAGS) $(DEPFLAGS) -c $< -o $@
#----------------------------------------------------------------------
# Special Built-in Target
@@ -180,6 +187,7 @@ $(BUILD)/%.o: %.cpp
$(BUILD)/%.o: %.s
@mkdir -p $(@D)
@$(ECHO) Compiling $@
@$(COMPILE.s)
cppcheck: $(SOURCES)

View File

@@ -15,10 +15,10 @@
<a href="https://github.com/emsesp/EMS-ESP32/blob/dev/CONTRIBUTING.md">
<img src="https://img.shields.io/badge/Contribute-ff4785?style=for-the-badge&logo=git&logoColor=white" alt="Contribute" />
</a>
<a href="https://docs.emsesp.org">
<a href="https://emsesp.org">
<img src="https://img.shields.io/badge/Documentation-0077b5?style=for-the-badge&logo=googledocs&logoColor=white" alt="Guides" />
</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 @@
[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=emsesp_EMS-ESP32&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=emsesp_EMS-ESP32)
[![Codacy Badge](https://app.codacy.com/project/badge/Grade/9441142f49424ef891e8f5251866ee6b)](https://app.codacy.com/gh/emsesp/EMS-ESP32/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade)
[![downloads](https://img.shields.io/github/downloads/emsesp/EMS-ESP32/total.svg)](https://github.com/emsesp/EMS-ESP32/releases)
[![chat](https://img.shields.io/discord/816637840644505620.svg?style=flat-square&color=blueviolet)](https://discord.gg/3J3GgnzpyT)
[![chat](https://img.shields.io/discord/816637840644505620.svg?style=flat-square&color=blueviolet)](https://discord.gg/GP9DPSgeJq)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/emsesp/EMS-ESP32)
[![GitHub stars](https://img.shields.io/github/stars/emsesp/EMS-ESP32.svg?style=social&label=Star)](https://github.com/emsesp/EMS-ESP32/stargazers)
[![GitHub forks](https://img.shields.io/github/forks/emsesp/EMS-ESP32.svg?style=social&label=Fork)](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.
## 📦&nbsp; **Key Features**
@@ -60,33 +62,31 @@ It requires a small circuit to interface with the EMS bus which can be purchased
## 🚀&nbsp; **Installing**
Head over to [download.emsesp.org](https://download.emsesp.org) for instructions on how to install EMS-ESP. There is also further details on which boards are supported in [this section](https://docs.emsesp.org/Installing/) of the documentation.
Head over to the [Installation Guide](https://emsesp.org/Installing) section of the documentation for instructions on how to install EMS-ESP.
## 📋&nbsp; **Documentation**
Visit [emsesp.org](https://docs.emsesp.org) for more details on how to install and configure EMS-ESP. There is also a collection of Frequently Asked Questions and Troubleshooting tips with example customizations from the community.
Visit [emsesp.org](https://emsesp.org) for more details on how to setup and configure EMS-ESP. You'll also find more a collection of example configuarations, Frequently Asked Questions and Troubleshooting tips.
## 💬&nbsp; **Getting Support**
To chat with the community reach out on our [Discord Server](https://discord.gg/3J3GgnzpyT).
To chat with the community reach out on our [Discord Server](https://discord.gg/GP9DPSgeJq).
If you find an issue or have a request, see [how to request support](https://docs.emsesp.org/Support/) on how to submit a bug report or feature request.
If you find an issue or have a request, see the [Getting Support](https://emsesp.org/Support/) section of the documentation. Note if you are using a non-BBQKees EMS gateway, you may need to contact the manufacturer for support.
## 🎥&nbsp; **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.
## 💖&nbsp; **Contributors**
EMS-ESP is a project created by [proddy](https://github.com/proddy) and owned and maintained by both [proddy](https://github.com/proddy) and [MichaelDvP](https://github.com/MichaelDvP) with support from [BBQKees Electronics](https://bbqkees-electronics.nl).
EMS-ESP is a project originally created by [proddy](https://github.com/proddy) and maintained by the ems-esp community.
If you like **EMS-ESP**, please give it a ✨ on GitHub, or even better fork it and contribute. You can also offer a small donation. This is an open-source project maintained by volunteers, and your support is greatly appreciated.
## 📦&nbsp; **Building**
To build the web interface only, run `platformio run -e build_webUI`. This will install the necessary dependencies and build the web interface and also create the embedded code used need to build the firmware. You can run the web interface locally by going to the `interface` directory and running `pnpm standalone`.
To build the firmware, run `platformio run`. This will build the firmware for all ESP32 modules and place the binaries in the `build/firmware` folder. If you want to configure the build for a single platform create a local `pio_local.ni` file in the root directory (see example in `pio_local.ini_example`).
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.
## 📢&nbsp; **Libraries used**

5
SECURITY.md Normal file
View File

@@ -0,0 +1,5 @@
# Security Policy
## Reporting a Vulnerability
Please report any security vulnerabilities using the [Contact Form](https://emsesp.org/About/#-contact).

View File

@@ -5,7 +5,7 @@
},
"core": "esp32",
"extra_flags": [
"-DTASMOTA_SDK",
"-DNO_TLS_SUPPORT",
"-DARDUINO_LOLIN_C3_MINI",
"-DARDUINO_USB_MODE=1",
"-DARDUINO_USB_CDC_ON_BOOT=1"

View File

@@ -6,7 +6,7 @@
"core": "esp32",
"extra_flags": [
"-DBOARD_HAS_PSRAM",
"-DTASMOTA_SDK",
"-DNO_TLS_SUPPORT",
"-DARDUINO_USB_CDC_ON_BOOT=1",
"-DARDUINO_USB_MODE=0"
],
@@ -37,8 +37,8 @@
"flash_size": "4MB",
"maximum_ram_size": 327680,
"maximum_size": 4194304,
"use_1200bps_touch": true,
"wait_for_upload_port": true,
"use_1200bps_touch": false,
"wait_for_upload_port": false,
"require_upload_port": true,
"speed": 921600
},

View File

@@ -21,11 +21,11 @@
"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,
"maximum_size": 16777216,
"maximum_size": 33554432,
"require_upload_port": true,
"speed": 460800
},

View File

@@ -1,7 +1,7 @@
{
"build": {
"core": "esp32",
"extra_flags": "-DTASMOTA_SDK",
"extra_flags": "-DNO_TLS_SUPPORT",
"f_cpu": "240000000L",
"f_flash": "40000000L",
"flash_mode": "dio",
@@ -19,7 +19,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,

View File

@@ -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,

View File

@@ -1,7 +1,7 @@
{
"build": {
"core": "esp32",
"extra_flags": "-DTASMOTA_SDK",
"extra_flags": "-DNO_TLS_SUPPORT",
"f_cpu": "240000000L",
"f_flash": "40000000L",
"flash_mode": "dio",

View File

@@ -2,6 +2,7 @@
"build": {
"core": "esp32",
"extra_flags": [
"-DNO_TLS_SUPPORT",
"-DARDUINO_XIAO_ESP32C6",
"-DARDUINO_USB_MODE=1",
"-DARDUINO_USB_CDC_ON_BOOT=1"

View File

@@ -9,6 +9,7 @@
}
],
"dictionaries": ["project-words"],
"caseSensitive": false,
"ignorePaths": [
"node_modules",
"compile_commands.json",
@@ -34,6 +35,10 @@
"sdkconfig.*",
"managed_components/**",
"pnpm-*.yaml",
"vite.config.ts"
"vite.config.ts",
"lib/esp32-psram/**",
"test/test_api/test_api.h",
"lib_standalone/**",
"**/*.js"
]
}

View File

@@ -1,85 +1,228 @@
{
"type": "settings",
"Network": {
"ssid": "my_wifi_ssid",
"bssid": "",
"password": "my_wifi_password",
"hostname": "ems-esp"
"type": "systembackup",
"version": "3.8.2",
"date": "2026-03-29T13:28:15",
"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": false,
"host": "",
"port": 1883,
"base": "ems-esp",
"username": "",
"password": "",
"client_id": "esp32-b8ffc9ec",
"keep_alive": 60,
"clean_session": false,
"entity_format": 1,
"publish_time_boiler": 10,
"publish_time_thermostat": 10,
"publish_time_solar": 10,
"publish_time_mixer": 10,
"publish_time_water": 10,
"publish_time_other": 60,
"publish_time_sensor": 10,
"publish_time_heartbeat": 60,
"mqtt_qos": 0,
"mqtt_retain": false,
"ha_enabled": false,
"nested_format": 1,
"discovery_prefix": "homeassistant",
"discovery_type": 0,
"ha_number_mode": 0,
"publish_single": false,
"publish_single2cmd": false,
"send_response": false
},
"NTP": {
"enabled": true,
"server": "time.google.com",
"tz_label": "Europe/Amsterdam",
"tz_format": "CET-1CEST,M3.5.0,M10.5.0/3"
},
"Security": {
"jwt_secret": "ems-esp-neo",
"users": [
{
"username": "admin",
"password": "admin",
"admin": true
},
{
"username": "guest",
"password": "guest",
"admin": false
}
]
},
"Settings": {
"version": "3.8.2",
"board_profile": "E32V2_2",
"platform": "ESP32",
"locale": "en",
"tx_mode": 1,
"ems_bus_id": 11,
"syslog_enabled": false,
"syslog_level": 3,
"trace_raw": false,
"syslog_mark_interval": 0,
"syslog_host": "",
"syslog_port": 514,
"boiler_heatingoff": false,
"remote_timeout": 24,
"remote_timeout_en": false,
"shower_timer": false,
"shower_alert": false,
"shower_alert_coldshot": 10,
"shower_alert_trigger": 7,
"shower_min_duration": 180,
"rx_gpio": 4,
"tx_gpio": 5,
"dallas_gpio": 14,
"dallas_parasite": false,
"led_gpio": 32,
"hide_led": false,
"led_type": 1,
"low_clock": false,
"telnet_enabled": true,
"notoken_api": false,
"readonly_mode": false,
"analog_enabled": true,
"pbutton_gpio": 34,
"solar_maxflow": 30,
"fahrenheit": false,
"bool_format": 1,
"bool_dashboard": 1,
"enum_format": 1,
"weblog_level": 6,
"weblog_buffer": 50,
"weblog_compact": true,
"phy_type": 1,
"eth_power": 15,
"eth_phy_addr": 0,
"eth_clock_mode": 1,
"modbus_enabled": false,
"modbus_port": 502,
"modbus_max_clients": 10,
"modbus_timeout": 300,
"developer_mode": true,
"email_enabled": false,
"email_ssl": false,
"email_starttls": true,
"email_server": "smtp.example.net",
"email_port": 587,
"email_login": "",
"email_pass": "",
"email_sender": "ems-esp@example.net",
"email_recp": "",
"email_subject": "ems-esp notification"
}
},
"AP": {
"provision_mode": 2,
"ssid": "ems-esp",
"password": "ems-esp-neo",
"channel": 1,
"ssid_hidden": false,
"max_clients": 4,
"local_ip": "192.168.4.1",
"gateway_ip": "192.168.4.1",
"subnet_mask": "255.255.255.0"
{
"type": "schedule",
"Schedule": {
"schedule": []
}
},
"MQTT": {
"enableTLS": false,
"rootCA": "",
"enabled": false,
"host": "127.0.0.1",
"port": 1883,
"base": "ems-esp",
"username": "username",
"password": "password",
"client_id": "ems-esp",
"entity_format": 1,
"publish_time_boiler": 10,
"publish_time_thermostat": 10,
"publish_time_solar": 10,
"publish_time_mixer": 10,
"publish_time_water": 10,
"publish_time_other": 60,
"publish_time_sensor": 10,
"publish_time_heartbeat": 60,
"mqtt_qos": 0,
"mqtt_retain": false,
"ha_enabled": false,
"nested_format": 1,
"discovery_prefix": "homeassistant",
"discovery_type": 0,
"publish_single": false,
"publish_single2cmd": false,
"send_response": false
{
"type": "customizations",
"Customizations": {
"ts": [
{
"id": "28_1767_7B13_2502",
"name": "gateway_temperature",
"offset": 0,
"is_system": true
}
],
"as": [
{
"gpio": 39,
"name": "core_voltage",
"offset": 0,
"factor": 0.003771,
"uom": 23,
"type": 3,
"is_system": true
},
{
"gpio": 36,
"name": "supply_voltage",
"offset": 0,
"factor": 0.017,
"uom": 23,
"type": 3,
"is_system": true
},
{
"gpio": 2,
"name": "led",
"offset": 0,
"factor": 1,
"uom": 0,
"type": 6,
"is_system": true
}
],
"masked_entities": []
}
},
"NTP": {
"enabled": true,
"server": "time.google.com",
"tz_label": "Europe/Amsterdam",
"tz_format": "CET-1CEST,M3.5.0,M10.5.0/3"
{
"type": "entities",
"Entities": {
"entities": []
}
},
"Security": {
"jwt_secret": "ems-esp-neo",
"users": [
{
"username": "admin",
"password": "admin",
"admin": true
},
{
"username": "guest",
"password": "guest",
"admin": false
}
]
{
"type": "modules",
"Modules": {
"modules": []
}
},
"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
{
"type": "customSupport",
"Support": {
"html": [
"This product is installed and managed by:",
"",
"<b>Bosch Installer Example</b>",
"",
"Nefit Road 12",
"1234 AB Amsterdam",
"Phone: +31 123 456 789",
"email: support@boschinstaller.nl",
"",
"For help and questions please <a target='_blank' href='https://emsesp.org'>contact</a> your installer."
],
"img_url": "https://emsesp.org/media/images/designer.png"
}
}
}
]
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -72,11 +72,12 @@ 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,
0x010C,IPMStatusMessage,
0x011E,JunkersDisp,fetched
0x011E,IPMTempMessage,
0x012E,HPEnergy1,
0x013B,HPEnergy2,
0x0165,JunkersSet,
@@ -111,10 +112,10 @@ telegram_type_id,name,is_fetched
0x02A0,RC300Curves,
0x02A1,RC300Curves,
0x02A2,RC300Curves,
0x02A5,RC300Monitor,
0x02A6,RC300Monitor,
0x02A5,RC300Monitor,fetched
0x02A6,CRFMonitor,
0x02A7,RC300Monitor,
0x02A8,RC300Monitor,
0x02A8,CRFMonitor,
0x02A9,RC300Monitor,
0x02AA,RC300Monitor,
0x02AB,RC300Monitor,
@@ -136,10 +137,7 @@ telegram_type_id,name,is_fetched
0x02BF,RC300Set,
0x02C0,RC300Set,
0x02CC,HPPressure,fetched
0x02CD,MMPLUSConfigMessage,fetched
0x02CE,RC300Set2,
0x02D0,RC300Set2,
0x02D2,RC300Set2,
0x02CD,MMPLUSConfigMessage,
0x02D6,HPPump2,fetched
0x02D7,MMPLUSStatusMessage,
0x02E0,UBASetPoints,
@@ -164,11 +162,16 @@ telegram_type_id,name,is_fetched
0x0380,SM100CollectorConfig,fetched
0x038E,SM100Energy,fetched
0x0391,SM100Time,fetched
0x0421,RC300Set2,
0x0422,RC300Set2,
0x0423,RC300Set2,
0x0424,RC300Set2,
0x043F,CRHolidays,fetched
0x0467,HPSet,
0x0468,HPSet,
0x0469,HPSet,
0x046A,HPSet,
0x0470,RC300Summer2,
0x0471,RC300Summer2,
0x0472,RC300Summer2,
0x0473,RC300Summer2,
@@ -176,7 +179,6 @@ telegram_type_id,name,is_fetched
0x0475,RC300Summer2,
0x0476,RC300Summer2,
0x0477,RC300Summer2,
0x0478,RC300Summer2,
0x047B,HP2,
0x0484,HPSilentMode,fetched
0x0485,HpCooling,fetched
@@ -196,7 +198,7 @@ telegram_type_id,name,is_fetched
0x04A2,HpInput,fetched
0x04A5,HPFan,fetched
0x04A7,HPPowerLimit,fetched
0x04AA,HPPower2,fetched
0x04AA,HPPower,
0x04AE,HPEnergy,fetched
0x04AF,HPMeters,fetched
0x055C,VentilationSet,fetched
1 telegram_type_id name is_fetched
72 0xE6 UBAParametersPlus fetched
73 0xE9 UBAMonitorWWPlus
74 0xEA UBAParameterWWPlus fetched
75 0xEB PumpKick fetched
76 0x0101 ISM1Set fetched
77 0x0103 ISM1StatusMessage fetched
78 0x0104 ISM2StatusMessage
79 0x010C IPMStatusMessage
80 0x011E JunkersDisp IPMTempMessage fetched
81 0x012E HPEnergy1
82 0x013B HPEnergy2
83 0x0165 JunkersSet
112 0x02A0 RC300Curves
113 0x02A1 RC300Curves
114 0x02A2 RC300Curves
115 0x02A5 RC300Monitor fetched
116 0x02A6 RC300Monitor CRFMonitor
117 0x02A7 RC300Monitor
118 0x02A8 RC300Monitor CRFMonitor
119 0x02A9 RC300Monitor
120 0x02AA RC300Monitor
121 0x02AB RC300Monitor
137 0x02BF RC300Set
138 0x02C0 RC300Set
139 0x02CC HPPressure fetched
140 0x02CD MMPLUSConfigMessage fetched
0x02CE RC300Set2
0x02D0 RC300Set2
0x02D2 RC300Set2
141 0x02D6 HPPump2 fetched
142 0x02D7 MMPLUSStatusMessage
143 0x02E0 UBASetPoints
162 0x0380 SM100CollectorConfig fetched
163 0x038E SM100Energy fetched
164 0x0391 SM100Time fetched
165 0x0421 RC300Set2
166 0x0422 RC300Set2
167 0x0423 RC300Set2
168 0x0424 RC300Set2
169 0x043F CRHolidays fetched
170 0x0467 HPSet
171 0x0468 HPSet
172 0x0469 HPSet
173 0x046A HPSet
174 0x0470 RC300Summer2
175 0x0471 RC300Summer2
176 0x0472 RC300Summer2
177 0x0473 RC300Summer2
179 0x0475 RC300Summer2
180 0x0476 RC300Summer2
181 0x0477 RC300Summer2
0x0478 RC300Summer2
182 0x047B HP2
183 0x0484 HPSilentMode fetched
184 0x0485 HpCooling fetched
198 0x04A2 HpInput fetched
199 0x04A5 HPFan fetched
200 0x04A7 HPPowerLimit fetched
201 0x04AA HPPower2 HPPower fetched
202 0x04AE HPEnergy fetched
203 0x04AF HPMeters fetched
204 0x055C VentilationSet fetched

View File

@@ -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"
}

View File

@@ -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,

View File

@@ -1,6 +1,6 @@
{
"name": "EMS-ESP",
"version": "3.7.3",
"version": "3.8.2",
"description": "EMS-ESP WebUI",
"homepage": "https://emsesp.org",
"author": "proddy, emsesp.org",
@@ -12,57 +12,59 @@
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"build-hosted": "typesafe-i18n && vite build --mode hosted",
"preview-standalone": "typesafe-i18n --no-watch && vite build && concurrently -c \"auto\" \"pnpm:mock-rest\" \"vite preview\"",
"build-hosted": "typesafe-i18n --no-watch && vite build --mode hosted",
"mock-rest": "bun --watch ../mock-api/restServer.ts",
"standalone": "concurrently -c \"auto\" \"typesafe-i18n\" \"pnpm:mock-rest\" \"vite\"",
"preview-standalone": "typesafe-i18n --no-watch && vite build && concurrently -c \"auto\" \"pnpm:mock-rest\" \"vite preview\"",
"standalone": "concurrently -c \"auto\" \"typesafe-i18n\" \"pnpm:mock-rest\" \"vite dev\"",
"typesafe-i18n": "typesafe-i18n --no-watch",
"webUI": "node progmem-generator.js",
"build-webUI": "typesafe-i18n --no-watch && vite build && node progmem-generator.js",
"format": "prettier -l -w '**/*.{ts,tsx,js,css,json,md}'",
"lint": "eslint . --fix",
"standalone-devcontainer": "concurrently -c \"auto\" \"typesafe-i18n\" \"pnpm:mock-rest\" \"vite --host\""
},
"dependencies": {
"@alova/adapter-xhr": "2.2.1",
"@alova/adapter-xhr": "2.3.1",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^7.3.4",
"@mui/material": "^7.3.4",
"@mui/icons-material": "^9.0.0",
"@mui/material": "^9.0.0",
"@preact/compat": "^18.3.2",
"@table-library/react-table-library": "4.1.15",
"alova": "3.3.4",
"alova": "^3.5.1",
"async-validator": "^4.2.5",
"etag": "^1.8.1",
"formidable": "^3.5.4",
"jwt-decode": "^4.0.0",
"magic-string": "^0.30.21",
"mime-types": "^3.0.1",
"preact": "^10.27.2",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-icons": "^5.5.0",
"react-router": "^7.9.5",
"react-toastify": "^11.0.5",
"typesafe-i18n": "^5.26.2",
"typescript": "^5.9.3"
"mime-types": "^3.0.2",
"preact": "^10.29.1",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-icons": "^5.6.0",
"react-router": "^7.14.2",
"react-toastify": "^11.1.0",
"typesafe-i18n": "^5.27.1",
"typescript": "^6.0.3"
},
"devDependencies": {
"@babel/core": "^7.28.5",
"@eslint/js": "^9.39.0",
"@preact/compat": "^18.3.1",
"@preact/preset-vite": "^2.10.2",
"@trivago/prettier-plugin-sort-imports": "^5.2.2",
"@types/node": "^24.10.0",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"@babel/core": "^7.29.0",
"@eslint/js": "^10.0.1",
"@preact/compat": "^18.3.2",
"@preact/preset-vite": "^2.10.5",
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
"@types/node": "^25.6.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"axe-core": "^4.11.3",
"concurrently": "^9.2.1",
"eslint": "^9.39.0",
"eslint": "^10.2.1",
"eslint-config-prettier": "^10.1.8",
"prettier": "^3.6.2",
"rollup-plugin-visualizer": "^6.0.5",
"terser": "^5.44.0",
"typescript-eslint": "^8.46.2",
"vite": "^7.1.12",
"vite-plugin-imagemin": "^0.6.1",
"vite-tsconfig-paths": "^5.1.4"
"prettier": "^3.8.3",
"rollup-plugin-visualizer": "^7.0.1",
"terser": "^5.46.1",
"typescript-eslint": "^8.59.0",
"vite": "^8.0.9",
"vite-plugin-imagemin": "^0.6.1"
},
"packageManager": "pnpm@10.20.0"
"packageManager": "pnpm@10.33.1+sha512.05ba3c1d5d1c18f68df06470d74055e62d41fc110a0c660db1b2dfb2785327f04cf0f68345d4609bc52089e7fa0343c31593b2f9594e2c5d5da426230acc9820"
}

2648
interface/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,9 @@
import crypto from 'crypto';
import etag from 'etag';
import {
createWriteStream,
existsSync,
readFileSync,
readdirSync,
statSync,
unlinkSync
} from 'fs';
import mime from 'mime-types';
@@ -25,20 +24,26 @@ 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]);
`;
const getFilesSync = (dir, files = []) => {
@@ -71,7 +76,9 @@ const writeFile = (relativeFilePath, buffer) => {
writeStream.write(`const uint8_t ${variable}[] = {`);
const zipBuffer = zlib.gzipSync(buffer, { level: 9 });
const hash = crypto.createHash('sha256').update(zipBuffer).digest('hex');
// const hash = crypto.createHash('sha256').update(zipBuffer).digest('hex');
const hash = etag(zipBuffer); // use smaller md5 instead of sha256
const rawHash = hash.replace(/^"|"$/g, '');
zipBuffer.forEach((b) => {
if (!(size % bytesPerLine)) {
@@ -94,7 +101,8 @@ const writeFile = (relativeFilePath, buffer) => {
mimeType,
variable,
size,
hash
hash,
rawHash
});
totalSize += size;

View File

@@ -1,4 +1,4 @@
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
import { memo, useCallback, useEffect, useState } from 'react';
import { ToastContainer, Zoom } from 'react-toastify';
import AppRouting from 'AppRouting';
@@ -8,7 +8,6 @@ import type { Locales } from 'i18n/i18n-types';
import { loadLocaleAsync } from 'i18n/i18n-util.async';
import { detectLocale, navigatorDetector } from 'typesafe-i18n/detectors';
// Memoize available locales to prevent recreation on every render
const AVAILABLE_LOCALES = [
'de',
'en',
@@ -23,6 +22,26 @@ const AVAILABLE_LOCALES = [
'cz'
] as Locales[];
// Static toast configuration - no need to recreate on every render
const TOAST_CONTAINER_PROPS = {
position: 'bottom-left' as const,
autoClose: 3000,
hideProgressBar: false,
newestOnTop: false,
closeOnClick: true,
rtl: false,
pauseOnFocusLoss: true,
draggable: false,
pauseOnHover: false,
transition: Zoom,
closeButton: false,
theme: 'dark' as const,
toastStyle: {
border: '1px solid #177ac9',
width: 'fit-content'
}
};
const App = memo(() => {
const [wasLoaded, setWasLoaded] = useState(false);
const [locale, setLocale] = useState<Locales>('en');
@@ -41,36 +60,13 @@ const App = memo(() => {
void initializeLocale();
}, [initializeLocale]);
// Memoize toast container props to prevent recreation
const toastContainerProps = useMemo(
() => ({
position: 'bottom-left' as const,
autoClose: 3000,
hideProgressBar: false,
newestOnTop: false,
closeOnClick: true,
rtl: false,
pauseOnFocusLoss: true,
draggable: false,
pauseOnHover: false,
transition: Zoom,
closeButton: false,
theme: 'dark' as const,
toastStyle: {
border: '1px solid #177ac9',
width: 'fit-content'
}
}),
[]
);
if (!wasLoaded) return null;
return (
<TypesafeI18n locale={locale}>
<CustomTheme>
<AppRouting />
<ToastContainer {...toastContainerProps} />
<ToastContainer {...TOAST_CONTAINER_PROPS} />
</CustomTheme>
</TypesafeI18n>
);

View File

@@ -1,4 +1,4 @@
import { useContext, useEffect } from 'react';
import { type FC, memo, useContext, useEffect, useRef } from 'react';
import { Navigate, Route, Routes } from 'react-router';
import { toast } from 'react-toastify';
@@ -9,20 +9,32 @@ import { Authentication, AuthenticationContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react';
interface SecurityRedirectProps {
message: string;
signOut?: boolean;
readonly message: string;
readonly signOut?: boolean;
}
const RootRedirect = ({ message, signOut }: SecurityRedirectProps) => {
const authenticationContext = useContext(AuthenticationContext);
useEffect(() => {
signOut && authenticationContext.signOut(false);
toast.success(message);
}, [message, signOut, authenticationContext]);
return <Navigate to="/" />;
};
const RootRedirect: FC<SecurityRedirectProps> = memo(
({ message, signOut = false }) => {
const { signOut: contextSignOut } = useContext(AuthenticationContext);
const hasShownToast = useRef(false);
const AppRouting = () => {
useEffect(() => {
// Prevent duplicate toasts on strict mode or re-renders
if (!hasShownToast.current) {
hasShownToast.current = true;
if (signOut) {
contextSignOut(false);
}
toast.success(message);
}
// Only run once on mount - using ref to track execution
}, []);
return <Navigate to="/" replace />;
}
);
const AppRouting: FC = memo(() => {
const { LL } = useI18nContext();
return (
@@ -55,6 +67,6 @@ const AppRouting = () => {
</Routes>
</Authentication>
);
};
});
export default AppRouting;

View File

@@ -1,4 +1,4 @@
import { useContext } from 'react';
import { memo, useContext } from 'react';
import { Navigate, Route, Routes } from 'react-router';
import CustomEntities from 'app/main/CustomEntities';
@@ -9,6 +9,7 @@ import Help from 'app/main/Help';
import Modules from 'app/main/Modules';
import Scheduler from 'app/main/Scheduler';
import Sensors from 'app/main/Sensors';
import UserProfile from 'app/main/UserProfile';
import APSettings from 'app/settings/APSettings';
import ApplicationSettings from 'app/settings/ApplicationSettings';
import DownloadUpload from 'app/settings/DownloadUpload';
@@ -29,7 +30,7 @@ import Version from 'app/status/Version';
import { Layout } from 'components';
import { AuthenticatedContext } from 'contexts/authentication';
const AuthenticatedRouting = () => {
const AuthenticatedRouting = memo(() => {
const { me } = useContext(AuthenticatedContext);
return (
<Layout>
@@ -38,6 +39,7 @@ const AuthenticatedRouting = () => {
<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 />} />
@@ -72,6 +74,6 @@ const AuthenticatedRouting = () => {
</Routes>
</Layout>
);
};
});
export default AuthenticatedRouting;

View File

@@ -11,17 +11,15 @@ import { createTheme } from '@mui/material/styles';
import type { RequiredChildrenProps } from 'utils';
// Memoize dialog style to prevent recreation
export const dialogStyle = {
'& .MuiDialog-paper': {
borderRadius: '8px',
borderColor: '#565656',
borderStyle: 'solid',
borderWidth: '1px'
borderWidth: '2px'
}
} as const;
// Memoize theme creation to prevent recreation
const theme = responsiveFontSizes(
createTheme({
typography: {

View File

@@ -1,8 +1,9 @@
import { useContext, useState } from 'react';
import { memo, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { toast } from 'react-toastify';
import ForwardIcon from '@mui/icons-material/Forward';
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';
@@ -17,9 +18,9 @@ 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';
const SignIn = () => {
const SignIn = memo(() => {
const authenticationContext = useContext(AuthenticationContext);
const { LL } = useI18nContext();
@@ -36,15 +37,24 @@ const SignIn = () => {
{
immediate: false
}
).onSuccess((response) => {
).onSuccess((response: { data: { access_token: string } }) => {
if (response.data) {
authenticationContext.signIn(response.data.access_token);
}
});
const updateLoginRequestValue = updateValue(setSignInRequest);
// Memoize callback to prevent recreation on every render
const updateLoginRequestValue = useMemo(
() =>
updateValue((updater) =>
setSignInRequest(
updater as unknown as (prevState: SignInRequest) => SignInRequest
)
),
[]
);
const signIn = async () => {
const signIn = useCallback(async () => {
await callSignIn(signInRequest).catch((event: Error) => {
if (event.message === 'Unauthorized') {
toast.warning(LL.INVALID_LOGIN());
@@ -53,9 +63,9 @@ const SignIn = () => {
}
setProcessing(false);
});
};
}, [callSignIn, signInRequest, LL]);
const validateAndSignIn = async () => {
const validateAndSignIn = useCallback(async () => {
setProcessing(true);
SIGN_IN_REQUEST_VALIDATOR.messages({
required: LL.IS_REQUIRED('%s')
@@ -64,22 +74,33 @@ const SignIn = () => {
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]);
const submitOnEnter = onEnterCallback(signIn);
const submitOnEnter = useMemo(() => onEnterCallback(signIn), [signIn]);
// get rid of scrollbar
useEffect(() => {
const originalOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = originalOverflow;
};
}, []);
return (
<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) => ({
@@ -92,23 +113,29 @@ const SignIn = () => {
width: '100%'
})}
>
<Typography variant="h4">{PROJECT_NAME}</Typography>
<Typography sx={{ mb: 1 }} variant="h4">
{PROJECT_NAME}
</Typography>
<LanguageSelector />
<Box display="flex" flexDirection="column" alignItems="center">
<Box
sx={{
mt: 1,
display: 'flex',
flexDirection: 'column',
gap: 1,
alignItems: 'center'
}}
>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
disabled={processing}
sx={{
width: 240
width: '32ch'
}}
name="username"
label={LL.USERNAME(0)}
value={signInRequest.username}
onChange={updateLoginRequestValue}
margin="normal"
variant="outlined"
slotProps={{
input: {
autoCapitalize: 'none',
@@ -120,14 +147,13 @@ const SignIn = () => {
fieldErrors={fieldErrors || {}}
disabled={processing}
sx={{
width: 240
width: '32ch'
}}
name="password"
label={LL.PASSWORD()}
value={signInRequest.password}
onChange={updateLoginRequestValue}
onKeyDown={submitOnEnter}
variant="outlined"
/>
</Box>
@@ -144,6 +170,6 @@ const SignIn = () => {
</Paper>
</Box>
);
};
});
export default SignIn;

View File

@@ -20,19 +20,18 @@ import type {
WriteTemperatureSensor
} from '../app/main/types';
const MSGPACK_CONFIG = { responseType: 'arraybuffer' as const };
// Dashboard
export const readDashboard = () =>
alovaInstance.Get<DashboardData>('/rest/dashboardData', {
responseType: 'arraybuffer' // uses msgpack
});
alovaInstance.Get<DashboardData>('/rest/dashboardData', MSGPACK_CONFIG);
// Devices
export const readCoreData = () => alovaInstance.Get<CoreData>(`/rest/coreData`);
export const readCoreData = () => alovaInstance.Get<CoreData>('/rest/coreData');
export const readDeviceData = (id: number) =>
alovaInstance.Get<DeviceData>('/rest/deviceData', {
// alovaInstance.Get<DeviceData>(`/rest/deviceData/${id}`, {
params: { id },
responseType: 'arraybuffer' // uses msgpack
...MSGPACK_CONFIG
});
export const writeDeviceValue = (data: { id: number; c: string; v: unknown }) =>
alovaInstance.Post('/rest/writeDeviceValue', data);
@@ -66,13 +65,13 @@ export const callAction = (action: Action) =>
// SettingsCustomization
export const readDeviceEntities = (id: number) =>
// alovaInstance.Get<DeviceEntity[]>(`/rest/deviceEntities/${id}`, {
alovaInstance.Get<DeviceEntity[]>(`/rest/deviceEntities`, {
alovaInstance.Get<DeviceEntity[]>('/rest/deviceEntities', {
params: { id },
responseType: 'arraybuffer',
...MSGPACK_CONFIG,
// @ts-expect-error - exactOptionalPropertyTypes compatibility issue
transform(data) {
return (data as DeviceEntity[]).map((de: DeviceEntity) => ({
const entities = data as DeviceEntity[];
return entities.map((de) => ({
...de,
o_m: de.m,
o_cn: de.cn,
@@ -95,7 +94,8 @@ export const readSchedule = () =>
alovaInstance.Get<ScheduleItem[]>('/rest/schedule', {
// @ts-expect-error - exactOptionalPropertyTypes compatibility issue
transform(data) {
return (data as Schedule).schedule.map((si: ScheduleItem) => ({
const schedule = (data as Schedule).schedule;
return schedule.map((si) => ({
...si,
o_id: si.id,
o_active: si.active,
@@ -115,7 +115,8 @@ export const writeSchedule = (data: Schedule) =>
export const readModules = () =>
alovaInstance.Get<ModuleItem[]>('/rest/modules', {
transform(data) {
return (data as Modules).modules.map((mi: ModuleItem) => ({
const modules = (data as Modules).modules;
return modules.map((mi) => ({
...mi,
o_enabled: mi.enabled,
o_license: mi.license
@@ -133,7 +134,8 @@ export const readCustomEntities = () =>
alovaInstance.Get<EntityItem[]>('/rest/customEntities', {
// @ts-expect-error - exactOptionalPropertyTypes compatibility issue
transform(data) {
return (data as Entities).entities.map((ei: EntityItem) => ({
const entities = (data as Entities).entities;
return entities.map((ei) => ({
...ei,
o_id: ei.id,
o_ram: ei.ram,

View File

@@ -4,55 +4,57 @@ import ReactHook from 'alova/react';
import { unpack } from './unpack';
export const ACCESS_TOKEN = 'access_token';
export const ACCESS_TOKEN = 'access_token' as const;
// Cached token to avoid repeated localStorage access
let cachedToken: string | null = null;
const getAccessToken = (): string | null => {
if (cachedToken === null) {
cachedToken = localStorage.getItem(ACCESS_TOKEN);
}
return cachedToken;
};
// Clear token cache when needed (e.g., on logout)
export const clearTokenCache = (): void => {
cachedToken = null;
};
const handleResponse = async (response: AlovaXHRResponse) => {
// Handle various HTTP status codes
if (response.status === 205) {
throw new Error('Reboot required');
}
if (response.status === 400) {
throw new Error('Request Failed');
}
if (response.status >= 400) {
throw new Error(response.statusText);
}
const data = (await response.data) as ArrayBuffer;
// Unpack MessagePack data if ArrayBuffer
if (data instanceof ArrayBuffer) {
return unpack(data) as ArrayBuffer;
}
return data;
};
export const alovaInstance = createAlova({
statesHook: ReactHook,
// timeout: 3000, // 3 seconds before throwing a timeout error, default is 0 = none
cacheFor: null, // disable cache
// cacheFor: {
// GET: {
// mode: 'memory',
// expire: 60 * 10 * 1000 // 60 seconds in cache
// }
// },
requestAdapter: xhrRequestAdapter(),
beforeRequest(method) {
if (localStorage.getItem(ACCESS_TOKEN)) {
method.config.headers.Authorization =
'Bearer ' + localStorage.getItem(ACCESS_TOKEN);
const token = getAccessToken();
if (token) {
method.config.headers.Authorization = `Bearer ${token}`;
}
// for simulating very slow networks
// return new Promise((resolve) => {
// const random = 3000 + Math.random() * 2000;
// setTimeout(resolve, Math.floor(random));
// });
},
responded: {
onSuccess: async (response: AlovaXHRResponse) => {
// if (response.status === 202) {
// throw new Error('Wait'); // wifi scan in progress
// } else
if (response.status === 205) {
throw new Error('Reboot required');
} else if (response.status === 400) {
throw new Error('Request Failed');
} else if (response.status >= 400) {
throw new Error(response.statusText);
}
const data: ArrayBuffer = (await response.data) as ArrayBuffer;
if (response.data instanceof ArrayBuffer) {
return unpack(data) as ArrayBuffer;
}
return data;
}
// Interceptor for request failure. This interceptor will be entered when the request is wrong.
// http errors like 401 (unauthorized) are handled either in the methods or AuthenticatedRouting()
// onError: (error, method) => {
// alert(error.message);
// }
onSuccess: handleResponse
}
});

View File

@@ -2,12 +2,14 @@ import type { NetworkSettingsType, NetworkStatusType, WiFiNetworkList } from 'ty
import { alovaInstance } from './endpoints';
const LIST_NETWORKS_TIMEOUT = 20000; // 20 seconds
export const readNetworkStatus = () =>
alovaInstance.Get<NetworkStatusType>('/rest/networkStatus');
export const scanNetworks = () => alovaInstance.Get('/rest/scanNetworks');
export const listNetworks = () =>
alovaInstance.Get<WiFiNetworkList>('/rest/listNetworks', {
timeout: 20000 // 20 seconds
timeout: LIST_NETWORKS_TIMEOUT
});
export const readNetworkSettings = () =>
alovaInstance.Get<NetworkSettingsType>('/rest/networkSettings');

View File

@@ -6,7 +6,7 @@ export const readNTPStatus = () =>
alovaInstance.Get<NTPStatusType>('/rest/ntpStatus');
export const readNTPSettings = () =>
alovaInstance.Get<NTPSettingsType>('/rest/ntpSettings', {});
alovaInstance.Get<NTPSettingsType>('/rest/ntpSettings');
export const updateNTPSettings = (data: NTPSettingsType) =>
alovaInstance.Post<NTPSettingsType>('/rest/ntpSettings', data);

View File

@@ -8,7 +8,7 @@ export const readSystemStatus = () =>
// SystemLog
export const readLogSettings = () =>
alovaInstance.Get<LogSettings>(`/rest/logSettings`);
alovaInstance.Get<LogSettings>('/rest/logSettings');
export const updateLogSettings = (data: LogSettings) =>
alovaInstance.Post('/rest/logSettings', data);
export const fetchLogES = () => alovaInstance.Get('/es/log');
@@ -36,10 +36,12 @@ export const getDevVersion = () =>
}
});
const UPLOAD_TIMEOUT = 60000; // 1 minute
export const uploadFile = (file: File) => {
const formData = new FormData();
formData.append('file', file);
return alovaInstance.Post('/rest/uploadFile', formData, {
timeout: 60000 // override timeout for uploading firmware - 1 minute
timeout: UPLOAD_TIMEOUT
});
};

View File

@@ -54,7 +54,7 @@ export class Unpackr {
}
Object.assign(this, options);
}
unpack(source, options?: any) {
unpack(source, options?: { start?: number; end?: number; lazy?: boolean }) {
if (src) {
return saveState(() => {
clearSource();
@@ -184,7 +184,7 @@ export class Unpackr {
function getPosition() {
return position;
}
function checkedRead(options: any) {
function checkedRead(options?: { lazy?: boolean }) {
try {
if (!currentUnpackr.trusted && !sequentialMode) {
const sharedLength = currentStructures.sharedLength || 0;

View File

@@ -1,4 +1,4 @@
import { useCallback, useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { useBlocker } from 'react-router';
import { toast } from 'react-toastify';
@@ -35,6 +35,10 @@ import { DeviceValueTypeNames, DeviceValueUOM_s } from './types';
import type { Entities, EntityItem } from './types';
import { entityItemValidation } from './validators';
const MIN_ID = -100;
const MAX_ID = 100;
const ICON_SIZE = 12;
const CustomEntities = () => {
const { LL } = useI18nContext();
const [numChanges, setNumChanges] = useState<number>(0);
@@ -53,18 +57,20 @@ const CustomEntities = () => {
initialData: []
});
useInterval(() => {
const intervalCallback = useCallback(() => {
if (!dialogOpen && !numChanges) {
void fetchEntities();
}
});
}, [dialogOpen, numChanges, fetchEntities]);
useInterval(intervalCallback);
const { send: writeEntities } = useRequest(
(data: Entities) => writeCustomEntities(data),
{ immediate: false }
);
function hasEntityChanged(ei: EntityItem) {
const hasEntityChanged = useCallback((ei: EntityItem) => {
return (
ei.id !== ei.o_id ||
ei.ram !== ei.o_ram ||
@@ -80,19 +86,21 @@ const CustomEntities = () => {
ei.deleted !== ei.o_deleted ||
(ei.value || '') !== (ei.o_value || '')
);
}
}, []);
const entity_theme = useTheme({
Table: `
const entity_theme = useMemo(
() =>
useTheme({
Table: `
--data-table-library_grid-template-columns: repeat(1, minmax(60px, 1fr)) minmax(80px, auto) 80px 80px 80px 120px;
`,
BaseRow: `
BaseRow: `
font-size: 14px;
.td {
height: 32px;
}
`,
BaseCell: `
BaseCell: `
&:nth-of-type(1) {
padding: 8px;
}
@@ -112,7 +120,7 @@ const CustomEntities = () => {
text-align: center;
}
`,
HeaderRow: `
HeaderRow: `
text-transform: uppercase;
background-color: black;
color: #90CAF9;
@@ -121,7 +129,7 @@ const CustomEntities = () => {
height: 36px;
}
`,
Row: `
Row: `
background-color: #1e1e1e;
position: relative;
cursor: pointer;
@@ -132,9 +140,11 @@ const CustomEntities = () => {
background-color: #177ac9;
}
`
});
}),
[]
);
const saveEntities = async () => {
const saveEntities = useCallback(async () => {
await writeEntities({
entities: entities
.filter((ei: EntityItem) => !ei.deleted)
@@ -163,7 +173,7 @@ const CustomEntities = () => {
await fetchEntities();
setNumChanges(0);
});
};
}, [entities, writeEntities, LL, fetchEntities]);
const editEntityItem = useCallback((ei: EntityItem) => {
setCreating(false);
@@ -171,36 +181,39 @@ const CustomEntities = () => {
setDialogOpen(true);
}, []);
const onDialogClose = () => {
const onDialogClose = useCallback(() => {
setDialogOpen(false);
};
}, []);
const onDialogCancel = async () => {
const onDialogCancel = useCallback(async () => {
await fetchEntities().then(() => {
setNumChanges(0);
});
};
}, [fetchEntities]);
const onDialogSave = (updatedItem: EntityItem) => {
setDialogOpen(false);
void updateState(readCustomEntities(), (data: EntityItem[]) => {
const new_data = creating
? [
...data.filter((ei) => creating || ei.o_id !== updatedItem.o_id),
updatedItem
]
: data.map((ei) =>
ei.id === updatedItem.id ? { ...ei, ...updatedItem } : ei
);
setNumChanges(new_data.filter((ei) => hasEntityChanged(ei)).length);
return new_data;
});
};
const onDialogSave = useCallback(
(updatedItem: EntityItem) => {
setDialogOpen(false);
void updateState(readCustomEntities(), (data: EntityItem[]) => {
const new_data = creating
? [
...data.filter((ei) => creating || ei.o_id !== updatedItem.o_id),
updatedItem
]
: data.map((ei) =>
ei.id === updatedItem.id ? { ...ei, ...updatedItem } : ei
);
setNumChanges(new_data.filter((ei) => hasEntityChanged(ei)).length);
return new_data;
});
},
[creating, hasEntityChanged]
);
const onDialogDup = (item: EntityItem) => {
const onDialogDup = useCallback((item: EntityItem) => {
setCreating(true);
setSelectedEntityItem({
id: Math.floor(Math.random() * (Math.floor(200) - 100) + 100),
id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID),
name: item.name + '_',
ram: item.ram,
device_id: item.device_id,
@@ -215,12 +228,12 @@ const CustomEntities = () => {
value: item.value
});
setDialogOpen(true);
};
}, []);
const addEntityItem = () => {
const addEntityItem = useCallback(() => {
setCreating(true);
setSelectedEntityItem({
id: Math.floor(Math.random() * (Math.floor(200) - 100) + 100),
id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID),
name: '',
ram: 0,
device_id: '0',
@@ -235,22 +248,30 @@ const CustomEntities = () => {
value: ''
});
setDialogOpen(true);
};
}, []);
function formatValue(value: unknown, uom: number) {
const formatValue = useCallback((value: unknown, uom: number) => {
return value === undefined
? ''
: typeof value === 'number'
? new Intl.NumberFormat().format(value) +
(uom === 0 ? '' : ' ' + DeviceValueUOM_s[uom])
: (value as string) + (uom === 0 ? '' : ' ' + DeviceValueUOM_s[uom]);
}
(uom === 0 ? '' : ` ${DeviceValueUOM_s[uom]}`)
: `${value as string}${uom === 0 ? '' : ` ${DeviceValueUOM_s[uom]}`}`;
}, []);
function showHex(value: number, digit: number) {
return '0x' + value.toString(16).toUpperCase().padStart(digit, '0');
}
const showHex = useCallback((value: number, digit: number) => {
return `0x${value.toString(16).toUpperCase().padStart(digit, '0')}`;
}, []);
const renderEntity = () => {
const filteredAndSortedEntities = useMemo(
() =>
entities
?.filter((ei: EntityItem) => !ei.deleted)
.sort((a: EntityItem, b: EntityItem) => a.name.localeCompare(b.name)) ?? [],
[entities]
);
const renderEntity = useCallback(() => {
if (!entities) {
return (
<FormLoader onRetry={fetchEntities} errorMessage={error?.message || ''} />
@@ -260,9 +281,7 @@ const CustomEntities = () => {
return (
<Table
data={{
nodes: entities
.filter((ei: EntityItem) => !ei.deleted)
.sort((a: EntityItem, b: EntityItem) => a.name.localeCompare(b.name))
nodes: filteredAndSortedEntities
}}
theme={entity_theme}
layout={{ custom: true }}
@@ -285,16 +304,21 @@ const CustomEntities = () => {
<Cell>
{ei.name}&nbsp;
{ei.writeable && (
<EditOutlinedIcon color="primary" sx={{ fontSize: 12 }} />
<EditOutlinedIcon
color="primary"
sx={{ fontSize: ICON_SIZE }}
/>
)}
</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>
@@ -304,14 +328,24 @@ 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()}
@@ -327,8 +361,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
@@ -350,7 +384,7 @@ const CustomEntities = () => {
</ButtonRow>
)}
</Box>
<Box flexWrap="nowrap" whiteSpace="nowrap">
<Box sx={{ flexWrap: 'nowrap', whiteSpace: 'nowrap' }}>
<Button
startIcon={<AddIcon />}
variant="outlined"

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import AddIcon from '@mui/icons-material/Add';
import CancelIcon from '@mui/icons-material/Cancel';
@@ -7,7 +7,7 @@ import DoneIcon from '@mui/icons-material/Done';
import EditOffOutlinedIcon from '@mui/icons-material/EditOffOutlined';
import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
import InsertCommentOutlinedIcon from '@mui/icons-material/InsertCommentOutlined';
import RemoveIcon from '@mui/icons-material/RemoveCircleOutline';
import RemoveIcon from '@mui/icons-material/RemoveCircleOutlined';
import {
Box,
Button,
@@ -28,11 +28,24 @@ 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 { DeviceValueType, DeviceValueTypeNames, DeviceValueUOM_s } from './types';
import type { EntityItem } from './types';
// Constant value type options for the dropdown
const VALUE_TYPE_OPTIONS = [
DeviceValueType.BOOL,
DeviceValueType.INT8,
DeviceValueType.UINT8,
DeviceValueType.INT16,
DeviceValueType.UINT16,
DeviceValueType.UINT24,
DeviceValueType.TIME,
DeviceValueType.UINT32,
DeviceValueType.STRING
] as const;
interface CustomEntitiesDialogProps {
open: boolean;
creating: boolean;
@@ -55,64 +68,97 @@ const CustomEntitiesDialog = ({
const { LL } = useI18nContext();
const [editItem, setEditItem] = useState<EntityItem>(selectedItem);
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const updateFormValue = updateValue(setEditItem);
const updateFormValue = useMemo(
() =>
updateValue(
setEditItem as unknown as React.Dispatch<
React.SetStateAction<Record<string, unknown>>
>
),
[]
);
useEffect(() => {
if (open) {
setFieldErrors(undefined);
setEditItem(selectedItem);
// convert to hex strings straight away
// Convert to hex strings - combined into single setEditItem call
const deviceIdHex =
typeof selectedItem.device_id === 'number'
? selectedItem.device_id.toString(16).toUpperCase()
: selectedItem.device_id;
const typeIdHex =
typeof selectedItem.type_id === 'number'
? selectedItem.type_id.toString(16).toUpperCase()
: selectedItem.type_id;
const factorValue =
selectedItem.value_type === DeviceValueType.BOOL &&
typeof selectedItem.factor === 'number'
? selectedItem.factor.toString(16).toUpperCase()
: selectedItem.factor;
setEditItem({
...selectedItem,
device_id: selectedItem.device_id.toString(16).toUpperCase(),
type_id: selectedItem.type_id.toString(16).toUpperCase(),
factor:
selectedItem.value_type === DeviceValueType.BOOL
? selectedItem.factor.toString(16).toUpperCase()
: selectedItem.factor
device_id: deviceIdHex,
type_id: typeIdHex,
factor: factorValue
});
}
}, [open, selectedItem]);
const handleClose = (
_event: React.SyntheticEvent,
reason: 'backdropClick' | 'escapeKeyDown'
) => {
if (reason !== 'backdropClick') {
onClose();
}
};
const handleClose = useCallback(
(_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => {
if (reason !== 'backdropClick') {
onClose();
}
},
[onClose]
);
const save = async () => {
const save = useCallback(async () => {
try {
setFieldErrors(undefined);
await validate(validator, editItem);
if (typeof editItem.device_id === 'string') {
editItem.device_id = parseInt(editItem.device_id, 16);
// Create a copy to avoid mutating the state directly
const processedItem: EntityItem = { ...editItem };
if (typeof processedItem.device_id === 'string') {
processedItem.device_id = Number.parseInt(processedItem.device_id, 16);
}
if (typeof editItem.type_id === 'string') {
editItem.type_id = parseInt(editItem.type_id, 16);
if (typeof processedItem.type_id === 'string') {
processedItem.type_id = Number.parseInt(processedItem.type_id, 16);
}
if (
editItem.value_type === DeviceValueType.BOOL &&
typeof editItem.factor === 'string'
processedItem.value_type === DeviceValueType.BOOL &&
typeof processedItem.factor === 'string'
) {
editItem.factor = parseInt(editItem.factor, 16);
processedItem.factor = Number.parseInt(processedItem.factor, 16);
}
onSave(editItem);
onSave(processedItem);
} catch (error) {
setFieldErrors(error as ValidateFieldsError);
setFieldErrors((error as ValidationError).fieldErrors);
}
};
}, [validator, editItem, onSave]);
const remove = () => {
editItem.deleted = true;
onSave(editItem);
};
const remove = useCallback(() => {
const itemWithDeleted = { ...editItem, deleted: true };
onSave(itemWithDeleted);
}, [editItem, onSave]);
const dup = () => {
const dup = useCallback(() => {
onDup(editItem);
};
}, [editItem, onDup]);
// Memoize UOM menu items to avoid recreating on every render
const uomMenuItems = useMemo(
() =>
DeviceValueUOM_s.map((val, i) => (
<MenuItem key={val} value={i}>
{val}
</MenuItem>
)),
[]
);
return (
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
@@ -120,9 +166,6 @@ const CustomEntitiesDialog = ({
{creating ? LL.ADD(1) + ' ' + LL.NEW(1) : LL.EDIT()}&nbsp;{LL.ENTITY()}
</DialogTitle>
<DialogContent dividers>
<Box display="flex" flexWrap="wrap" mb={1}>
<Box flexWrap="nowrap" whiteSpace="nowrap" />
</Box>
<Grid container spacing={2} rowSpacing={0}>
<Grid size={12}>
<ValidatedTextField
@@ -135,7 +178,7 @@ const CustomEntitiesDialog = ({
onChange={updateFormValue}
/>
</Grid>
<Grid mt={3}>
<Grid sx={{ mt: 3 }}>
<BlockFormControlLabel
control={
<Checkbox
@@ -162,16 +205,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
@@ -187,18 +231,14 @@ const CustomEntitiesDialog = ({
onChange={updateFormValue}
select
>
{DeviceValueUOM_s.map((val, i) => (
<MenuItem key={val} value={i}>
{val}
</MenuItem>
))}
{uomMenuItems}
</TextField>
</Grid>
</>
)}
{editItem.ram === 0 && (
<>
<Grid mt={3}>
<Grid sx={{ mt: 3 }}>
<BlockFormControlLabel
control={
<Checkbox
@@ -220,7 +260,7 @@ const CustomEntitiesDialog = ({
margin="normal"
sx={{ width: '11ch' }}
type="string"
value={editItem.device_id as string}
value={editItem.device_id}
onChange={updateFormValue}
slotProps={{
input: {
@@ -240,7 +280,7 @@ const CustomEntitiesDialog = ({
margin="normal"
sx={{ width: '11ch' }}
type="string"
value={editItem.type_id as string}
value={editItem.type_id}
onChange={updateFormValue}
slotProps={{
input: {
@@ -275,33 +315,11 @@ const CustomEntitiesDialog = ({
margin="normal"
select
>
<MenuItem value={DeviceValueType.BOOL}>
{DeviceValueTypeNames[DeviceValueType.BOOL]}
</MenuItem>
<MenuItem value={DeviceValueType.INT8}>
{DeviceValueTypeNames[DeviceValueType.INT8]}
</MenuItem>
<MenuItem value={DeviceValueType.UINT8}>
{DeviceValueTypeNames[DeviceValueType.UINT8]}
</MenuItem>
<MenuItem value={DeviceValueType.INT16}>
{DeviceValueTypeNames[DeviceValueType.INT16]}
</MenuItem>
<MenuItem value={DeviceValueType.UINT16}>
{DeviceValueTypeNames[DeviceValueType.UINT16]}
</MenuItem>
<MenuItem value={DeviceValueType.UINT24}>
{DeviceValueTypeNames[DeviceValueType.UINT24]}
</MenuItem>
<MenuItem value={DeviceValueType.TIME}>
{DeviceValueTypeNames[DeviceValueType.TIME]}
</MenuItem>
<MenuItem value={DeviceValueType.UINT32}>
{DeviceValueTypeNames[DeviceValueType.UINT32]}
</MenuItem>
<MenuItem value={DeviceValueType.STRING}>
{DeviceValueTypeNames[DeviceValueType.STRING]}
</MenuItem>
{VALUE_TYPE_OPTIONS.map((valueType) => (
<MenuItem key={valueType} value={valueType}>
{DeviceValueTypeNames[valueType]}
</MenuItem>
))}
</TextField>
</Grid>
@@ -333,11 +351,7 @@ const CustomEntitiesDialog = ({
onChange={updateFormValue}
select
>
{DeviceValueUOM_s.map((val, i) => (
<MenuItem key={val} value={i}>
{val}
</MenuItem>
))}
{uomMenuItems}
</TextField>
</Grid>
</>
@@ -367,7 +381,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}
@@ -390,7 +404,7 @@ const CustomEntitiesDialog = ({
</DialogContent>
<DialogActions>
{!creating && (
<Box flexGrow={1}>
<Box sx={{ flexGrow: 1 }}>
<Button
startIcon={<RemoveIcon />}
variant="outlined"

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useBlocker, useLocation } from 'react-router';
import { toast } from 'react-toastify';
@@ -62,7 +62,24 @@ import OptionIcon from './OptionIcon';
import { DeviceEntityMask } from './types';
import type { APIcall, Device, DeviceEntity } from './types';
export const APIURL = window.location.origin + '/api/';
export const APIURL = `${window.location.origin}/api/`;
const MAX_BUFFER_SIZE = 2000;
// Helper function to create masked entity ID - extracted to avoid duplication
const createMaskedEntityId = (de: DeviceEntity): string => {
const maskHex = de.m.toString(16).padStart(2, '0');
const hasCustomizations = !!(de.cn || de.mi || de.ma);
const customizations = [
de.cn || '',
de.mi ? `>${de.mi}` : '',
de.ma ? `<${de.ma}` : ''
]
.filter(Boolean)
.join('');
return `${maskHex}${de.id}${hasCustomizations ? `|${customizations}` : ''}`;
};
const Customizations = () => {
const { LL } = useI18nContext();
@@ -94,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
}
@@ -153,17 +171,19 @@ const Customizations = () => {
);
};
const entities_theme = useTheme({
Table: `
const entities_theme = useMemo(
() =>
useTheme({
Table: `
--data-table-library_grid-template-columns: 156px repeat(1, minmax(80px, 1fr)) 45px minmax(45px, auto) minmax(120px, auto);
`,
BaseRow: `
BaseRow: `
font-size: 14px;
.td {
height: 32px;
}
`,
BaseCell: `
BaseCell: `
&:nth-of-type(3) {
text-align: right;
}
@@ -174,7 +194,7 @@ const Customizations = () => {
text-align: right;
}
`,
HeaderRow: `
HeaderRow: `
text-transform: uppercase;
background-color: black;
color: #90CAF9;
@@ -186,7 +206,7 @@ const Customizations = () => {
text-align: center;
}
`,
Row: `
Row: `
background-color: #1e1e1e;
position: relative;
cursor: pointer;
@@ -202,7 +222,7 @@ const Customizations = () => {
background-color: #177ac9;
}
`,
Cell: `
Cell: `
&:nth-of-type(2) {
padding: 8px;
}
@@ -216,7 +236,9 @@ const Customizations = () => {
padding-right: 8px;
}
`
});
}),
[]
);
function hasEntityChanged(de: DeviceEntity) {
return (
@@ -229,19 +251,8 @@ const Customizations = () => {
useEffect(() => {
if (deviceEntities.length) {
setNumChanges(
deviceEntities
.filter((de) => hasEntityChanged(de))
.map(
(new_de) =>
new_de.m.toString(16).padStart(2, '0') +
new_de.id +
(new_de.cn || new_de.mi || new_de.ma ? '|' : '') +
(new_de.cn ? new_de.cn : '') +
(new_de.mi ? '>' + new_de.mi : '') +
(new_de.ma ? '<' + new_de.ma : '')
).length
);
const changedEntities = deviceEntities.filter((de) => hasEntityChanged(de));
setNumChanges(changedEntities.length);
}
}, [deviceEntities]);
@@ -257,6 +268,7 @@ const Customizations = () => {
if (device) {
setSelectedDeviceTypeNameURL(device.url || '');
setSelectedDeviceName(device.n);
setSelectedDeviceBrand(device.b);
}
setNumChanges(0);
setRestartNeeded(false);
@@ -275,18 +287,26 @@ const Customizations = () => {
return value as string;
}
const formatName = (de: DeviceEntity, withShortname: boolean) =>
(de.n && de.n[0] === '!'
? de.t
? LL.COMMAND(1) + ': ' + de.t + ' ' + de.n.slice(1)
: LL.COMMAND(1) + ': ' + de.n.slice(1)
: de.cn && de.cn !== ''
? de.t
? de.t + ' ' + de.cn
: de.cn
: de.t
? de.t + ' ' + de.n
: de.n) + (withShortname ? ' ' + de.id : '');
const isCommand = useCallback((de: DeviceEntity) => {
return de.n && de.n[0] === '!';
}, []);
const formatName = useCallback(
(de: DeviceEntity, withShortname: boolean) => {
let name: string;
if (isCommand(de)) {
name = de.t
? `${LL.COMMAND(1)}: ${de.t} ${de.n?.slice(1)}`
: `${LL.COMMAND(1)}: ${de.n?.slice(1)}`;
} else if (de.cn && de.cn !== '') {
name = de.t ? `${de.t} ${de.cn}` : de.cn;
} else {
name = de.t ? `${de.t} ${de.n}` : de.n || '';
}
return withShortname ? `${name} ${de.id}` : name;
},
[LL]
);
const getMaskNumber = (newMask: string[]) => {
let new_mask = 0;
@@ -316,34 +336,33 @@ const Customizations = () => {
return new_masks;
};
const filter_entity = (de: DeviceEntity) =>
(de.m & selectedFilters || !selectedFilters) &&
formatName(de, true).toLowerCase().includes(search.toLowerCase());
const filter_entity = useCallback(
(de: DeviceEntity) =>
(de.m & selectedFilters || !selectedFilters) &&
formatName(de, true).toLowerCase().includes(search.toLowerCase()),
[selectedFilters, search, formatName]
);
const maskDisabled = (set: boolean) => {
setDeviceEntities(
deviceEntities.map(function (de) {
if (filter_entity(de)) {
return {
...de,
m: set
? de.m |
(DeviceEntityMask.DV_API_MQTT_EXCLUDE |
DeviceEntityMask.DV_WEB_EXCLUDE)
: de.m &
~(
DeviceEntityMask.DV_API_MQTT_EXCLUDE |
DeviceEntityMask.DV_WEB_EXCLUDE
)
};
} else {
const maskDisabled = useCallback(
(set: boolean) => {
setDeviceEntities((prev) =>
prev.map((de) => {
if (filter_entity(de)) {
const excludeMask =
DeviceEntityMask.DV_API_MQTT_EXCLUDE | DeviceEntityMask.DV_WEB_EXCLUDE;
return {
...de,
m: set ? de.m | excludeMask : de.m & ~excludeMask
};
}
return de;
}
})
);
};
})
);
},
[filter_entity]
);
const resetCustomization = async () => {
const resetCustomization = useCallback(async () => {
try {
await sendResetCustomizations();
toast.info(LL.CUSTOMIZATIONS_RESTART());
@@ -351,25 +370,30 @@ const Customizations = () => {
toast.error((error as Error).message);
} finally {
setConfirmReset(false);
setRestarting(true);
}
};
}, [sendResetCustomizations, LL]);
const onDialogClose = () => {
setDialogOpen(false);
};
const updateDeviceEntity = (updatedItem: DeviceEntity) => {
const updateDeviceEntity = useCallback((updatedItem: DeviceEntity) => {
setDeviceEntities(
deviceEntities?.map((de) =>
de.id === updatedItem.id ? { ...de, ...updatedItem } : de
)
(prev) =>
prev?.map((de) =>
de.id === updatedItem.id ? { ...de, ...updatedItem } : de
) ?? []
);
};
}, []);
const onDialogSave = (updatedItem: DeviceEntity) => {
setDialogOpen(false);
updateDeviceEntity(updatedItem);
};
const onDialogSave = useCallback(
(updatedItem: DeviceEntity) => {
setDialogOpen(false);
updateDeviceEntity(updatedItem);
},
[updateDeviceEntity]
);
const editDeviceEntity = useCallback((de: DeviceEntity) => {
if (de.n === undefined || (de.n && de.n[0] === '!')) {
@@ -384,77 +408,93 @@ const Customizations = () => {
setDialogOpen(true);
}, []);
const saveCustomization = async () => {
if (devices && deviceEntities && selectedDevice !== -1) {
const masked_entities = deviceEntities
.filter((de: DeviceEntity) => hasEntityChanged(de))
.map(
(new_de) =>
new_de.m.toString(16).padStart(2, '0') +
new_de.id +
(new_de.cn || new_de.mi || new_de.ma ? '|' : '') +
(new_de.cn ? new_de.cn : '') +
(new_de.mi ? '>' + new_de.mi : '') +
(new_de.ma ? '<' + new_de.ma : '')
);
// check size in bytes to match buffer in CPP, which is 2048
const bytes = new TextEncoder().encode(JSON.stringify(masked_entities)).length;
if (bytes > 2000) {
toast.warning(LL.CUSTOMIZATIONS_FULL());
return;
}
await sendCustomizationEntities({
id: selectedDevice,
entity_ids: masked_entities
})
.then(() => {
toast.success(LL.CUSTOMIZATIONS_SAVED());
})
.catch((error: Error) => {
if (error.message === 'Reboot required') {
setRestartNeeded(true);
} else {
toast.error(error.message);
}
})
.finally(() => {
setOriginalSettings(deviceEntities);
});
const saveCustomization = useCallback(async () => {
if (!devices || !deviceEntities || selectedDevice === -1) {
return;
}
};
const renameDevice = async () => {
await sendDeviceName({ id: selectedDevice, name: selectedDeviceName })
const masked_entities = deviceEntities
.filter((de: DeviceEntity) => hasEntityChanged(de))
.map((new_de) => createMaskedEntityId(new_de));
// check size in bytes to match buffer in CPP, which is 2048
const bytes = new TextEncoder().encode(JSON.stringify(masked_entities)).length;
if (bytes > MAX_BUFFER_SIZE) {
toast.warning(LL.CUSTOMIZATIONS_FULL());
return;
}
await sendCustomizationEntities({
id: selectedDevice,
entity_ids: masked_entities
})
.then(() => {
toast.success(LL.CUSTOMIZATIONS_SAVED());
})
.catch((error: Error) => {
if (error.message === 'Reboot required') {
setRestartNeeded(true);
} else {
toast.error(error.message);
}
})
.finally(() => {
setOriginalSettings(deviceEntities);
});
}, [devices, deviceEntities, selectedDevice, sendCustomizationEntities, LL]);
const renameDevice = useCallback(async () => {
await sendDeviceName({
id: selectedDevice,
name: selectedDeviceName,
brand: selectedDeviceBrand
})
.then(() => {
toast.success(LL.UPDATED_OF(LL.NAME(1)));
})
.catch(() => {
toast.error(LL.UPDATE_OF(LL.NAME(1)) + ' ' + LL.FAILED(1));
toast.error(`${LL.UPDATE_OF(LL.NAME(1))} ${LL.FAILED(1)}`);
})
.finally(async () => {
setRename(false);
await fetchCoreData();
});
};
}, [
selectedDevice,
selectedDeviceName,
selectedDeviceBrand,
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"
@@ -500,50 +540,59 @@ const Customizations = () => {
</Button>
</>
) : (
<Button
startIcon={<EditIcon />}
variant="outlined"
onClick={() => setRename(true)}
>
{LL.RENAME()}
</Button>
<>
<Button
startIcon={<EditIcon />}
variant="outlined"
onClick={() => setRename(true)}
>
{LL.RENAME()}
</Button>
<Button
startIcon={<SettingsBackupRestoreIcon />}
variant="outlined"
color="error"
onClick={() => setConfirmReset(true)}
>
{LL.REMOVE_ALL()}
</Button>
</>
))}
</Box>
</>
);
const renderDeviceData = () => {
const shown_data = deviceEntities.filter((de) => filter_entity(de));
const filteredEntities = useMemo(
() => deviceEntities.filter((de) => filter_entity(de)),
[deviceEntities, filter_entity]
);
const renderDeviceData = () => {
return (
<>
<Box color="warning.main">
<Typography variant="body2" mt={1}>
<OptionIcon type="favorite" isSet={true} />={LL.CUSTOMIZATIONS_HELP_2()}
&nbsp;&nbsp;
<OptionIcon type="readonly" isSet={true} />={LL.CUSTOMIZATIONS_HELP_3()}
&nbsp;&nbsp;
<OptionIcon type="api_mqtt_exclude" isSet={true} />=
{LL.CUSTOMIZATIONS_HELP_4()}&nbsp;&nbsp;
<OptionIcon type="web_exclude" isSet={true} />=
{LL.CUSTOMIZATIONS_HELP_5()}&nbsp;&nbsp;
<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()}
&nbsp;&nbsp;
<OptionIcon type="readonly" isSet={true} />={LL.CUSTOMIZATIONS_HELP_3()}
&nbsp;&nbsp;
<OptionIcon type="api_mqtt_exclude" isSet={true} />=
{LL.CUSTOMIZATIONS_HELP_4()}&nbsp;&nbsp;
<OptionIcon type="web_exclude" isSet={true} />={LL.CUSTOMIZATIONS_HELP_5()}
&nbsp;&nbsp;
<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
size="small"
variant="outlined"
placeholder={LL.SEARCH()}
aria-label={LL.SEARCH()}
onChange={(event) => {
setSearch(event.target.value);
}}
@@ -612,13 +661,13 @@ const Customizations = () => {
</Grid>
<Grid>
<Typography variant="subtitle2" color="grey">
{LL.SHOWING()}&nbsp;{shown_data.length}/{deviceEntities.length}
{LL.SHOWING()}&nbsp;{filteredEntities.length}/{deviceEntities.length}
&nbsp;{LL.ENTITIES(deviceEntities.length)}
</Typography>
</Grid>
</Grid>
<Table
data={{ nodes: shown_data }}
data={{ nodes: filteredEntities }}
theme={entities_theme}
layout={{ custom: true }}
>
@@ -640,14 +689,27 @@ const Customizations = () => {
<EntityMaskToggle onUpdate={updateDeviceEntity} de={de} />
</Cell>
<Cell>
{formatName(de, false)}&nbsp;(
<Link
target="_blank"
href={APIURL + selectedDeviceTypeNameURL + '/' + de.id}
<span
style={{
color:
de.v === undefined && !isCommand(de) ? 'grey' : 'inherit'
}}
>
{de.id}
</Link>
)
{formatName(de, false)}&nbsp;(
<Link
style={{
color:
de.v === undefined && !isCommand(de)
? 'grey'
: 'primary'
}}
target="_blank"
href={APIURL + selectedDeviceTypeNameURL + '/' + de.id}
>
{de.id}
</Link>
)
</span>
</Cell>
<Cell>
{!(de.m & DeviceEntityMask.DV_READONLY) && formatValue(de.mi)}
@@ -672,7 +734,7 @@ const Customizations = () => {
open={confirmReset}
onClose={() => setConfirmReset(false)}
>
<DialogTitle>{LL.RESET(1)}</DialogTitle>
<DialogTitle>{LL.REMOVE_ALL()}</DialogTitle>
<DialogContent dividers>{LL.CUSTOMIZATIONS_RESET()}</DialogContent>
<DialogActions>
<Button
@@ -689,7 +751,7 @@ const Customizations = () => {
onClick={resetCustomization}
color="error"
>
{LL.RESET(0)}
{LL.REMOVE_ALL()}
</Button>
</DialogActions>
</Dialog>
@@ -700,8 +762,9 @@ const Customizations = () => {
{devices && renderDeviceList()}
{selectedDevice !== -1 && !rename && renderDeviceData()}
{restartNeeded ? (
<MessageBox my={2} level="warning" message={LL.RESTART_TEXT(0)}>
<MessageBox level="warning" message={LL.RESTART_TEXT(0)}>
<Button
sx={{ ml: 2 }}
startIcon={<PowerSettingsNewIcon />}
variant="contained"
color="error"
@@ -711,15 +774,19 @@ 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
startIcon={<CancelIcon />}
variant="outlined"
color="secondary"
onClick={() => devices && sendDeviceEntities(selectedDevice)}
onClick={() => {
if (devices) {
void sendDeviceEntities(selectedDevice);
}
}}
>
{LL.CANCEL()}
</Button>
@@ -734,28 +801,18 @@ const Customizations = () => {
</ButtonRow>
)}
</Box>
{!rename && (
<ButtonRow mt={1}>
<Button
startIcon={<SettingsBackupRestoreIcon />}
variant="outlined"
color="error"
onClick={() => setConfirmReset(true)}
>
{LL.RESET(0)}
</Button>
</ButtonRow>
)}
</Box>
)}
{renderResetDialog()}
</>
);
return (
return restarting ? (
<SystemMonitor />
) : (
<SectionContent>
{blocker ? <BlockNavigation blocker={blocker} /> : null}
{restarting ? <SystemMonitor /> : renderContent()}
{renderContent()}
{selectedDeviceEntity && (
<SettingsCustomizationsDialog
open={dialogOpen}

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel';
import CloseIcon from '@mui/icons-material/Close';
@@ -30,6 +30,23 @@ interface SettingsCustomizationsDialogProps {
selectedItem: DeviceEntity;
}
interface LabelValueProps {
label: string;
value: React.ReactNode;
}
const LabelValue = memo(({ label, value }: LabelValueProps) => (
<Grid container direction="row">
<Typography variant="body2" color="warning">
{label}:&nbsp;
</Typography>
<Typography variant="body2">{value}</Typography>
</Grid>
));
LabelValue.displayName = 'LabelValue';
const ICON_SIZE = 16;
const CustomizationsDialog = ({
open,
onClose,
@@ -40,12 +57,23 @@ const CustomizationsDialog = ({
const [editItem, setEditItem] = useState<DeviceEntity>(selectedItem);
const [error, setError] = useState<boolean>(false);
const updateFormValue = updateValue(setEditItem);
const updateFormValue = useMemo(
() =>
updateValue(
setEditItem as unknown as React.Dispatch<
React.SetStateAction<Record<string, unknown>>
>
),
[]
);
const isWriteableNumber =
typeof editItem.v === 'number' &&
editItem.w &&
!(editItem.m & DeviceEntityMask.DV_READONLY);
const isWriteableNumber = useMemo(
() =>
typeof editItem.v === 'number' &&
editItem.w &&
!(editItem.m & DeviceEntityMask.DV_READONLY),
[editItem.v, editItem.w, editItem.m]
);
useEffect(() => {
if (open) {
@@ -54,66 +82,59 @@ const CustomizationsDialog = ({
}
}, [open, selectedItem]);
const handleClose = (
_event: React.SyntheticEvent,
reason: 'backdropClick' | 'escapeKeyDown'
) => {
if (reason !== 'backdropClick') {
onClose();
}
};
const handleClose = useCallback(
(_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => {
if (reason !== 'backdropClick') {
onClose();
}
},
[onClose]
);
const save = () => {
const save = useCallback(() => {
if (
isWriteableNumber &&
editItem.mi &&
editItem.ma &&
editItem.mi > editItem?.ma
editItem.mi > editItem.ma
) {
setError(true);
} else {
onSave(editItem);
}
};
}, [isWriteableNumber, editItem, onSave]);
const updateDeviceEntity = (updatedItem: DeviceEntity) => {
setEditItem({ ...editItem, m: updatedItem.m });
};
const updateDeviceEntity = useCallback((updatedItem: DeviceEntity) => {
setEditItem((prev) => ({ ...prev, m: updatedItem.m }));
}, []);
const dialogTitle = useMemo(() => `${LL.EDIT()} ${LL.ENTITY()}`, [LL]);
const writeableIcon = useMemo(
() =>
editItem.w ? (
<DoneIcon color="success" sx={{ fontSize: ICON_SIZE }} />
) : (
<CloseIcon color="error" sx={{ fontSize: ICON_SIZE }} />
),
[editItem.w]
);
return (
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
<DialogTitle>{LL.EDIT() + ' ' + LL.ENTITY()}</DialogTitle>
<DialogTitle>{dialogTitle}</DialogTitle>
<DialogContent dividers>
<Grid container>
<Typography variant="body2" color="warning.main">
{LL.ID_OF(LL.ENTITY())}:&nbsp;
</Typography>
<Typography variant="body2">{editItem.id}</Typography>
</Grid>
<LabelValue label={LL.ID_OF(LL.ENTITY())} value={editItem.id} />
<LabelValue
label={`${LL.DEFAULT(1)} ${LL.ENTITY_NAME(1)}`}
value={editItem.n}
/>
<LabelValue label={LL.WRITEABLE()} value={writeableIcon} />
<Grid container direction="row">
<Typography variant="body2" color="warning.main">
{LL.DEFAULT(1) + ' ' + LL.ENTITY_NAME(1)}:&nbsp;
</Typography>
<Typography variant="body2">{editItem.n}</Typography>
</Grid>
<Grid container direction="row">
<Typography variant="body2" color="warning.main">
{LL.WRITEABLE()}:&nbsp;
</Typography>
<Typography variant="body2">
{editItem.w ? (
<DoneIcon color="success" sx={{ fontSize: 16 }} />
) : (
<CloseIcon color="error" sx={{ fontSize: 16 }} />
)}
</Typography>
</Grid>
<Box mt={1} mb={2}>
<Box sx={{ mt: 1, mb: 2 }}>
<EntityMaskToggle onUpdate={updateDeviceEntity} de={editItem} />
</Box>
<Grid container spacing={2}>
<Grid>
<TextField
@@ -149,12 +170,14 @@ const CustomizationsDialog = ({
</>
)}
</Grid>
{error && (
<Typography variant="body2" color="error" mt={2}>
<Typography sx={{ mt: 2 }} variant="body2" color="error">
Error: Check min and max values
</Typography>
)}
</DialogContent>
<DialogActions>
<Button
startIcon={<CancelIcon />}

View File

@@ -6,7 +6,7 @@ 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 UnfoldLessIcon from '@mui/icons-material/UnfoldLess';
import UnfoldMoreIcon from '@mui/icons-material/UnfoldMore';
import {
@@ -133,7 +133,7 @@ const Dashboard = memo(() => {
);
const tree = useTree(
{ nodes: data.nodes },
{ nodes: [...data.nodes] },
{
onChange: () => {} // not used but needed
},
@@ -262,12 +262,8 @@ const Dashboard = memo(() => {
return (
<>
{!data.connected && (
<MessageBox mb={2} 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()}&nbsp;
<Link to="/customizations" style={{ color: 'white' }}>
@@ -283,112 +279,118 @@ const Dashboard = memo(() => {
</MessageBox>
)}
{data.nodes.length > 0 && (
<>
<Box
display="flex"
justifyContent="flex-end"
flexWrap="nowrap"
whiteSpace="nowrap"
<Box
sx={{
display: 'flex',
justifyContent: 'flex-end',
flexWrap: 'nowrap',
whiteSpace: 'nowrap'
}}
>
<ToggleButtonGroup
size="small"
color="primary"
value={showAll}
exclusive
onChange={handleShowAll}
>
<ButtonTooltip title={LL.ALLVALUES()}>
<ToggleButton value={true}>
<UnfoldMoreIcon sx={{ fontSize: 18 }} />
</ToggleButton>
</ButtonTooltip>
<ButtonTooltip title={LL.COMPACT()}>
<ToggleButton value={false}>
<UnfoldLessIcon sx={{ fontSize: 18 }} />
</ToggleButton>
</ButtonTooltip>
</ToggleButtonGroup>
</Box>
{data.nodes.length > 0 ? (
<Box sx={{ mt: 1, justifyContent: 'center', flexDirection: 'column' }}>
<IconContext.Provider
value={{
color: 'lightblue',
size: '18',
style: { verticalAlign: 'middle' }
}}
>
<ToggleButtonGroup
size="small"
color="primary"
value={showAll}
exclusive
onChange={handleShowAll}
<Table
data={{ nodes: data.nodes }}
theme={dashboard_theme}
layout={{ custom: true }}
tree={tree}
>
<ButtonTooltip title={LL.ALLVALUES()}>
<ToggleButton value={true}>
<UnfoldMoreIcon sx={{ fontSize: 18 }} />
</ToggleButton>
</ButtonTooltip>
<ButtonTooltip title={LL.COMPACT()}>
<ToggleButton value={false}>
<UnfoldLessIcon sx={{ fontSize: 18 }} />
</ToggleButton>
</ButtonTooltip>
</ToggleButtonGroup>
<Tooltip title={LL.DASHBOARD_1()}>
<HelpOutlineIcon
sx={{
ml: 1,
mt: 1,
fontSize: 20,
verticalAlign: 'middle'
}}
color="primary"
/>
</Tooltip>
</Box>
{(tableList: DashboardItem[]) => (
<Body>
{tableList.map((di: DashboardItem) => (
<Row
key={di.id}
item={di}
onClick={() => editDashboardValue(di)}
>
{di.id > 99 ? (
<>
<Cell>{showName(di)}</Cell>
<Cell>
<ButtonTooltip
title={formatValue(LL, di.dv?.v, di.dv?.u)}
>
<span>{formatValue(LL, di.dv?.v, di.dv?.u)}</span>
</ButtonTooltip>
</Cell>
<Box mt={1} justifyContent="center" flexDirection="column">
<IconContext.Provider
value={{
color: 'lightblue',
size: '18',
style: { verticalAlign: 'middle' }
<Cell>
{me.admin &&
di.dv?.c &&
!hasMask(di.dv.id, DeviceEntityMask.DV_READONLY) && (
<IconButton
size="small"
aria-label={
LL.CHANGE_VALUE() + ' ' + LL.VALUE(0)
}
onClick={() => editDashboardValue(di)}
>
<EditIcon
color="primary"
sx={{ fontSize: 16 }}
/>
</IconButton>
)}
</Cell>
</>
) : (
<>
<CellTree item={di}>{showName(di)}</CellTree>
<Cell />
<Cell />
</>
)}
</Row>
))}
</Body>
)}
</Table>
</IconContext.Provider>
</Box>
) : (
<Box sx={{ display: 'flex' }}>
<Typography sx={{ mt: 1 }} color="warning" variant="body1">
no data
</Typography>
<Tooltip title={LL.DASHBOARD_1()}>
<HelpOutlineIcon
sx={{
ml: 1,
mt: 1,
fontSize: 20,
verticalAlign: 'middle'
}}
>
<Table
data={{ nodes: data.nodes }}
theme={dashboard_theme}
layout={{ custom: true }}
tree={tree}
>
{(tableList: DashboardItem[]) => (
<Body>
{tableList.map((di: DashboardItem) => (
<Row
key={di.id}
item={di}
onClick={() => editDashboardValue(di)}
>
{di.id > 99 ? (
<>
<Cell>{showName(di)}</Cell>
<Cell>
<ButtonTooltip
title={formatValue(LL, di.dv?.v, di.dv?.u)}
>
<span>{formatValue(LL, di.dv?.v, di.dv?.u)}</span>
</ButtonTooltip>
</Cell>
<Cell>
{me.admin &&
di.dv?.c &&
!hasMask(
di.dv.id,
DeviceEntityMask.DV_READONLY
) && (
<IconButton
size="small"
onClick={() => editDashboardValue(di)}
>
<EditIcon
color="primary"
sx={{ fontSize: 16 }}
/>
</IconButton>
)}
</Cell>
</>
) : (
<>
<CellTree item={di}>{showName(di)}</CellTree>
<Cell />
<Cell />
</>
)}
</Row>
))}
</Body>
)}
</Table>
</IconContext.Provider>
</Box>
</>
color="primary"
/>
</Tooltip>
</Box>
)}
</>
);

View File

@@ -1,26 +1,24 @@
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';
import { GiHeatHaze, GiTap } from 'react-icons/gi';
import { MdPlaylistAdd } from 'react-icons/md';
import { MdMoreTime } from 'react-icons/md';
import {
MdMoreTime,
MdOutlineDevices,
MdOutlinePool,
MdOutlineSensors,
MdPlaylistAdd,
MdThermostatAuto
} from 'react-icons/md';
import { PiFan, PiGauge } from 'react-icons/pi';
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: {
[key in DeviceType]: React.ComponentType<SvgIconProps> | undefined;
} = {
const deviceIconLookup: Record<DeviceType, IconType | null> = {
[DeviceType.TEMPERATURESENSOR]: TiThermometer,
[DeviceType.ANALOGSENSOR]: PiGauge,
[DeviceType.BOILER]: CgSmartHomeBoiler,
@@ -39,15 +37,19 @@ const deviceIconLookup: {
[DeviceType.POOL]: MdOutlinePool,
[DeviceType.CUSTOM]: MdPlaylistAdd,
[DeviceType.UNKNOWN]: MdOutlineSensors,
[DeviceType.SYSTEM]: undefined,
[DeviceType.SYSTEM]: null,
[DeviceType.SCHEDULER]: MdMoreTime,
[DeviceType.GENERIC]: MdOutlineSensors,
[DeviceType.VENTILATION]: PiFan
};
const DeviceIcon = ({ type_id }: { type_id: DeviceType }) => {
interface DeviceIconProps {
type_id: DeviceType;
}
const DeviceIcon = memo(({ type_id }: DeviceIconProps) => {
const Icon = deviceIconLookup[type_id];
return Icon ? <Icon /> : null;
};
});
export default DeviceIcon;

View File

@@ -8,7 +8,7 @@ import {
useState
} from 'react';
import { IconContext } from 'react-icons';
import { useNavigate } from 'react-router';
import { Link, useNavigate } from 'react-router';
import { toast } from 'react-toastify';
import CommentsDisabledOutlinedIcon from '@mui/icons-material/CommentsDisabledOutlined';
@@ -93,7 +93,7 @@ const Devices = memo(() => {
useLayoutTitle(LL.DEVICES());
const { data: coreData, send: sendCoreData } = useRequest(() => readCoreData(), {
const { data: coreData, send: sendCoreData } = useRequest(readCoreData, {
initialData: {
connected: true,
devices: []
@@ -118,30 +118,28 @@ const Devices = memo(() => {
);
useLayoutEffect(() => {
function updateSize() {
setSize([window.innerWidth, window.innerHeight]);
}
let raf = 0;
const updateSize = () => {
cancelAnimationFrame(raf);
raf = requestAnimationFrame(() => {
setSize([window.innerWidth, window.innerHeight]);
});
};
window.addEventListener('resize', updateSize);
updateSize();
return () => window.removeEventListener('resize', updateSize);
return () => {
window.removeEventListener('resize', updateSize);
cancelAnimationFrame(raf);
};
}, []);
const leftOffset = () => {
const leftOffset = useCallback(() => {
const devicesWindow = document.getElementById('devices-window');
if (!devicesWindow) {
return 0;
}
const clientRect = devicesWindow.getBoundingClientRect();
const left = clientRect.left;
const right = clientRect.right;
if (!left || !right) {
return 0;
}
if (!devicesWindow) return 0;
const { left, right } = devicesWindow.getBoundingClientRect();
if (!left || !right) return 0;
return left + (right - left < 400 ? 0 : 200);
};
}, []);
const common_theme = useMemo(
() =>
@@ -261,7 +259,7 @@ const Devices = memo(() => {
};
const dv_sort = useSort(
{ nodes: deviceData.nodes },
{ nodes: [...deviceData.nodes] },
{},
{
sortIcon: {
@@ -291,7 +289,7 @@ const Devices = memo(() => {
}
const device_select = useRowSelect(
{ nodes: coreData.devices },
{ nodes: [...coreData.devices] },
{
onChange: onSelectChange
}
@@ -535,21 +533,29 @@ const Devices = memo(() => {
const renderCoreData = () => (
<>
<Box justifyContent="center" flexDirection="column">
<IconContext.Provider
value={{
color: 'lightblue',
size: '18',
style: { verticalAlign: 'middle' }
}}
>
{!coreData.connected && (
<MessageBox my={2} level="error" message={LL.EMS_BUS_WARNING()} />
)}
{coreData.connected && (
{!coreData.connected ? (
<MessageBox level="error" message={LL.EMS_BUS_WARNING() + '.'}>
&nbsp;(
<Link
target="_blank"
to="https://docs.emsesp.org/Troubleshooting#ems-bus-is-not-connecting"
style={{ color: 'white' }}
>
{LL.ONLINE_HELP()}
</Link>
)
</MessageBox>
) : (
<Box sx={{ justifyContent: 'center', flexDirection: 'column' }}>
<IconContext.Provider
value={{
color: 'lightblue',
size: '18',
style: { verticalAlign: 'middle' }
}}
>
<Table
data={{ nodes: coreData.devices }}
data={{ nodes: [...coreData.devices] }}
select={device_select}
theme={device_theme}
layout={{ custom: true }}
@@ -583,9 +589,9 @@ const Devices = memo(() => {
</>
)}
</Table>
)}
</IconContext.Provider>
</Box>
</IconContext.Provider>
</Box>
)}
</>
);
@@ -654,7 +660,7 @@ const Devices = memo(() => {
sx={{
backgroundColor: 'black',
position: 'absolute',
left: () => leftOffset(),
left: leftOffset,
right: 0,
bottom: 0,
top: 64,
@@ -664,14 +670,14 @@ 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}&nbsp;(
{deviceInfo.tn})
</Typography>
<Grid justifyContent="flex-end">
<Grid sx={{ justifyContent: 'flex-end' }}>
<ButtonTooltip title={LL.CLOSE()}>
<IconButton onClick={resetDeviceSelect}>
<IconButton onClick={resetDeviceSelect} aria-label={LL.CLOSE()}>
<HighlightOffIcon color="primary" sx={{ fontSize: 18 }} />
</IconButton>
</ButtonTooltip>
@@ -683,6 +689,7 @@ const Devices = memo(() => {
variant="outlined"
sx={{ width: '22ch' }}
placeholder={LL.SEARCH()}
aria-label={LL.SEARCH()}
onChange={(event) => {
setSearch(event.target.value);
}}
@@ -697,19 +704,22 @@ const Devices = memo(() => {
}}
/>
<ButtonTooltip title={LL.DEVICE_DETAILS()}>
<IconButton onClick={() => setShowDeviceInfo(true)}>
<IconButton
onClick={() => setShowDeviceInfo(true)}
aria-label={LL.DEVICE_DETAILS()}
>
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
</IconButton>
</ButtonTooltip>
{me.admin && (
<ButtonTooltip title={LL.CUSTOMIZATIONS()}>
<IconButton onClick={customize}>
<IconButton onClick={customize} aria-label={LL.CUSTOMIZATIONS()}>
<ConstructionIcon color="primary" sx={{ fontSize: 18 }} />
</IconButton>
</ButtonTooltip>
)}
<ButtonTooltip title={LL.EXPORT()}>
<IconButton onClick={handleDownloadCsv}>
<IconButton onClick={handleDownloadCsv} aria-label={LL.EXPORT()}>
<DownloadIcon color="primary" sx={{ fontSize: 18 }} />
</IconButton>
</ButtonTooltip>
@@ -744,7 +754,7 @@ const Devices = memo(() => {
</Box>
<Table
data={{ nodes: shown_data }}
data={{ nodes: Array.from(shown_data) }}
theme={data_theme}
sort={dv_sort}
layout={{ custom: true, fixedHeader: true }}

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel';
import WarningIcon from '@mui/icons-material/Warning';
@@ -24,7 +24,7 @@ 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 { DeviceValueUOM, DeviceValueUOM_s } from './types';
import type { DeviceValue } from './types';
@@ -52,7 +52,7 @@ const DevicesDialog = ({
const [editItem, setEditItem] = useState<DeviceValue>(selectedItem);
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const updateFormValue = updateValue(setEditItem);
const updateFormValue = useMemo(() => updateValue(setEditItem), [setEditItem]);
useEffect(() => {
if (open) {
@@ -61,67 +61,83 @@ const DevicesDialog = ({
}
}, [open, selectedItem]);
const close = () => {
onClose();
};
const save = async () => {
const save = useCallback(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 setUom = (uom?: DeviceValueUOM) => {
if (uom === undefined) {
return;
}
switch (uom) {
case DeviceValueUOM.HOURS:
return LL.HOURS();
case DeviceValueUOM.MINUTES:
return LL.MINUTES();
case DeviceValueUOM.SECONDS:
return LL.SECONDS();
default:
return DeviceValueUOM_s[uom];
}
};
const setUom = useCallback(
(uom?: DeviceValueUOM) => {
if (uom === undefined) {
return;
}
switch (uom) {
case DeviceValueUOM.HOURS:
return LL.HOURS();
case DeviceValueUOM.MINUTES:
return LL.MINUTES();
case DeviceValueUOM.SECONDS:
return LL.SECONDS();
default:
return DeviceValueUOM_s[uom];
}
},
[LL]
);
const showHelperText = (dv: DeviceValue) =>
dv.h ? (
dv.h
) : dv.l ? (
dv.l.join(' | ')
) : dv.m !== undefined && dv.x !== undefined ? (
<>
{dv.m}&nbsp;&rarr;&nbsp;{dv.x}
</>
) : undefined;
const showHelperText = useCallback((dv: DeviceValue) => {
if (dv.h) return dv.h;
if (dv.l) return dv.l.join(' | ');
if (dv.m !== undefined && dv.x !== undefined) {
return (
<>
{dv.m}&nbsp;&rarr;&nbsp;{dv.x}
</>
);
}
return undefined;
}, []);
const isCommand = useMemo(
() => selectedItem.v === '' && selectedItem.c,
[selectedItem.v, selectedItem.c]
);
const dialogTitle = useMemo(() => {
if (isCommand) return LL.RUN_COMMAND();
return writeable ? LL.CHANGE_VALUE() : LL.VALUE(0);
}, [isCommand, writeable, LL]);
const buttonLabel = useMemo(() => {
return isCommand ? LL.EXECUTE() : LL.UPDATE();
}, [isCommand, LL]);
const helperText = useMemo(
() => showHelperText(editItem),
[editItem, showHelperText]
);
const valueLabel = LL.VALUE(0);
return (
<Dialog sx={dialogStyle} open={open} onClose={close}>
<DialogTitle>
{selectedItem.v === '' && selectedItem.c
? LL.RUN_COMMAND()
: writeable
? LL.CHANGE_VALUE()
: LL.VALUE(0)}
</DialogTitle>
<Dialog sx={dialogStyle} open={open} onClose={onClose}>
<DialogTitle>{dialogTitle}</DialogTitle>
<DialogContent dividers>
<Box color="warning.main" p={0} pl={0} pr={0} mt={0} mb={2}>
<Typography variant="body2">{editItem.id.slice(2)}</Typography>
</Box>
<Typography sx={{ mb: 2 }} color="warning" variant="body2">
{editItem.id.slice(2)}
</Typography>
<Grid container>
<Grid size={12}>
{editItem.l ? (
<TextField
name="v"
// label={LL.VALUE(0)}
value={editItem.v}
aria-label={valueLabel}
disabled={!writeable}
sx={{ width: '30ch' }}
select
@@ -137,7 +153,7 @@ const DevicesDialog = ({
<ValidatedTextField
fieldErrors={fieldErrors || {}}
name="v"
label={LL.VALUE(0)}
label={valueLabel}
value={numberValue(Math.round((editItem.v as number) * 10) / 10)}
autoFocus
disabled={!writeable}
@@ -161,7 +177,7 @@ const DevicesDialog = ({
<ValidatedTextField
fieldErrors={fieldErrors || {}}
name="v"
label={LL.VALUE(0)}
label={valueLabel}
value={editItem.v}
disabled={!writeable}
sx={{ width: '30ch' }}
@@ -170,9 +186,9 @@ const DevicesDialog = ({
/>
)}
</Grid>
{writeable && (
{writeable && helperText && (
<Grid>
<FormHelperText>{showHelperText(editItem)}</FormHelperText>
<FormHelperText>{helperText}</FormHelperText>
</Grid>
)}
</Grid>
@@ -191,7 +207,7 @@ const DevicesDialog = ({
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={close}
onClick={onClose}
color="secondary"
>
{LL.CANCEL()}
@@ -202,7 +218,7 @@ const DevicesDialog = ({
onClick={save}
color="primary"
>
{selectedItem.v === '' && selectedItem.c ? LL.EXECUTE() : LL.UPDATE()}
{buttonLabel}
</Button>
{progress && (
<CircularProgress
@@ -217,7 +233,7 @@ const DevicesDialog = ({
)}
</Box>
) : (
<Button variant="outlined" onClick={close} color="secondary">
<Button variant="outlined" onClick={onClose} color="secondary">
{LL.CLOSE()}
</Button>
)}

View File

@@ -1,3 +1,5 @@
import { useCallback, useMemo } from 'react';
import { ToggleButton, ToggleButtonGroup } from '@mui/material';
import OptionIcon from './OptionIcon';
@@ -9,92 +11,132 @@ interface EntityMaskToggleProps {
de: DeviceEntity;
}
const EntityMaskToggle = ({ onUpdate, de }: EntityMaskToggleProps) => {
const getMaskNumber = (newMask: string[]) => {
let new_mask = 0;
for (const entry of newMask) {
new_mask |= Number(entry);
}
return new_mask;
};
// Available mask values
const MASK_VALUES = [
DeviceEntityMask.DV_WEB_EXCLUDE, // 1
DeviceEntityMask.DV_API_MQTT_EXCLUDE, // 2
DeviceEntityMask.DV_READONLY, // 4
DeviceEntityMask.DV_FAVORITE, // 8
DeviceEntityMask.DV_DELETED // 128
];
const getMaskString = (m: number) => {
const new_masks: string[] = [];
if ((m & 1) === 1) {
new_masks.push('1');
}
if ((m & 2) === 2) {
new_masks.push('2');
}
if ((m & 4) === 4) {
new_masks.push('4');
}
if ((m & 8) === 8) {
new_masks.push('8');
}
if ((m & 128) === 128) {
new_masks.push('128');
}
return new_masks;
};
/**
* Converts an array of mask strings to a bitmask number
*/
const getMaskNumber = (newMask: string[]): number => {
return newMask.reduce((mask, entry) => mask | Number(entry), 0);
};
/**
* Converts a bitmask number to an array of mask strings
*/
const getMaskString = (mask: number): string[] => {
return MASK_VALUES.filter((value) => (mask & value) === value).map((value) =>
String(value)
);
};
/**
* Checks if a specific mask bit is set
*/
const hasMask = (mask: number, flag: number): boolean => (mask & flag) === flag;
const EntityMaskToggle = ({ onUpdate, de }: EntityMaskToggleProps) => {
const handleChange = useCallback(
(_event: unknown, mask: string[]) => {
// Convert selected masks to a number
const newMask = getMaskNumber(mask);
const updatedDe = { ...de };
// Apply business logic for mask interactions
// If entity has no name and is set to readonly, also exclude from web
if (updatedDe.n === '' && hasMask(newMask, DeviceEntityMask.DV_READONLY)) {
updatedDe.m = newMask | DeviceEntityMask.DV_WEB_EXCLUDE;
} else {
updatedDe.m = newMask;
}
// If excluded from web, cannot be favorite
if (hasMask(updatedDe.m, DeviceEntityMask.DV_WEB_EXCLUDE)) {
updatedDe.m = updatedDe.m & ~DeviceEntityMask.DV_FAVORITE;
}
onUpdate(updatedDe);
},
[de, onUpdate]
);
// Memoize mask string value
const maskStringValue = useMemo(() => getMaskString(de.m), [de.m]);
// Memoize disabled states
const isFavoriteDisabled = useMemo(
() =>
hasMask(de.m, DeviceEntityMask.DV_WEB_EXCLUDE | DeviceEntityMask.DV_DELETED) ||
de.n === undefined,
[de.m, de.n]
);
const isReadonlyDisabled = useMemo(
() =>
!de.w ||
hasMask(de.m, DeviceEntityMask.DV_WEB_EXCLUDE | DeviceEntityMask.DV_FAVORITE),
[de.w, de.m]
);
const isApiMqttExcludeDisabled = useMemo(
() => de.n === '' || hasMask(de.m, DeviceEntityMask.DV_DELETED),
[de.n, de.m]
);
const isWebExcludeDisabled = useMemo(
() => de.n === undefined || hasMask(de.m, DeviceEntityMask.DV_DELETED),
[de.n, de.m]
);
// Memoize mask flag checks
const isFavoriteSet = useMemo(
() => hasMask(de.m, DeviceEntityMask.DV_FAVORITE),
[de.m]
);
const isReadonlySet = useMemo(
() => hasMask(de.m, DeviceEntityMask.DV_READONLY),
[de.m]
);
const isApiMqttExcludeSet = useMemo(
() => hasMask(de.m, DeviceEntityMask.DV_API_MQTT_EXCLUDE),
[de.m]
);
const isWebExcludeSet = useMemo(
() => hasMask(de.m, DeviceEntityMask.DV_WEB_EXCLUDE),
[de.m]
);
const isDeletedSet = useMemo(
() => hasMask(de.m, DeviceEntityMask.DV_DELETED),
[de.m]
);
return (
<ToggleButtonGroup
size="small"
color="secondary"
value={getMaskString(de.m)}
onChange={(_event, mask: string[]) => {
de.m = getMaskNumber(mask);
if (de.n === '' && de.m & DeviceEntityMask.DV_READONLY) {
de.m = de.m | DeviceEntityMask.DV_WEB_EXCLUDE;
}
if (de.m & DeviceEntityMask.DV_WEB_EXCLUDE) {
de.m = de.m & ~DeviceEntityMask.DV_FAVORITE;
}
onUpdate(de);
}}
value={maskStringValue}
onChange={handleChange}
>
<ToggleButton value="8" disabled={(de.m & 0x81) !== 0 || de.n === undefined}>
<OptionIcon
type="favorite"
isSet={
(de.m & DeviceEntityMask.DV_FAVORITE) === DeviceEntityMask.DV_FAVORITE
}
/>
<ToggleButton value="8" disabled={isFavoriteDisabled}>
<OptionIcon type="favorite" isSet={isFavoriteSet} />
</ToggleButton>
<ToggleButton value="4" disabled={!de.w || (de.m & 0x83) >= 3}>
<OptionIcon
type="readonly"
isSet={
(de.m & DeviceEntityMask.DV_READONLY) === DeviceEntityMask.DV_READONLY
}
/>
<ToggleButton value="4" disabled={isReadonlyDisabled}>
<OptionIcon type="readonly" isSet={isReadonlySet} />
</ToggleButton>
<ToggleButton value="2" disabled={de.n === '' || (de.m & 0x80) !== 0}>
<OptionIcon
type="api_mqtt_exclude"
isSet={
(de.m & DeviceEntityMask.DV_API_MQTT_EXCLUDE) ===
DeviceEntityMask.DV_API_MQTT_EXCLUDE
}
/>
<ToggleButton value="2" disabled={isApiMqttExcludeDisabled}>
<OptionIcon type="api_mqtt_exclude" isSet={isApiMqttExcludeSet} />
</ToggleButton>
<ToggleButton value="1" disabled={de.n === undefined || (de.m & 0x80) !== 0}>
<OptionIcon
type="web_exclude"
isSet={
(de.m & DeviceEntityMask.DV_WEB_EXCLUDE) ===
DeviceEntityMask.DV_WEB_EXCLUDE
}
/>
<ToggleButton value="1" disabled={isWebExcludeDisabled}>
<OptionIcon type="web_exclude" isSet={isWebExcludeSet} />
</ToggleButton>
<ToggleButton value="128">
<OptionIcon
type="deleted"
isSet={
(de.m & DeviceEntityMask.DV_DELETED) === DeviceEntityMask.DV_DELETED
}
/>
<OptionIcon type="deleted" isSet={isDeletedSet} />
</ToggleButton>
</ToggleButtonGroup>
);

View File

@@ -1,4 +1,5 @@
import { useContext, useState } from 'react';
import { memo, useCallback, useContext, useMemo, useState } from 'react';
import type { ReactElement } from 'react';
import { toast } from 'react-toastify';
import CommentIcon from '@mui/icons-material/CommentTwoTone';
@@ -10,6 +11,7 @@ import {
Box,
Button,
Divider,
Grid,
Link,
List,
ListItem,
@@ -19,6 +21,7 @@ import {
Stack,
Typography
} from '@mui/material';
import type { SxProps, Theme } from '@mui/material/styles';
import { useRequest } from 'alova/client';
import { SectionContent, useLayoutTitle } from 'components';
@@ -29,26 +32,60 @@ import { saveFile } from 'utils';
import { API, callAction } from '../../api/app';
import type { APIcall } from './types';
const Help = () => {
interface HelpLink {
href: string;
icon: ReactElement;
label: () => string;
}
interface CustomSupport {
img_url: string | null;
html: string | null;
}
const DEFAULT_IMAGE_URL = 'https://emsesp.org/media/images/installer.jpeg';
const SUPPORT_BOX_STYLES: SxProps<Theme> = {
borderRadius: 3,
border: '1px solid lightblue',
justifyContent: 'space-evenly',
alignItems: 'center'
};
const IMAGE_STYLES: SxProps<Theme> = {
maxHeight: { xs: 100, md: 250 }
};
const AVATAR_STYLES: SxProps<Theme> = {
bgcolor: '#72caf9'
};
const HelpComponent = () => {
const { LL } = useI18nContext();
useLayoutTitle(LL.HELP());
const { me } = useContext(AuthenticatedContext);
const [customSupportIMG, setCustomSupportIMG] = useState<string | null>(null);
const [customSupportHTML, setCustomSupportHTML] = useState<string | null>(null);
const [notFound, setNotFound] = useState<boolean>(false);
const [customSupport, setCustomSupport] = useState<CustomSupport>({
img_url: null,
html: null
});
const [imgError, setImgError] = useState<boolean>(false);
useRequest(() => callAction({ action: 'getCustomSupport' })).onSuccess((event) => {
if (event && event.data && Object.keys(event.data).length !== 0) {
const data = (event.data as { Support: { img_url?: string; html?: string[] } })
.Support;
if (data.img_url) {
setCustomSupportIMG(data.img_url);
}
if (data.html) {
setCustomSupportHTML(data.html.join('<br/>'));
}
const getCustomSupportMethod = useMemo(
() => callAction({ action: 'getCustomSupport' }),
[]
);
useRequest(getCustomSupportMethod).onSuccess((event) => {
if (event?.data && Object.keys(event.data).length !== 0) {
const { Support } = event.data as {
Support: { img_url?: string; html?: string[] };
};
setCustomSupport({
img_url: Support.img_url || null,
html: Support.html?.join('<br/>') || null
});
}
});
@@ -63,110 +100,106 @@ const Help = () => {
toast.error(String(error.error?.message || 'An error occurred'));
});
// Optimize API call memoization
const apiCall = useMemo(() => ({ device: 'system', cmd: 'info', id: 0 }), []);
const handleDownloadSystemInfo = useCallback(() => {
void sendAPI(apiCall);
}, [sendAPI, apiCall]);
const handleImageError = useCallback(() => {
setImgError(true);
}, []);
// Memoize help links to prevent recreation on every render
const helpLinks: HelpLink[] = useMemo(
() => [
{
href: 'https://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()
}
],
[LL]
);
const isAdmin = useMemo(() => me?.admin ?? false, [me?.admin]);
// Memoize image source computation
const imageSrc = useMemo(
() =>
imgError || !customSupport.img_url ? DEFAULT_IMAGE_URL : customSupport.img_url,
[imgError, customSupport.img_url]
);
return (
<SectionContent>
{customSupportHTML && (
{customSupport.html && (
<Stack
padding={1}
mb={2}
direction="row"
divider={<Divider orientation="vertical" flexItem />}
sx={{
borderRadius: 3,
border: '1px solid lightblue',
justifyContent: 'space-evenly',
alignItems: 'center'
}}
sx={{ padding: 1, mb: 2, ...SUPPORT_BOX_STYLES }}
>
<Typography variant="subtitle1">
<div dangerouslySetInnerHTML={{ __html: customSupportHTML }} />
<div dangerouslySetInnerHTML={{ __html: customSupport.html }} />
</Typography>
<Box
component="img"
referrerPolicy="no-referrer"
sx={{
maxHeight: { xs: 100, md: 250 }
}}
onError={() => setNotFound(true)}
src={
notFound
? ''
: customSupportIMG ||
'https://docs.emsesp.org/_media/images/installer.jpeg'
}
sx={IMAGE_STYLES}
onError={handleImageError}
src={imageSrc}
/>
</Stack>
)}
{me.admin && (
{isAdmin && (
<List>
<ListItem>
<ListItemButton
component="a"
target="_blank"
rel="noreferrer"
href="https://docs.emsesp.org"
>
<ListItemAvatar>
<Avatar sx={{ bgcolor: '#72caf9' }}>
<MenuBookIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary={LL.HELP_INFORMATION_1()} />
</ListItemButton>
</ListItem>
<ListItem>
<ListItemButton
component="a"
target="_blank"
rel="noreferrer"
href="https://discord.gg/3J3GgnzpyT"
>
<ListItemAvatar>
<Avatar sx={{ bgcolor: '#72caf9' }}>
<CommentIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary={LL.HELP_INFORMATION_2()} />
</ListItemButton>
</ListItem>
<ListItem>
<ListItemButton
component="a"
target="_blank"
rel="noreferrer"
href="https://github.com/emsesp/EMS-ESP32/issues/new/choose"
>
<ListItemAvatar>
<Avatar sx={{ bgcolor: '#72caf9' }}>
<GitHubIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary={LL.HELP_INFORMATION_3()} />
</ListItemButton>
</ListItem>
{helpLinks.map(({ href, icon, label }) => (
<ListItem key={href}>
<ListItemButton
component="a"
target="_blank"
rel="noreferrer"
href={href}
>
<ListItemAvatar>
<Avatar sx={AVATAR_STYLES}>{icon}</Avatar>
</ListItemAvatar>
<ListItemText primary={label()} />
</ListItemButton>
</ListItem>
))}
</List>
)}
<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={() => sendAPI({ device: 'system', cmd: 'info', id: 0 })}
onClick={handleDownloadSystemInfo}
>
{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 }}>
&copy;&nbsp;
<Link
target="_blank"
@@ -174,11 +207,14 @@ const Help = () => {
href="https://emsesp.org"
color="primary"
>
{'emsesp.org'}
emsesp.org
</Link>
</Typography>
</SectionContent>
);
};
// Memoize the component to prevent unnecessary re-renders
const Help = memo(HelpComponent);
export default Help;

View File

@@ -1,4 +1,4 @@
import { useCallback, useState } from 'react';
import { memo, useCallback, useMemo, useState } from 'react';
import { useBlocker } from 'react-router';
import { toast } from 'react-toastify';
@@ -31,6 +31,19 @@ import { readModules, writeModules } from '../../api/app';
import ModulesDialog from './ModulesDialog';
import type { ModuleItem } from './types';
const PENDING_COLOR = 'red';
const ACTIVATED_COLOR = '#00FF7F';
const hasModulesChanged = (mi: ModuleItem): boolean =>
mi.enabled !== mi.o_enabled || mi.license !== mi.o_license;
const ColorStatus = memo(({ status }: { status: number }) => {
if (status === 1) {
return <div style={{ color: PENDING_COLOR }}>Pending Activation</div>;
}
return <div style={{ color: ACTIVATED_COLOR }}>Activated</div>;
});
const Modules = () => {
const { LL } = useI18nContext();
const [numChanges, setNumChanges] = useState<number>(0);
@@ -56,105 +69,107 @@ const Modules = () => {
}
);
const modules_theme = useTheme({
Table: `
--data-table-library_grid-template-columns: 48px 180px 120px 100px repeat(1, minmax(160px, 1fr)) 180px;
`,
BaseRow: `
font-size: 14px;
.td {
height: 32px;
}
`,
BaseCell: `
&:nth-of-type(1) {
text-align: center;
}
`,
HeaderRow: `
text-transform: uppercase;
background-color: black;
color: #90CAF9;
.th {
border-bottom: 1px solid #565656;
height: 36px;
}
`,
Row: `
background-color: #1e1e1e;
position: relative;
cursor: pointer;
.td {
border-top: 1px solid #565656;
border-bottom: 1px solid #565656;
}
&:hover .td {
border-top: 1px solid #177ac9;
border-bottom: 1px solid #177ac9;
}
&:nth-of-type(odd) .td {
background-color: #303030;
}
`
});
const modules_theme = useTheme(
useMemo(
() => ({
Table: `
--data-table-library_grid-template-columns: 48px 180px 120px 100px repeat(1, minmax(160px, 1fr)) 180px;
`,
BaseRow: `
font-size: 14px;
.td {
height: 32px;
}
`,
BaseCell: `
&:nth-of-type(1) {
text-align: center;
}
`,
HeaderRow: `
text-transform: uppercase;
background-color: black;
color: #90CAF9;
.th {
border-bottom: 1px solid #565656;
height: 36px;
}
`,
Row: `
background-color: #1e1e1e;
position: relative;
cursor: pointer;
.td {
border-top: 1px solid #565656;
border-bottom: 1px solid #565656;
}
&:hover .td {
border-top: 1px solid #177ac9;
border-bottom: 1px solid #177ac9;
}
&:nth-of-type(odd) .td {
background-color: #303030;
}
`
}),
[]
)
);
const onDialogClose = () => {
const onDialogClose = useCallback(() => {
setDialogOpen(false);
};
}, []);
const onDialogSave = (updatedItem: ModuleItem) => {
setDialogOpen(false);
updateModuleItem(updatedItem);
};
const updateModuleItem = useCallback((updatedItem: ModuleItem) => {
void updateState(readModules(), (data: ModuleItem[]) => {
const new_data = data.map((mi) =>
mi.id === updatedItem.id ? { ...mi, ...updatedItem } : mi
);
setNumChanges(new_data.filter(hasModulesChanged).length);
return new_data;
});
}, []);
const onDialogSave = useCallback(
(updatedItem: ModuleItem) => {
setDialogOpen(false);
updateModuleItem(updatedItem);
},
[updateModuleItem]
);
const editModuleItem = useCallback((mi: ModuleItem) => {
setSelectedModuleItem(mi);
setDialogOpen(true);
}, []);
const onCancel = async () => {
const onCancel = useCallback(async () => {
await fetchModules().then(() => {
setNumChanges(0);
});
};
}, [fetchModules]);
function hasModulesChanged(mi: ModuleItem) {
return mi.enabled !== mi.o_enabled || mi.license !== mi.o_license;
}
const updateModuleItem = (updatedItem: ModuleItem) => {
void updateState(readModules(), (data: ModuleItem[]) => {
const new_data = data.map((mi) =>
mi.id === updatedItem.id ? { ...mi, ...updatedItem } : mi
const saveModules = useCallback(async () => {
try {
await Promise.all(
modules.map((condensed_mi: ModuleItem) =>
updateModules({
key: condensed_mi.key,
enabled: condensed_mi.enabled,
license: condensed_mi.license
})
)
);
setNumChanges(new_data.filter((mi) => hasModulesChanged(mi)).length);
return new_data;
});
};
toast.success(LL.MODULES_UPDATED());
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
} finally {
await fetchModules();
setNumChanges(0);
}
}, [modules, updateModules, LL, fetchModules]);
const saveModules = async () => {
await Promise.all(
modules.map((condensed_mi: ModuleItem) =>
updateModules({
key: condensed_mi.key,
enabled: condensed_mi.enabled,
license: condensed_mi.license
})
)
)
.then(() => {
toast.success(LL.MODULES_UPDATED());
})
.catch((error: Error) => {
toast.error(error.message);
})
.finally(async () => {
await fetchModules();
setNumChanges(0);
});
};
const renderContent = () => {
const content = useMemo(() => {
if (!modules) {
return (
<FormLoader onRetry={fetchModules} errorMessage={error?.message || ''} />
@@ -169,18 +184,11 @@ const Modules = () => {
);
}
const colorStatus = (status: number) => {
if (status === 1) {
return <div style={{ color: 'red' }}>Pending Activation</div>;
}
return <div style={{ color: '#00FF7F' }}>Activated</div>;
};
return (
<>
<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}
@@ -218,7 +226,9 @@ const Modules = () => {
<Cell>{mi.author}</Cell>
<Cell>{mi.version}</Cell>
<Cell>{mi.message}</Cell>
<Cell>{colorStatus(mi.status)}</Cell>
<Cell>
<ColorStatus status={mi.status} />
</Cell>
</Row>
))}
</Body>
@@ -226,8 +236,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
@@ -252,12 +262,22 @@ const Modules = () => {
</Box>
</>
);
};
}, [
modules,
fetchModules,
error,
modules_theme,
editModuleItem,
LL,
numChanges,
onCancel,
saveModules
]);
return (
<SectionContent>
{blocker ? <BlockNavigation blocker={blocker} /> : null}
{renderContent()}
{content}
{selectedModuleItem && (
<ModulesDialog
open={dialogOpen}

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel';
import DoneIcon from '@mui/icons-material/Done';
@@ -37,25 +37,35 @@ const ModulesDialog = ({
const { LL } = useI18nContext();
const [editItem, setEditItem] = useState<ModuleItem>(selectedItem);
const updateFormValue = updateValue(setEditItem);
const updateFormValue = useMemo(
() =>
updateValue(
setEditItem as unknown as React.Dispatch<
React.SetStateAction<Record<string, unknown>>
>
),
[]
);
// Sync form state when dialog opens or selected item changes
useEffect(() => {
if (open) {
setEditItem(selectedItem);
}
}, [open, selectedItem]);
const close = () => {
onClose();
};
const save = () => {
const handleSave = useCallback(() => {
onSave(editItem);
};
}, [editItem, onSave]);
const dialogTitle = useMemo(
() => `${LL.EDIT()} ${editItem.key}`,
[LL, editItem.key]
);
return (
<Dialog sx={dialogStyle} fullWidth maxWidth="xs" open={open} onClose={onClose}>
<DialogTitle>{LL.EDIT() + ' ' + editItem.key}</DialogTitle>
<DialogTitle>{dialogTitle}</DialogTitle>
<DialogContent dividers>
<Grid container>
<BlockFormControlLabel
@@ -69,7 +79,7 @@ const ModulesDialog = ({
label="Enabled"
/>
</Grid>
<Box mt={2} mb={1}>
<Box sx={{ mt: 2, mb: 1 }}>
<TextField
name="license"
label="License Key"
@@ -85,7 +95,7 @@ const ModulesDialog = ({
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={close}
onClick={onClose}
color="secondary"
>
{LL.CANCEL()}
@@ -93,7 +103,7 @@ const ModulesDialog = ({
<Button
startIcon={<DoneIcon />}
variant="outlined"
onClick={save}
onClick={handleSave}
color="primary"
>
{LL.UPDATE()}

View File

@@ -1,42 +1,50 @@
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';
type OptionType =
export type OptionType =
| 'deleted'
| 'readonly'
| 'web_exclude'
| 'api_mqtt_exclude'
| 'favorite';
const OPTION_ICONS: {
[type in OptionType]: [
React.ComponentType<SvgIconProps>,
React.ComponentType<SvgIconProps>
];
} = {
type IconPair = [
React.ComponentType<SvgIconProps>,
React.ComponentType<SvgIconProps>
];
const OPTION_ICONS: Record<OptionType, IconPair> = {
deleted: [DeleteForeverIcon, DeleteOutlineIcon],
readonly: [EditOffOutlinedIcon, EditOutlinedIcon],
web_exclude: [VisibilityOffOutlinedIcon, VisibilityOutlinedIcon],
api_mqtt_exclude: [CommentsDisabledOutlinedIcon, InsertCommentOutlinedIcon],
favorite: [StarIcon, StarOutlineIcon]
} as const;
const ICON_SIZE = 16;
const ICON_SX = { fontSize: ICON_SIZE, verticalAlign: 'middle' } as const;
export interface OptionIconProps {
readonly type: OptionType;
readonly isSet: boolean;
}
const OptionIcon = ({ type, isSet }: OptionIconProps) => {
const [SetIcon, UnsetIcon] = OPTION_ICONS[type];
const Icon = isSet ? SetIcon : UnsetIcon;
return <Icon {...(isSet && { color: 'primary' })} sx={ICON_SX} />;
};
const OptionIcon = ({ type, isSet }: { type: OptionType; isSet: boolean }) => {
const Icon = OPTION_ICONS[type][isSet ? 0 : 1];
return isSet ? (
<Icon color="primary" sx={{ fontSize: 16, verticalAlign: 'middle' }} />
) : (
<Icon sx={{ fontSize: 16, verticalAlign: 'middle' }} />
);
};
export default OptionIcon;
export default memo(OptionIcon);

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useBlocker } from 'react-router';
import { toast } from 'react-toastify';
@@ -35,6 +35,77 @@ import { ScheduleFlag } from './types';
import type { Schedule, ScheduleItem } from './types';
import { schedulerItemValidation } from './validators';
// Constants
const INTERVAL_DELAY = 30000; // 30 seconds
const MIN_ID = -100;
const MAX_ID = 100;
const ICON_SIZE = 16;
const SCHEDULE_FLAG_THRESHOLD = 127;
const FLAG_ALL_DAYS = 127;
const REFERENCE_YEAR = 2017;
const REFERENCE_MONTH = '01';
const LOG_2 = Math.log(2);
// Days of week starting from Monday (1-7)
const WEEK_DAYS = [1, 2, 3, 4, 5, 6, 7] as const;
const DEFAULT_SCHEDULE_ITEM: Omit<ScheduleItem, 'id' | 'o_id'> = {
active: false,
deleted: false,
flags: FLAG_ALL_DAYS,
time: '',
cmd: '',
value: '',
name: ''
};
const scheduleTheme = {
Table: `
--data-table-library_grid-template-columns: 36px 210px 100px 192px repeat(1, minmax(100px, 1fr)) 160px;
`,
BaseRow: `
font-size: 14px;
.td {
height: 32px;
}
`,
BaseCell: `
&:nth-of-type(2) {
text-align: center;
}
&:nth-of-type(1) {
text-align: center;
}
`,
HeaderRow: `
text-transform: uppercase;
background-color: black;
color: #90CAF9;
.th {
border-bottom: 1px solid #565656;
height: 36px;
}
`,
Row: `
background-color: #1e1e1e;
position: relative;
cursor: pointer;
.td {
border-bottom: 1px solid #565656;
}
&:hover .td {
background-color: #177ac9;
}
`
};
const scheduleTypeLabels: Record<number, string> = {
[ScheduleFlag.SCHEDULE_IMMEDIATE]: 'Immediate',
[ScheduleFlag.SCHEDULE_TIMER]: 'Timer',
[ScheduleFlag.SCHEDULE_CONDITION]: 'Condition',
[ScheduleFlag.SCHEDULE_ONCHANGE]: 'On Change'
};
const Scheduler = () => {
const { LL, locale } = useI18nContext();
const [numChanges, setNumChanges] = useState<number>(0);
@@ -61,7 +132,7 @@ const Scheduler = () => {
}
);
function hasScheduleChanged(si: ScheduleItem) {
const hasScheduleChanged = useCallback((si: ScheduleItem) => {
return (
si.id !== si.o_id ||
(si.name || '') !== (si.o_name || '') ||
@@ -72,91 +143,56 @@ const Scheduler = () => {
si.cmd !== si.o_cmd ||
si.value !== si.o_value
);
}
}, []);
useInterval(() => {
const intervalCallback = useCallback(() => {
if (numChanges === 0) {
void fetchSchedule();
}
});
}, [numChanges, fetchSchedule]);
useInterval(intervalCallback, INTERVAL_DELAY);
useEffect(() => {
const formatter = new Intl.DateTimeFormat(locale, {
weekday: 'short',
timeZone: 'UTC'
});
const days = [1, 2, 3, 4, 5, 6, 7].map((day) => {
const dd = day < 10 ? `0${day}` : day;
return new Date(`2017-01-${dd}T00:00:00+00:00`);
const days = WEEK_DAYS.map((day) => {
const dayStr = String(day).padStart(2, '0');
return new Date(
`${REFERENCE_YEAR}-${REFERENCE_MONTH}-${dayStr}T00:00:00+00:00`
);
});
setDow(days.map((date) => formatter.format(date)));
}, [locale]);
const schedule_theme = useTheme({
Table: `
--data-table-library_grid-template-columns: 36px 210px 100px 192px repeat(1, minmax(100px, 1fr)) 160px;
`,
BaseRow: `
font-size: 14px;
.td {
height: 32px;
}
`,
BaseCell: `
&:nth-of-type(2) {
text-align: center;
}
&:nth-of-type(1) {
text-align: center;
}
`,
HeaderRow: `
text-transform: uppercase;
background-color: black;
color: #90CAF9;
.th {
border-bottom: 1px solid #565656;
height: 36px;
}
`,
Row: `
background-color: #1e1e1e;
position: relative;
cursor: pointer;
.td {
border-bottom: 1px solid #565656;
}
&:hover .td {
background-color: #177ac9;
}
`
});
const schedule_theme = useTheme(scheduleTheme);
const saveSchedule = async () => {
await updateSchedule({
schedule: schedule
.filter((si: ScheduleItem) => !si.deleted)
.map((condensed_si: ScheduleItem) => ({
id: condensed_si.id,
active: condensed_si.active,
flags: condensed_si.flags,
time: condensed_si.time,
cmd: condensed_si.cmd,
value: condensed_si.value,
name: condensed_si.name
}))
})
.then(() => {
toast.success(LL.SCHEDULE_UPDATED());
})
.catch((error: Error) => {
toast.error(error.message);
})
.finally(async () => {
await fetchSchedule();
setNumChanges(0);
const saveSchedule = useCallback(async () => {
try {
await updateSchedule({
schedule: schedule
.filter((si: ScheduleItem) => !si.deleted)
.map((condensed_si: ScheduleItem) => ({
id: condensed_si.id,
active: condensed_si.active,
flags: condensed_si.flags,
time: condensed_si.time,
cmd: condensed_si.cmd,
value: condensed_si.value,
name: condensed_si.name
}))
});
};
toast.success(LL.SCHEDULE_UPDATED());
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
toast.error(message);
} finally {
await fetchSchedule();
setNumChanges(0);
}
}, [LL, schedule, updateSchedule, fetchSchedule]);
const editScheduleItem = useCallback((si: ScheduleItem) => {
setCreating(false);
@@ -167,95 +203,93 @@ const Scheduler = () => {
}
}, []);
const onDialogClose = () => {
const onDialogClose = useCallback(() => {
setDialogOpen(false);
};
}, []);
const onDialogCancel = async () => {
const onDialogCancel = useCallback(async () => {
await fetchSchedule().then(() => {
setNumChanges(0);
});
};
}, [fetchSchedule]);
const onDialogSave = (updatedItem: ScheduleItem) => {
setDialogOpen(false);
void updateState(readSchedule(), (data: ScheduleItem[]) => {
const new_data = creating
? [
...data.filter((si) => creating || si.o_id !== updatedItem.o_id),
updatedItem
]
: data.map((si) =>
si.id === updatedItem.id ? { ...si, ...updatedItem } : si
);
const onDialogSave = useCallback(
(updatedItem: ScheduleItem) => {
setDialogOpen(false);
void updateState(readSchedule(), (data: ScheduleItem[]) => {
const new_data = creating
? [...data, updatedItem]
: data.map((si) =>
si.id === updatedItem.id ? { ...si, ...updatedItem } : si
);
setNumChanges(new_data.filter((si) => hasScheduleChanged(si)).length);
setNumChanges(new_data.filter((si) => hasScheduleChanged(si)).length);
return new_data;
});
};
return new_data;
});
},
[creating, hasScheduleChanged]
);
const addScheduleItem = () => {
const addScheduleItem = useCallback(() => {
setCreating(true);
setSelectedScheduleItem({
id: Math.floor(Math.random() * (Math.floor(200) - 100) + 100),
active: false,
deleted: false,
flags: ScheduleFlag.SCHEDULE_DAY,
time: '',
cmd: '',
value: '',
name: ''
});
const newItem: ScheduleItem = {
id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID),
...DEFAULT_SCHEDULE_ITEM
};
setSelectedScheduleItem(newItem);
setDialogOpen(true);
};
}, []);
const renderSchedule = () => {
const filteredAndSortedSchedule = useMemo(
() =>
schedule
.filter((si: ScheduleItem) => !si.deleted)
.sort((a: ScheduleItem, b: ScheduleItem) => a.flags - b.flags),
[schedule]
);
const dayBox = useCallback(
(si: ScheduleItem, flag: number) => {
const dayIndex = Math.log(flag) / LOG_2;
const isActive = (si.flags & flag) === flag;
return (
<>
<Box>
<Typography sx={{ fontSize: 11 }} color={isActive ? 'primary' : 'grey'}>
{dow[dayIndex]}
</Typography>
</Box>
<Divider orientation="vertical" flexItem />
</>
);
},
[dow]
);
const scheduleType = useCallback((si: ScheduleItem) => {
const label = scheduleTypeLabels[si.flags];
return (
<Box>
<Typography sx={{ fontSize: 11 }} color="primary">
{label || ''}
</Typography>
</Box>
);
}, []);
const renderSchedule = useCallback(() => {
if (!schedule) {
return (
<FormLoader onRetry={fetchSchedule} errorMessage={error?.message || ''} />
);
}
const dayBox = (si: ScheduleItem, flag: number) => (
<>
<Box>
<Typography
sx={{ fontSize: 11 }}
color={(si.flags & flag) === flag ? 'primary' : 'grey'}
>
{dow[Math.log(flag) / Math.log(2)]}
</Typography>
</Box>
<Divider orientation="vertical" flexItem />
</>
);
const scheduleType = (si: ScheduleItem) => (
<Box>
<Typography sx={{ fontSize: 11 }} color="primary">
{si.flags === ScheduleFlag.SCHEDULE_IMMEDIATE ? (
<>Immediate</>
) : si.flags === ScheduleFlag.SCHEDULE_TIMER ? (
<>Timer</>
) : si.flags === ScheduleFlag.SCHEDULE_CONDITION ? (
<>Condition</>
) : si.flags === ScheduleFlag.SCHEDULE_ONCHANGE ? (
<>On Change</>
) : (
<></>
)}
</Typography>
</Box>
);
return (
<Table
data={{
nodes: schedule
.filter((si: ScheduleItem) => !si.deleted)
.sort((a: ScheduleItem, b: ScheduleItem) => a.flags - b.flags)
}}
data={{ nodes: filteredAndSortedSchedule }}
theme={schedule_theme}
layout={{ custom: true }}
>
@@ -275,22 +309,15 @@ const Scheduler = () => {
{tableList.map((si: ScheduleItem) => (
<Row key={si.id} item={si} onClick={() => editScheduleItem(si)}>
<Cell stiff>
{si.active ? (
<CircleIcon
color="success"
sx={{ fontSize: 16, verticalAlign: 'middle' }}
/>
) : (
<CircleIcon
color="error"
sx={{ fontSize: 16, verticalAlign: 'middle' }}
/>
)}
<CircleIcon
color={si.active ? 'success' : 'error'}
sx={{ fontSize: ICON_SIZE, verticalAlign: 'middle' }}
/>
</Cell>
<Cell stiff>
<Stack spacing={0.5} direction="row">
<Divider orientation="vertical" flexItem />
{si.flags > 127 ? (
{si.flags > SCHEDULE_FLAG_THRESHOLD ? (
scheduleType(si)
) : (
<>
@@ -316,14 +343,24 @@ 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 && (
@@ -338,8 +375,8 @@ const Scheduler = () => {
/>
)}
<Box display="flex" flexWrap="wrap">
<Box flexGrow={1}>
<Box sx={{ display: 'flex', flexWrap: 'wrap' }}>
<Box sx={{ flexGrow: 1 }}>
{numChanges !== 0 && (
<ButtonRow>
<Button
@@ -361,7 +398,7 @@ const Scheduler = () => {
</ButtonRow>
)}
</Box>
<Box flexWrap="nowrap" whiteSpace="nowrap">
<Box sx={{ flexWrap: 'nowrap', whiteSpace: 'nowrap' }}>
<ButtonRow>
<Button
startIcon={<AddIcon />}

View File

@@ -1,10 +1,10 @@
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import AddIcon from '@mui/icons-material/Add';
import CancelIcon from '@mui/icons-material/Cancel';
import 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,
@@ -26,11 +26,40 @@ 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 { ScheduleFlag } from './types';
import type { ScheduleItem } from './types';
// Constants
const FLAG_MASK_127 = 127;
const SCHEDULE_TYPE_THRESHOLD = 127;
const FLAG_ALL_DAYS = 127;
const DEFAULT_TIME = '00:00';
const TYPOGRAPHY_FONT_SIZE = 10;
// Day of week flag configuration (static, defined outside component)
const DAY_FLAGS = [
{ value: '2', flag: ScheduleFlag.SCHEDULE_MON },
{ value: '4', flag: ScheduleFlag.SCHEDULE_TUE },
{ value: '8', flag: ScheduleFlag.SCHEDULE_WED },
{ value: '16', flag: ScheduleFlag.SCHEDULE_THU },
{ value: '32', flag: ScheduleFlag.SCHEDULE_FRI },
{ value: '64', flag: ScheduleFlag.SCHEDULE_SAT },
{ value: '1', flag: ScheduleFlag.SCHEDULE_SUN }
] as const;
// Day of week flag values array (static)
const FLAG_VALUES = [
ScheduleFlag.SCHEDULE_SUN,
ScheduleFlag.SCHEDULE_MON,
ScheduleFlag.SCHEDULE_TUE,
ScheduleFlag.SCHEDULE_WED,
ScheduleFlag.SCHEDULE_THU,
ScheduleFlag.SCHEDULE_FRI,
ScheduleFlag.SCHEDULE_SAT
] as const;
interface SchedulerDialogProps {
open: boolean;
creating: boolean;
@@ -53,110 +82,164 @@ const SchedulerDialog = ({
const { LL } = useI18nContext();
const [editItem, setEditItem] = useState<ScheduleItem>(selectedItem);
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const [scheduleType, setScheduleType] = useState<ScheduleFlag>();
const updateFormValue = updateValue(setEditItem);
const updateFormValue = useMemo(
() =>
updateValue(
setEditItem as unknown as React.Dispatch<
React.SetStateAction<Record<string, unknown>>
>
),
[]
);
useEffect(() => {
if (open) {
setFieldErrors(undefined);
setEditItem(selectedItem);
// set the flags based on type when page is loaded...
// Set the flags based on type when page is loaded:
// 0-127 is day schedule
// 128 is timer
// 129 is on change
// 130 is on condition
// 132 is immediate
setScheduleType(
selectedItem.flags < 128 ? ScheduleFlag.SCHEDULE_DAY : selectedItem.flags
selectedItem.flags <= SCHEDULE_TYPE_THRESHOLD
? ScheduleFlag.SCHEDULE_DAY
: selectedItem.flags
);
}
}, [open, selectedItem]);
const save = async () => {
try {
setFieldErrors(undefined);
await validate(validator, editItem);
onSave(editItem);
} catch (error) {
setFieldErrors(error as ValidateFieldsError);
}
};
const saveandactivate = async () => {
editItem.active = true;
try {
setFieldErrors(undefined);
await validate(validator, editItem);
onSave(editItem);
} catch (error) {
setFieldErrors(error as ValidateFieldsError);
}
};
const remove = () => {
editItem.deleted = true;
onSave(editItem);
};
const getFlagDOWnumber = (newFlag: string[]) => {
let new_flag = 0;
for (const entry of newFlag) {
new_flag |= Number(entry);
}
return new_flag & 127;
};
const getFlagDOWstring = (f: number) => {
const new_flags: string[] = [];
if ((f & 129) === 1) {
new_flags.push('1');
}
if ((f & 130) === 2) {
new_flags.push('2');
}
if ((f & 4) === 4) {
new_flags.push('4');
}
if ((f & 8) === 8) {
new_flags.push('8');
}
if ((f & 16) === 16) {
new_flags.push('16');
}
if ((f & 32) === 32) {
new_flags.push('32');
}
if ((f & 64) === 64) {
new_flags.push('64');
}
return new_flags;
};
const showDOW = (si: ScheduleItem, flag: number) => (
<Typography
sx={{ fontSize: 10 }}
color={(si.flags & flag) === flag ? 'primary' : 'grey'}
>
{dow[Math.log(flag) / Math.log(2)]}
</Typography>
// Helper function to handle save operations
const handleSave = useCallback(
async (itemToSave: ScheduleItem) => {
try {
setFieldErrors(undefined);
await validate(validator, itemToSave);
onSave(itemToSave);
} catch (error) {
setFieldErrors((error as ValidationError).fieldErrors);
}
},
[validator, onSave]
);
const handleClose = (
_event: React.SyntheticEvent,
reason: 'backdropClick' | 'escapeKeyDown'
) => {
if (reason !== 'backdropClick') {
onClose();
const save = useCallback(async () => {
await handleSave(editItem);
}, [editItem, handleSave]);
const saveandactivate = useCallback(async () => {
await handleSave({ ...editItem, active: true });
}, [editItem, handleSave]);
const remove = useCallback(() => {
onSave({ ...editItem, deleted: true });
}, [editItem, onSave]);
// Optimize DOW flag conversion
const getFlagDOWnumber = useCallback((flags: string[]) => {
return flags.reduce((acc, flag) => acc | Number(flag), 0) & FLAG_MASK_127;
}, []);
const getFlagDOWstring = useCallback((f: number) => {
return FLAG_VALUES.filter((flag) => (f & flag) === flag).map((flag) =>
String(flag)
);
}, []);
// Day of week display component
const DayOfWeekButton = useCallback(
(flag: number) => {
const dayIndex = Math.log2(flag);
const isSelected = (editItem.flags & flag) === flag;
return (
<Typography
sx={{ fontSize: TYPOGRAPHY_FONT_SIZE }}
color={isSelected ? 'primary' : 'grey'}
>
{dow[dayIndex]}
</Typography>
);
},
[editItem.flags, dow]
);
const handleClose = useCallback(
(_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => {
if (reason !== 'backdropClick') {
onClose();
}
},
[onClose]
);
const handleScheduleTypeChange = useCallback(
(_event: React.SyntheticEvent<HTMLElement>, flag: ScheduleFlag | null) => {
if (flag !== null) {
setFieldErrors(undefined); // clear any validation errors
setScheduleType(flag);
// wipe the time field when changing the schedule type
// set the flags based on type
const newFlags = flag === ScheduleFlag.SCHEDULE_DAY ? 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;
}
};
return editItem.time === DEFAULT_TIME ? '' : editItem.time;
}, [editItem.time, needsTimeField]);
const timeFieldLabel = useMemo(() => {
if (scheduleType === ScheduleFlag.SCHEDULE_TIMER) return LL.TIMER(1);
if (scheduleType === ScheduleFlag.SCHEDULE_CONDITION) return LL.CONDITION();
if (scheduleType === ScheduleFlag.SCHEDULE_ONCHANGE) return LL.ONCHANGE();
if (scheduleType === ScheduleFlag.SCHEDULE_IMMEDIATE) return LL.IMMEDIATE();
return LL.TIME(1);
}, [scheduleType, LL]);
return (
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
<DialogTitle>
{creating ? LL.ADD(1) + ' ' + LL.NEW(0) : LL.EDIT()}&nbsp;
{creating ? `${LL.ADD(1)} ${LL.NEW(0)}` : LL.EDIT()}&nbsp;
{LL.SCHEDULE(1)}
</DialogTitle>
<DialogContent dividers>
@@ -166,47 +249,27 @@ const SchedulerDialog = ({
value={scheduleType}
exclusive
disabled={!creating}
onChange={(_event, flag: ScheduleFlag) => {
if (flag !== null) {
setFieldErrors(undefined); // clear any validation errors
setScheduleType(flag);
// wipe the time field when changing the schedule type
setEditItem({ ...editItem, time: '' });
// set the flags based on type
// 0-127 is day schedule
// 128 is timer
// 129 is on change
// 130 is on condition
// 132 is immediate
setEditItem(
flag === ScheduleFlag.SCHEDULE_DAY
? { ...editItem, flags: 0 }
: { ...editItem, flags: flag }
);
}
}}
onChange={handleScheduleTypeChange}
>
<ToggleButton value={ScheduleFlag.SCHEDULE_DAY}>
<Typography
sx={{ fontSize: 10 }}
color={scheduleType === ScheduleFlag.SCHEDULE_DAY ? 'primary' : 'grey'}
sx={{ fontSize: TYPOGRAPHY_FONT_SIZE }}
color={isDaySchedule ? 'primary' : 'grey'}
>
{LL.SCHEDULE(0)}
</Typography>
</ToggleButton>
<ToggleButton value={ScheduleFlag.SCHEDULE_TIMER}>
<Typography
sx={{ fontSize: 10 }}
color={
scheduleType === ScheduleFlag.SCHEDULE_TIMER ? 'primary' : 'grey'
}
sx={{ fontSize: TYPOGRAPHY_FONT_SIZE }}
color={isTimerSchedule ? 'primary' : 'grey'}
>
{LL.TIMER(0)}
</Typography>
</ToggleButton>
<ToggleButton value={ScheduleFlag.SCHEDULE_ONCHANGE}>
<Typography
sx={{ fontSize: 10 }}
sx={{ fontSize: TYPOGRAPHY_FONT_SIZE }}
color={
scheduleType === ScheduleFlag.SCHEDULE_ONCHANGE ? 'primary' : 'grey'
}
@@ -216,7 +279,7 @@ const SchedulerDialog = ({
</ToggleButton>
<ToggleButton value={ScheduleFlag.SCHEDULE_CONDITION}>
<Typography
sx={{ fontSize: 10 }}
sx={{ fontSize: TYPOGRAPHY_FONT_SIZE }}
color={
scheduleType === ScheduleFlag.SCHEDULE_CONDITION ? 'primary' : 'grey'
}
@@ -226,50 +289,30 @@ const SchedulerDialog = ({
</ToggleButton>
<ToggleButton value={ScheduleFlag.SCHEDULE_IMMEDIATE}>
<Typography
sx={{ fontSize: 10 }}
color={
scheduleType === ScheduleFlag.SCHEDULE_IMMEDIATE ? 'primary' : 'grey'
}
sx={{ fontSize: TYPOGRAPHY_FONT_SIZE }}
color={isImmediateSchedule ? 'primary' : 'grey'}
>
{LL.IMMEDIATE()}
</Typography>
</ToggleButton>
</ToggleButtonGroup>
{scheduleType === ScheduleFlag.SCHEDULE_DAY && (
{isDaySchedule && (
<ToggleButtonGroup
size="small"
color="secondary"
value={getFlagDOWstring(editItem.flags)}
onChange={(_event, flag: string[]) => {
setEditItem({ ...editItem, flags: getFlagDOWnumber(flag) });
}}
value={dowFlags}
onChange={handleDOWChange}
>
<ToggleButton value="2">
{showDOW(editItem, ScheduleFlag.SCHEDULE_MON)}
</ToggleButton>
<ToggleButton value="4">
{showDOW(editItem, ScheduleFlag.SCHEDULE_TUE)}
</ToggleButton>
<ToggleButton value="8">
{showDOW(editItem, ScheduleFlag.SCHEDULE_WED)}
</ToggleButton>
<ToggleButton value="16">
{showDOW(editItem, ScheduleFlag.SCHEDULE_THU)}
</ToggleButton>
<ToggleButton value="32">
{showDOW(editItem, ScheduleFlag.SCHEDULE_FRI)}
</ToggleButton>
<ToggleButton value="64">
{showDOW(editItem, ScheduleFlag.SCHEDULE_SAT)}
</ToggleButton>
<ToggleButton value="1">
{showDOW(editItem, ScheduleFlag.SCHEDULE_SUN)}
</ToggleButton>
{DAY_FLAGS.map(({ value, flag }) => (
<ToggleButton key={value} value={value}>
{DayOfWeekButton(flag)}
</ToggleButton>
))}
</ToggleButtonGroup>
)}
{scheduleType !== ScheduleFlag.SCHEDULE_IMMEDIATE && (
{!isImmediateSchedule && (
<>
<Grid container>
<BlockFormControlLabel
@@ -284,42 +327,33 @@ const SchedulerDialog = ({
/>
</Grid>
<Grid container>
{scheduleType === ScheduleFlag.SCHEDULE_DAY ||
scheduleType === ScheduleFlag.SCHEDULE_TIMER ? (
{needsTimeField ? (
<>
<TextField
name="time"
type="time"
label={
scheduleType === ScheduleFlag.SCHEDULE_TIMER
? LL.TIMER(1)
: LL.TIME(1)
}
value={editItem.time === '' ? '00:00' : editItem.time}
label={timeFieldLabel}
value={timeFieldValue}
margin="normal"
onChange={updateFormValue}
/>
{scheduleType === ScheduleFlag.SCHEDULE_TIMER && (
<Box color="warning.main" ml={2} mt={4}>
<Typography variant="body2">
{LL.SCHEDULER_HELP_2()}
</Typography>
</Box>
{isTimerSchedule && (
<Typography
sx={{ ml: 2, mt: 4 }}
color="warning"
variant="body2"
>
{LL.SCHEDULER_HELP_2()}
</Typography>
)}
</>
) : (
<TextField
name="time"
label={
scheduleType === ScheduleFlag.SCHEDULE_CONDITION
? LL.CONDITION()
: scheduleType === ScheduleFlag.SCHEDULE_ONCHANGE
? LL.ONCHANGE()
: LL.IMMEDIATE()
}
label={timeFieldLabel}
multiline
fullWidth
value={editItem.time === '00:00' ? '' : editItem.time}
value={timeFieldValue}
margin="normal"
onChange={updateFormValue}
/>
@@ -359,7 +393,7 @@ const SchedulerDialog = ({
<DialogActions>
{!creating && (
<Box flexGrow={1}>
<Box sx={{ flexGrow: 1 }}>
<Button
startIcon={<RemoveIcon />}
variant="outlined"
@@ -386,7 +420,7 @@ const SchedulerDialog = ({
>
{creating ? LL.ADD(0) : LL.UPDATE()}
</Button>
{scheduleType === ScheduleFlag.SCHEDULE_IMMEDIATE && editItem.cmd !== '' && (
{isImmediateSchedule && editItem.cmd !== '' && (
<Button
startIcon={<PlayArrowIcon />}
variant="outlined"

View File

@@ -1,4 +1,4 @@
import { useContext, useState } from 'react';
import { useCallback, useContext, useMemo, useRef, useState } from 'react';
import { toast } from 'react-toastify';
import AddCircleOutlineOutlinedIcon from '@mui/icons-material/AddCircleOutlineOutlined';
@@ -49,6 +49,74 @@ import {
temperatureSensorItemValidation
} from './validators';
// Constants
const MS_PER_SECOND = 1000;
const MS_PER_MINUTE = 60 * MS_PER_SECOND;
const MS_PER_HOUR = 60 * MS_PER_MINUTE;
const MS_PER_DAY = 24 * MS_PER_HOUR;
const MIN_TEMP_ID = -100;
const MAX_TEMP_ID = 100;
const GPIO_25 = 25;
const GPIO_26 = 26;
const HEADER_BUTTON_STYLE: React.CSSProperties = {
fontSize: '14px',
justifyContent: 'flex-start'
};
const HEADER_BUTTON_STYLE_END: React.CSSProperties = {
fontSize: '14px',
justifyContent: 'flex-end'
};
const common_theme = {
BaseRow: `
font-size: 14px;
.td {
height: 32px;
}
`,
HeaderRow: `
text-transform: uppercase;
background-color: black;
color: #90CAF9;
.th {
border-bottom: 1px solid #565656;
height: 36px;
}
`,
Row: `
background-color: #1e1e1e;
position: relative;
cursor: pointer;
.td {
padding: 8px;
border-bottom: 1px solid #565656;
}
&:hover .td {
background-color: #177ac9;
color: white;
}
`,
Cell: `
&:last-of-type {
text-align: right;
},
`
};
const temperature_theme_config = {
Table: `
--data-table-library_grid-template-columns: minmax(0, 1fr) 35%;
`
};
const analog_theme_config = {
Table: `
--data-table-library_grid-template-columns: 80px repeat(1, minmax(0, 1fr)) 120px 110px;
`
};
const Sensors = () => {
const { LL } = useI18nContext();
const { me } = useContext(AuthenticatedContext);
@@ -59,18 +127,22 @@ const Sensors = () => {
const [temperatureDialogOpen, setTemperatureDialogOpen] = useState<boolean>(false);
const [analogDialogOpen, setAnalogDialogOpen] = useState<boolean>(false);
const [creating, setCreating] = useState<boolean>(false);
const firstAvailableGPIO = useRef<number>(undefined);
const { data: sensorData, send: fetchSensorData } = useRequest(
() => readSensorData(),
{
initialData: {
ts: [],
as: [],
analog_enabled: false,
platform: 'ESP32'
}
const { data: sensorData, send: fetchSensorData } = useRequest(readSensorData, {
initialData: {
ts: [],
as: [],
analog_enabled: false,
available_gpios: [] as number[],
platform: 'ESP32'
}
);
}).onSuccess((event) => {
// store the first available GPIO in a ref
if (event.data.available_gpios.length > 0) {
firstAvailableGPIO.current = event.data.available_gpios[0];
}
});
const { send: sendTemperatureSensor } = useRequest(
(data: WriteTemperatureSensor) => writeTemperatureSensor(data),
@@ -86,116 +158,18 @@ const Sensors = () => {
}
);
useInterval(() => {
const intervalCallback = useCallback(() => {
if (!temperatureDialogOpen && !analogDialogOpen) {
void fetchSensorData();
}
});
}, [temperatureDialogOpen, analogDialogOpen, fetchSensorData]);
const common_theme = useTheme({
BaseRow: `
font-size: 14px;
.td {
height: 32px;
}
`,
HeaderRow: `
text-transform: uppercase;
background-color: black;
color: #90CAF9;
.th {
border-bottom: 1px solid #565656;
height: 36px;
}
`,
Row: `
background-color: #1e1e1e;
position: relative;
cursor: pointer;
.td {
padding: 8px;
border-bottom: 1px solid #565656;
}
&:hover .td {
background-color: #177ac9;
}
`,
Cell: `
&:last-of-type {
text-align: right;
},
`
});
useInterval(intervalCallback);
const temperature_theme = useTheme([
common_theme,
{
Table: `
--data-table-library_grid-template-columns: minmax(0, 1fr) 35%;
`
}
]);
const temperature_theme = useTheme([common_theme, temperature_theme_config]);
const analog_theme = useTheme([common_theme, analog_theme_config]);
const analog_theme = useTheme([
common_theme,
{
Table: `
--data-table-library_grid-template-columns: 80px repeat(1, minmax(0, 1fr)) 120px 110px;
`
}
]);
const RenderTemperatureSensors = () => (
<Table
data={{ nodes: sensorData.ts }}
theme={temperature_theme}
sort={temperature_sort}
layout={{ custom: true }}
>
{(tableList: TemperatureSensor[]) => (
<>
<Header>
<HeaderRow>
<HeaderCell resize>
<Button
fullWidth
style={{ fontSize: '14px', justifyContent: 'flex-start' }}
endIcon={getSortIcon(temperature_sort.state, 'NAME')}
onClick={() =>
temperature_sort.fns.onToggleSort({ sortKey: 'NAME' })
}
>
{LL.NAME(0)}
</Button>
</HeaderCell>
<HeaderCell stiff>
<Button
fullWidth
style={{ fontSize: '14px', justifyContent: 'flex-end' }}
endIcon={getSortIcon(temperature_sort.state, 'VALUE')}
onClick={() =>
temperature_sort.fns.onToggleSort({ sortKey: 'VALUE' })
}
>
{LL.VALUE(0)}
</Button>
</HeaderCell>
</HeaderRow>
</Header>
<Body>
{tableList.map((ts: TemperatureSensor) => (
<Row key={ts.id} item={ts} onClick={() => updateTemperatureSensor(ts)}>
<Cell>{ts.n}</Cell>
<Cell>{formatValue(ts.t, ts.u)}</Cell>
</Row>
))}
</Body>
</>
)}
</Table>
);
const getSortIcon = (state: State, sortKey: unknown) => {
const getSortIcon = useCallback((state: State, sortKey: unknown) => {
if (state.sortKey === sortKey && state.reverse) {
return <KeyboardArrowDownOutlinedIcon />;
}
@@ -203,7 +177,7 @@ const Sensors = () => {
return <KeyboardArrowUpOutlinedIcon />;
}
return <UnfoldMoreOutlinedIcon />;
};
}, []);
const analog_sort = useSort(
{ nodes: sensorData.as },
@@ -216,11 +190,20 @@ const Sensors = () => {
},
sortToggleType: SortToggleType.AlternateWithReset,
sortFns: {
GPIO: (array) => array.sort((a, b) => a.g - b.g),
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
NAME: (array) => array.sort((a, b) => a.n.localeCompare(b.n)),
TYPE: (array) => array.sort((a, b) => a.t - b.t),
VALUE: (array) => array.sort((a, b) => a.v - b.v)
GPIO: (array) =>
[...array].sort(
(a, b) => ((a as AnalogSensor)?.g ?? 0) - ((b as AnalogSensor)?.g ?? 0)
),
NAME: (array) =>
[...array].sort((a, b) =>
((a as AnalogSensor)?.n ?? '').localeCompare(
(b as AnalogSensor)?.n ?? ''
)
),
TYPE: (array) =>
[...array].sort((a, b) => (a as AnalogSensor).t - (b as AnalogSensor).t),
VALUE: (array) =>
[...array].sort((a, b) => (a as AnalogSensor).v - (b as AnalogSensor).v)
}
}
);
@@ -236,227 +219,349 @@ const Sensors = () => {
},
sortToggleType: SortToggleType.AlternateWithReset,
sortFns: {
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
NAME: (array) => array.sort((a, b) => a.n.localeCompare(b.n)),
VALUE: (array) => array.sort((a, b) => a.t - b.t)
NAME: (array) =>
[...array].sort((a, b) =>
(a as TemperatureSensor).n.localeCompare((b as TemperatureSensor).n)
),
VALUE: (array) =>
[...array].sort(
(a, b) =>
((a as TemperatureSensor).t ?? 0) - ((b as TemperatureSensor).t ?? 0)
)
}
}
);
useLayoutTitle(LL.SENSORS());
const formatDurationMin = (duration_min: number) => {
const days = Math.trunc((duration_min * 60000) / 86400000);
const hours = Math.trunc((duration_min * 60000) / 3600000) % 24;
const minutes = Math.trunc((duration_min * 60000) / 60000) % 60;
const formatDurationMin = useCallback(
(duration_min: number) => {
const totalMs = duration_min * MS_PER_MINUTE;
const days = Math.trunc(totalMs / MS_PER_DAY);
const hours = Math.trunc(totalMs / MS_PER_HOUR) % 24;
const minutes = Math.trunc(totalMs / MS_PER_MINUTE) % 60;
let formatted = '';
if (days) {
formatted += LL.NUM_DAYS({ num: days }) + ' ';
}
if (hours) {
formatted += LL.NUM_HOURS({ num: hours }) + ' ';
}
if (minutes) {
formatted += LL.NUM_MINUTES({ num: minutes });
}
return formatted;
};
const parts: string[] = [];
if (days > 0) {
parts.push(LL.NUM_DAYS({ num: days }));
}
if (hours > 0) {
parts.push(LL.NUM_HOURS({ num: hours }));
}
if (minutes > 0) {
parts.push(LL.NUM_MINUTES({ num: minutes }));
}
return parts.join(' ');
},
[LL]
);
function formatValue(value: unknown, uom: DeviceValueUOM) {
if (value === undefined) {
return '';
}
if (typeof value !== 'number') {
return value as string;
}
switch (uom) {
case DeviceValueUOM.HOURS:
return value ? formatDurationMin(value * 60) : LL.NUM_HOURS({ num: 0 });
case DeviceValueUOM.MINUTES:
return value ? formatDurationMin(value) : LL.NUM_MINUTES({ num: 0 });
case DeviceValueUOM.SECONDS:
return LL.NUM_SECONDS({ num: value });
case DeviceValueUOM.NONE:
return new Intl.NumberFormat().format(value);
case DeviceValueUOM.DEGREES:
case DeviceValueUOM.DEGREES_R:
case DeviceValueUOM.FAHRENHEIT:
return (
new Intl.NumberFormat(undefined, {
minimumFractionDigits: 1
}).format(value) +
' ' +
DeviceValueUOM_s[uom]
);
default:
return new Intl.NumberFormat().format(value) + ' ' + DeviceValueUOM_s[uom];
}
}
const formatValue = useCallback(
(value: unknown, uom: DeviceValueUOM) => {
if (value === undefined) {
return '';
}
if (typeof value !== 'number') {
return value as string;
}
switch (uom) {
case DeviceValueUOM.HOURS:
return value ? formatDurationMin(value * 60) : LL.NUM_HOURS({ num: 0 });
case DeviceValueUOM.MINUTES:
return value ? formatDurationMin(value) : LL.NUM_MINUTES({ num: 0 });
case DeviceValueUOM.SECONDS:
return LL.NUM_SECONDS({ num: value });
case DeviceValueUOM.NONE:
return new Intl.NumberFormat().format(value);
case DeviceValueUOM.DEGREES:
case DeviceValueUOM.DEGREES_R:
case DeviceValueUOM.FAHRENHEIT:
return (
new Intl.NumberFormat(undefined, {
minimumFractionDigits: 1
}).format(value) +
' ' +
DeviceValueUOM_s[uom]
);
default:
return new Intl.NumberFormat().format(value) + ' ' + DeviceValueUOM_s[uom];
}
},
[formatDurationMin, LL]
);
const updateTemperatureSensor = (ts: TemperatureSensor) => {
if (me.admin) {
ts.o_n = ts.n;
setSelectedTemperatureSensor(ts);
setTemperatureDialogOpen(true);
}
};
const updateTemperatureSensor = useCallback(
(ts: TemperatureSensor) => {
if (me.admin) {
ts.o_n = ts.n;
setSelectedTemperatureSensor(ts);
setTemperatureDialogOpen(true);
}
},
[me.admin]
);
const onTemperatureDialogClose = () => {
const onTemperatureDialogClose = useCallback(() => {
setTemperatureDialogOpen(false);
void fetchSensorData();
};
}, [fetchSensorData]);
const onTemperatureDialogSave = async (ts: TemperatureSensor) => {
await sendTemperatureSensor({ id: ts.id, name: ts.n, offset: ts.o })
.then(() => {
toast.success(LL.UPDATED_OF(LL.SENSOR(1)));
const onTemperatureDialogSave = useCallback(
async (ts: TemperatureSensor) => {
await sendTemperatureSensor({
id: ts.id,
name: ts.n,
offset: ts.o,
is_system: ts.s
})
.catch(() => {
toast.error(LL.UPDATE_OF(LL.SENSOR(2)) + ' ' + LL.FAILED(1));
})
.finally(() => {
setTemperatureDialogOpen(false);
setSelectedTemperatureSensor(undefined);
void fetchSensorData();
});
};
.then(() => {
toast.success(LL.UPDATED_OF(LL.SENSOR(1)));
})
.catch(() => {
toast.error(LL.UPDATE_OF(LL.SENSOR(2)) + ' ' + LL.FAILED(1));
})
.finally(() => {
setTemperatureDialogOpen(false);
setSelectedTemperatureSensor(undefined);
void fetchSensorData();
});
},
[sendTemperatureSensor, LL, fetchSensorData]
);
const updateAnalogSensor = (as: AnalogSensor) => {
if (me.admin) {
setCreating(false);
as.o_n = as.n;
setSelectedAnalogSensor(as);
setAnalogDialogOpen(true);
}
};
const updateAnalogSensor = useCallback(
(as: AnalogSensor) => {
if (me.admin) {
setCreating(false);
as.o_n = as.n;
setSelectedAnalogSensor(as);
setAnalogDialogOpen(true);
}
},
[me.admin]
);
const onAnalogDialogClose = () => {
const onAnalogDialogClose = useCallback(() => {
setAnalogDialogOpen(false);
void fetchSensorData();
};
}, [fetchSensorData]);
const addAnalogSensor = () => {
const addAnalogSensor = useCallback(() => {
if (firstAvailableGPIO.current === undefined) {
toast.error(LL.NO_GPIO());
return;
}
setCreating(true);
setSelectedAnalogSensor({
id: Math.floor(Math.random() * (Math.floor(200) - 100) + 100),
id: Math.floor(Math.random() * (MAX_TEMP_ID - MIN_TEMP_ID) + MIN_TEMP_ID),
n: '',
g: 21, // default GPIO 21 which is safe for all platforms
u: 0,
g: firstAvailableGPIO.current,
u: DeviceValueUOM.NONE,
v: 0,
o: 0,
t: 0,
f: 1,
t: AnalogType.DIGITAL_IN, // default to digital in 1
d: false,
s: false,
o_n: ''
});
setAnalogDialogOpen(true);
};
}, []);
const onAnalogDialogSave = async (as: AnalogSensor) => {
await sendAnalogSensor({
id: as.id,
gpio: as.g,
name: as.n,
offset: as.o,
factor: as.f,
uom: as.u,
type: as.t,
deleted: as.d
})
.then(() => {
toast.success(LL.UPDATED_OF(LL.ANALOG_SENSOR(2)));
const onAnalogDialogSave = useCallback(
async (as: AnalogSensor) => {
await sendAnalogSensor({
id: as.id,
gpio: as.g,
name: as.n,
offset: as.o,
factor: as.f,
uom: as.u,
type: as.t,
deleted: as.d,
is_system: as.s
})
.catch(() => {
toast.error(LL.UPDATE_OF(LL.ANALOG_SENSOR(5)) + ' ' + LL.FAILED(1));
})
.finally(() => {
setAnalogDialogOpen(false);
setSelectedAnalogSensor(undefined);
void fetchSensorData();
});
};
.then(() => {
toast.success(LL.UPDATED_OF(LL.ANALOG_SENSOR(2)));
})
.catch(() => {
toast.error(LL.UPDATE_OF(LL.ANALOG_SENSOR(5)) + ' ' + LL.FAILED(1));
})
.finally(() => {
setAnalogDialogOpen(false);
setSelectedAnalogSensor(undefined);
void fetchSensorData();
});
},
[sendAnalogSensor, LL, fetchSensorData]
);
const RenderAnalogSensors = () => (
<Table
data={{ nodes: sensorData.as }}
theme={analog_theme}
sort={analog_sort}
layout={{ custom: true }}
>
{(tableList: AnalogSensor[]) => (
<>
<Header>
<HeaderRow>
<HeaderCell stiff>
<Button
fullWidth
style={{ fontSize: '14px', justifyContent: 'flex-start' }}
endIcon={getSortIcon(analog_sort.state, 'GPIO')}
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'GPIO' })}
const RenderAnalogSensors = useMemo(
() => (
<Table
data={{ nodes: sensorData.as }}
theme={analog_theme}
sort={analog_sort}
layout={{ custom: true }}
>
{(tableList: AnalogSensor[]) => (
<>
<Header>
<HeaderRow>
<HeaderCell stiff>
<Button
fullWidth
style={HEADER_BUTTON_STYLE}
endIcon={getSortIcon(analog_sort.state, 'GPIO')}
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'GPIO' })}
>
GPIO
</Button>
</HeaderCell>
<HeaderCell resize>
<Button
fullWidth
style={HEADER_BUTTON_STYLE}
endIcon={getSortIcon(analog_sort.state, 'NAME')}
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'NAME' })}
>
{LL.NAME(0)}
</Button>
</HeaderCell>
<HeaderCell stiff>
<Button
fullWidth
style={HEADER_BUTTON_STYLE}
endIcon={getSortIcon(analog_sort.state, 'TYPE')}
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'TYPE' })}
>
{LL.TYPE(0)}
</Button>
</HeaderCell>
<HeaderCell stiff>
<Button
fullWidth
style={HEADER_BUTTON_STYLE_END}
endIcon={getSortIcon(analog_sort.state, 'VALUE')}
onClick={() =>
analog_sort.fns.onToggleSort({ sortKey: 'VALUE' })
}
>
{LL.VALUE(0)}
</Button>
</HeaderCell>
</HeaderRow>
</Header>
<Body>
{tableList.map((as: AnalogSensor) => (
<Row
style={{ color: as.s ? 'grey' : 'inherit' }}
key={as.id}
item={as}
onClick={() => updateAnalogSensor(as)}
>
GPIO
</Button>
</HeaderCell>
<HeaderCell resize>
<Button
fullWidth
style={{ fontSize: '14px', justifyContent: 'flex-start' }}
endIcon={getSortIcon(analog_sort.state, 'NAME')}
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'NAME' })}
<Cell stiff>{as.g}</Cell>
<Cell>{as.n}</Cell>
<Cell stiff>{AnalogTypeNames[as.t - 1]} </Cell>
{(as.t === AnalogType.DIGITAL_OUT &&
as.g !== GPIO_25 &&
as.g !== GPIO_26) ||
as.t === AnalogType.DIGITAL_IN ||
as.t === AnalogType.PULSE ? (
<Cell stiff>{as.v ? LL.ON() : LL.OFF()}</Cell>
) : (
<Cell stiff>{formatValue(as.v, as.u)}</Cell>
)}
</Row>
))}
</Body>
</>
)}
</Table>
),
[
analog_sort,
analog_theme,
getSortIcon,
sensorData.as,
LL,
updateAnalogSensor,
formatValue
]
);
const RenderTemperatureSensors = useMemo(
() => (
<Table
data={{ nodes: sensorData.ts }}
theme={temperature_theme}
sort={temperature_sort}
layout={{ custom: true }}
>
{(tableList: TemperatureSensor[]) => (
<>
<Header>
<HeaderRow>
<HeaderCell resize>
<Button
fullWidth
style={HEADER_BUTTON_STYLE}
endIcon={getSortIcon(temperature_sort.state, 'NAME')}
onClick={() =>
temperature_sort.fns.onToggleSort({ sortKey: 'NAME' })
}
>
{LL.NAME(0)}
</Button>
</HeaderCell>
<HeaderCell stiff>
<Button
fullWidth
style={HEADER_BUTTON_STYLE_END}
endIcon={getSortIcon(temperature_sort.state, 'VALUE')}
onClick={() =>
temperature_sort.fns.onToggleSort({ sortKey: 'VALUE' })
}
>
{LL.VALUE(0)}
</Button>
</HeaderCell>
</HeaderRow>
</Header>
<Body>
{tableList.map((ts: TemperatureSensor) => (
<Row
style={{ color: ts.s ? 'grey' : 'inherit' }}
key={ts.id}
item={ts}
onClick={() => updateTemperatureSensor(ts)}
>
{LL.NAME(0)}
</Button>
</HeaderCell>
<HeaderCell stiff>
<Button
fullWidth
style={{ fontSize: '14px', justifyContent: 'flex-start' }}
endIcon={getSortIcon(analog_sort.state, 'TYPE')}
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'TYPE' })}
>
{LL.TYPE(0)}
</Button>
</HeaderCell>
<HeaderCell stiff>
<Button
fullWidth
style={{ fontSize: '14px', justifyContent: 'flex-end' }}
endIcon={getSortIcon(analog_sort.state, 'VALUE')}
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'VALUE' })}
>
{LL.VALUE(0)}
</Button>
</HeaderCell>
</HeaderRow>
</Header>
<Body>
{tableList.map((a: AnalogSensor) => (
<Row key={a.id} item={a} onClick={() => updateAnalogSensor(a)}>
<Cell stiff>{a.g}</Cell>
<Cell>{a.n}</Cell>
<Cell stiff>{AnalogTypeNames[a.t]} </Cell>
{(a.t === AnalogType.DIGITAL_OUT && a.g !== 25 && a.g !== 26) ||
a.t === AnalogType.DIGITAL_IN ||
a.t === AnalogType.PULSE ? (
<Cell stiff>{a.v ? LL.ON() : LL.OFF()}</Cell>
) : (
<Cell stiff>{a.t ? formatValue(a.v, a.u) : ''}</Cell>
)}
</Row>
))}
</Body>
</>
)}
</Table>
<Cell>{ts.n}</Cell>
<Cell>{formatValue(ts.t, ts.u)}</Cell>
</Row>
))}
</Body>
</>
)}
</Table>
),
[
temperature_sort,
temperature_theme,
getSortIcon,
sensorData.ts,
LL,
updateTemperatureSensor,
formatValue
]
);
return (
<SectionContent>
<Typography sx={{ pb: 1 }} variant="h6" color="secondary">
<Typography sx={{ pb: 1 }} variant="h6" color="primary">
{LL.TEMP_SENSORS()}
</Typography>
<RenderTemperatureSensors />
{RenderTemperatureSensors}
{selectedTemperatureSensor && (
<DashboardSensorsTemperatureDialog
open={temperatureDialogOpen}
@@ -469,10 +574,10 @@ const Sensors = () => {
)}
/>
)}
<Typography sx={{ pt: 4, pb: 1 }} variant="h6" color="secondary">
<Typography sx={{ pt: 4, pb: 1 }} variant="h6" color="primary">
{LL.ANALOG_SENSORS()}
</Typography>
<RenderAnalogSensors />
{RenderAnalogSensors}
{selectedAnalogSensor && (
<DashboardSensorsAnalogDialog
open={analogDialogOpen}
@@ -480,16 +585,20 @@ const Sensors = () => {
onSave={onAnalogDialogSave}
creating={creating}
selectedItem={selectedAnalogSensor}
validator={analogSensorItemValidation(
sensorData.as,
selectedAnalogSensor,
creating,
sensorData.platform
)}
analogGPIOList={sensorData.available_gpios}
disabledTypeList={sensorData.exclude_types}
validator={analogSensorItemValidation(sensorData.as, selectedAnalogSensor)}
/>
)}
{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"

View File

@@ -1,7 +1,8 @@
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel';
import RemoveIcon from '@mui/icons-material/RemoveCircleOutline';
import DoneIcon from '@mui/icons-material/Done';
import RemoveIcon from '@mui/icons-material/RemoveCircleOutlined';
import WarningIcon from '@mui/icons-material/Warning';
import {
Box,
@@ -13,7 +14,6 @@ import {
Grid,
InputAdornment,
MenuItem,
TextField,
Typography
} from '@mui/material';
@@ -23,7 +23,7 @@ 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 { AnalogType, AnalogTypeNames, DeviceValueUOM_s } from './types';
import type { AnalogSensor } from './types';
@@ -34,6 +34,8 @@ interface DashboardSensorsAnalogDialogProps {
onSave: (as: AnalogSensor) => void;
creating: boolean;
selectedItem: AnalogSensor;
analogGPIOList: number[];
disabledTypeList: number[];
validator: Schema;
}
@@ -43,13 +45,111 @@ const SensorsAnalogDialog = ({
onSave,
creating,
selectedItem,
analogGPIOList,
disabledTypeList,
validator
}: DashboardSensorsAnalogDialogProps) => {
const { LL } = useI18nContext();
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const [editItem, setEditItem] = useState<AnalogSensor>(selectedItem);
const updateFormValue = updateValue(setEditItem);
const updateFormValue = useMemo(
() =>
updateValue((updater) =>
setEditItem(
(prev) =>
updater(
prev as unknown as Record<string, unknown>
) as unknown as AnalogSensor
)
),
[setEditItem]
);
// Memoize helper functions to check sensor type conditions
const isCounterOrRate = useMemo(
() =>
editItem.t === AnalogType.COUNTER ||
editItem.t === AnalogType.RATE ||
(editItem.t >= AnalogType.CNT_0 && editItem.t <= AnalogType.CNT_2),
[editItem.t]
);
const isCounter = useMemo(
() =>
editItem.t === AnalogType.COUNTER ||
(editItem.t >= AnalogType.CNT_0 && editItem.t <= AnalogType.CNT_2),
[editItem.t]
);
const isFreqType = useMemo(
() => editItem.t >= AnalogType.FREQ_0 && editItem.t <= AnalogType.FREQ_2,
[editItem.t]
);
const isPWM = useMemo(
() =>
editItem.t === AnalogType.PWM_0 ||
editItem.t === AnalogType.PWM_1 ||
editItem.t === AnalogType.PWM_2,
[editItem.t]
);
const isDACOutGPIO = useMemo(
() =>
editItem.t === AnalogType.DIGITAL_OUT &&
(editItem.g === 25 || editItem.g === 26),
[editItem.t, editItem.g]
);
const isDigitalOutGPIO = useMemo(
() =>
editItem.t === AnalogType.DIGITAL_OUT &&
editItem.g !== 25 &&
editItem.g !== 26,
[editItem.t, editItem.g]
);
// Memoize menu items to avoid recreation on each render
const analogTypeMenuItems = useMemo(
() =>
AnalogTypeNames.map((val, i) => ({ name: val, value: i + 1 }))
.sort((a, b) => a.name.localeCompare(b.name))
.map(({ name, value }) => (
<MenuItem
key={name}
value={value}
disabled={disabledTypeList?.includes(value)}
>
{name}
</MenuItem>
)),
[disabledTypeList]
);
const uomMenuItems = useMemo(
() =>
DeviceValueUOM_s.map((val, i) => (
<MenuItem key={val} value={i}>
{val}
</MenuItem>
)),
[]
);
const analogGPIOMenuItems = () =>
// add selectedItem.g to the list
[
...(analogGPIOList?.includes(selectedItem.g) || selectedItem.g === undefined
? analogGPIOList
: [selectedItem.g, ...analogGPIOList])
]
.filter((gpio, idx, arr) => arr.indexOf(gpio) === idx)
.sort((a, b) => a - b)
.map((gpio: number) => {
return (
<MenuItem key={gpio} value={gpio}>
{gpio}
</MenuItem>
);
});
// Reset form when dialog opens or selectedItem changes
useEffect(() => {
if (open) {
setFieldErrors(undefined);
@@ -57,114 +157,101 @@ const SensorsAnalogDialog = ({
}
}, [open, selectedItem]);
const handleClose = (
_event: React.SyntheticEvent,
reason: 'backdropClick' | 'escapeKeyDown'
) => {
if (reason !== 'backdropClick') {
onClose();
}
};
const handleClose = useCallback(
(_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => {
if (reason !== 'backdropClick') {
onClose();
}
},
[onClose]
);
const save = async () => {
const save = useCallback(async () => {
try {
setFieldErrors(undefined);
await validate(validator, editItem);
onSave(editItem);
} catch (error) {
setFieldErrors(error as ValidateFieldsError);
setFieldErrors((error as ValidationError).fieldErrors);
}
};
}, [validator, editItem, onSave]);
const remove = () => {
editItem.d = true;
onSave(editItem);
};
const remove = useCallback(() => {
onSave({ ...editItem, d: true });
}, [editItem, onSave]);
const dialogTitle = useMemo(
() =>
`${creating ? LL.ADD(1) + ' ' + LL.NEW(0) : LL.EDIT()} ${LL.ANALOG_SENSOR(0)}`,
[creating, LL]
);
return (
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
<DialogTitle>
{creating ? LL.ADD(1) + ' ' + LL.NEW(0) : LL.EDIT()}&nbsp;
{LL.ANALOG_SENSOR(0)}
</DialogTitle>
<DialogTitle>{dialogTitle}</DialogTitle>
<DialogContent dividers>
<Grid container spacing={2}>
<ValidatedTextField
name="g"
label="GPIO"
value={editItem.g}
sx={{ width: '9ch' }}
disabled={editItem.s || !creating}
select
onChange={updateFormValue}
>
{analogGPIOMenuItems()}
</ValidatedTextField>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
name="g"
label="GPIO"
sx={{ width: '11ch' }}
value={numberValue(editItem.g)}
type="number"
variant="outlined"
onChange={updateFormValue}
/>
</Grid>
{creating && (
<Grid>
<Box color="warning.main" mt={2}>
<Typography variant="body2">{LL.WARN_GPIO()}</Typography>
</Box>
</Grid>
)}
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
name="n"
label={LL.NAME(0)}
value={editItem.n}
fullWidth
variant="outlined"
onChange={updateFormValue}
/>
</Grid>
<Grid>
<TextField
<ValidatedTextField
name="t"
label={LL.TYPE(0)}
value={editItem.t}
fullWidth
select
onChange={updateFormValue}
disabled={editItem.s}
>
{AnalogTypeNames.map((val, i) => (
<MenuItem key={val} value={i}>
{val}
</MenuItem>
))}
</TextField>
{analogTypeMenuItems}
</ValidatedTextField>
</Grid>
{((editItem.t >= AnalogType.COUNTER && editItem.t <= AnalogType.RATE) ||
(editItem.t >= AnalogType.FREQ_0 &&
editItem.t <= AnalogType.FREQ_2)) && (
{(isCounterOrRate ||
isFreqType ||
editItem.t === AnalogType.ADC ||
editItem.t === AnalogType.TIMER) && (
<Grid>
<TextField
<ValidatedTextField
name="u"
label={LL.UNIT()}
value={editItem.u}
sx={{ width: '15ch' }}
select
onChange={updateFormValue}
disabled={editItem.s}
>
{DeviceValueUOM_s.map((val, i) => (
<MenuItem key={val} value={i}>
{val}
</MenuItem>
))}
</TextField>
{uomMenuItems}
</ValidatedTextField>
</Grid>
)}
{editItem.t === AnalogType.ADC && (
<Grid>
<TextField
<ValidatedTextField
name="o"
label={LL.OFFSET()}
value={numberValue(editItem.o)}
type="number"
sx={{ width: '11ch' }}
variant="outlined"
onChange={updateFormValue}
disabled={editItem.s}
slotProps={{
input: {
startAdornment: (
@@ -178,14 +265,14 @@ const SensorsAnalogDialog = ({
)}
{editItem.t === AnalogType.NTC && (
<Grid>
<TextField
<ValidatedTextField
name="o"
label={LL.OFFSET()}
value={numberValue(editItem.o)}
sx={{ width: '11ch' }}
type="number"
variant="outlined"
onChange={updateFormValue}
disabled={editItem.s}
slotProps={{
input: {
startAdornment: (
@@ -197,16 +284,16 @@ const SensorsAnalogDialog = ({
/>
</Grid>
)}
{editItem.t === AnalogType.COUNTER && (
{isCounter && (
<Grid>
<TextField
<ValidatedTextField
name="o"
label={LL.STARTVALUE()}
value={numberValue(editItem.o)}
type="number"
sx={{ width: '11ch' }}
variant="outlined"
onChange={updateFormValue}
disabled={editItem.s}
slotProps={{
htmlInput: { step: '0.001' }
}}
@@ -215,113 +302,113 @@ const SensorsAnalogDialog = ({
)}
{editItem.t === AnalogType.RGB && (
<Grid>
<TextField
<ValidatedTextField
name="o"
label={'RGB ' + LL.VALUE(0)}
value={numberValue(editItem.o)}
type="number"
sx={{ width: '11ch' }}
variant="outlined"
onChange={updateFormValue}
disabled={editItem.s}
/>
</Grid>
)}
{editItem.t >= AnalogType.COUNTER && editItem.t <= AnalogType.RATE && (
{(isCounterOrRate ||
isFreqType ||
editItem.t === AnalogType.ADC ||
editItem.t === AnalogType.TIMER) && (
<Grid>
<TextField
<ValidatedTextField
name="f"
label={LL.FACTOR()}
value={numberValue(editItem.f)}
sx={{ width: '11ch' }}
sx={{ width: '14ch' }}
type="number"
variant="outlined"
onChange={updateFormValue}
disabled={editItem.s}
slotProps={{
htmlInput: { step: '0.001' }
}}
/>
</Grid>
)}
{editItem.t === AnalogType.DIGITAL_OUT &&
(editItem.g === 25 || editItem.g === 26) && (
{isDACOutGPIO && (
<Grid>
<ValidatedTextField
name="o"
label={LL.VALUE(0)}
value={numberValue(editItem.o)}
sx={{ width: '11ch' }}
type="number"
onChange={updateFormValue}
disabled={editItem.s}
slotProps={{
htmlInput: { min: '0', max: '255', step: '1' }
}}
/>
</Grid>
)}
{isDigitalOutGPIO && (
<>
<Grid>
<TextField
<ValidatedTextField
name="o"
label={LL.VALUE(0)}
value={numberValue(editItem.o)}
sx={{ width: '11ch' }}
type="number"
variant="outlined"
select
onChange={updateFormValue}
slotProps={{
htmlInput: { min: '0', max: '255', step: '1' }
}}
/>
disabled={editItem.s}
>
<MenuItem value={0}>{LL.OFF()}</MenuItem>
<MenuItem value={1}>{LL.ON()}</MenuItem>
</ValidatedTextField>
</Grid>
)}
{editItem.t === AnalogType.DIGITAL_OUT &&
editItem.g !== 25 &&
editItem.g !== 26 && (
<>
<Grid>
<TextField
name="o"
label={LL.VALUE(0)}
value={numberValue(editItem.o)}
select
variant="outlined"
onChange={updateFormValue}
>
<MenuItem value={0}>{LL.OFF()}</MenuItem>
<MenuItem value={1}>{LL.ON()}</MenuItem>
</TextField>
</Grid>
<Grid>
<TextField
name="f"
label={LL.POLARITY()}
value={editItem.f}
sx={{ width: '15ch' }}
select
onChange={updateFormValue}
>
<MenuItem value={1}>{LL.ACTIVEHIGH()}</MenuItem>
<MenuItem value={-1}>{LL.ACTIVELOW()}</MenuItem>
</TextField>
</Grid>
<Grid>
<TextField
name="u"
label={LL.STARTVALUE()}
sx={{ width: '15ch' }}
value={editItem.u}
select
onChange={updateFormValue}
>
<MenuItem value={0}>{LL.UNCHANGED()}</MenuItem>
<MenuItem value={1}>
{LL.ALWAYS()}&nbsp;{LL.OFF()}
</MenuItem>
<MenuItem value={2}>
{LL.ALWAYS()}&nbsp;{LL.ON()}
</MenuItem>
</TextField>
</Grid>
</>
)}
{(editItem.t === AnalogType.PWM_0 ||
editItem.t === AnalogType.PWM_1 ||
editItem.t === AnalogType.PWM_2) && (
<Grid>
<ValidatedTextField
name="f"
label={LL.POLARITY()}
value={editItem.f}
sx={{ width: '15ch' }}
select
onChange={updateFormValue}
disabled={editItem.s}
>
<MenuItem value={1}>{LL.ACTIVEHIGH()}</MenuItem>
<MenuItem value={-1}>{LL.ACTIVELOW()}</MenuItem>
</ValidatedTextField>
</Grid>
<Grid>
<ValidatedTextField
name="u"
label={LL.STARTVALUE()}
sx={{ width: '15ch' }}
value={editItem.u}
select
onChange={updateFormValue}
disabled={editItem.s}
>
<MenuItem value={0}>{LL.UNCHANGED()}</MenuItem>
<MenuItem value={1}>
{LL.ALWAYS()}&nbsp;{LL.OFF()}
</MenuItem>
<MenuItem value={2}>
{LL.ALWAYS()}&nbsp;{LL.ON()}
</MenuItem>
</ValidatedTextField>
</Grid>
</>
)}
{isPWM && (
<>
<Grid>
<TextField
<ValidatedTextField
name="f"
label={LL.FREQ()}
value={numberValue(editItem.f)}
type="number"
variant="outlined"
sx={{ width: '11ch' }}
onChange={updateFormValue}
disabled={editItem.s}
slotProps={{
input: {
startAdornment: (
@@ -333,14 +420,14 @@ const SensorsAnalogDialog = ({
/>
</Grid>
<Grid>
<TextField
<ValidatedTextField
name="o"
label={LL.DUTY_CYCLE()}
value={numberValue(editItem.o)}
type="number"
sx={{ width: '11ch' }}
variant="outlined"
onChange={updateFormValue}
disabled={editItem.s}
slotProps={{
input: {
startAdornment: (
@@ -356,27 +443,28 @@ const SensorsAnalogDialog = ({
{editItem.t === AnalogType.PULSE && (
<>
<Grid>
<TextField
<ValidatedTextField
name="o"
label={LL.POLARITY()}
value={editItem.o}
sx={{ width: '11ch' }}
select
onChange={updateFormValue}
disabled={editItem.s}
>
<MenuItem value={0}>{LL.ACTIVEHIGH()}</MenuItem>
<MenuItem value={1}>{LL.ACTIVELOW()}</MenuItem>
</TextField>
</ValidatedTextField>
</Grid>
<Grid>
<TextField
<ValidatedTextField
name="f"
label="Pulse"
value={numberValue(editItem.f)}
type="number"
sx={{ width: '15ch' }}
variant="outlined"
onChange={updateFormValue}
disabled={editItem.s}
slotProps={{
input: {
startAdornment: (
@@ -390,12 +478,43 @@ const SensorsAnalogDialog = ({
</>
)}
</Grid>
{fieldErrors && Object.keys(fieldErrors).length > 0 && (
<Box sx={{ mt: 1 }}>
{Object.values(fieldErrors).map((errArr, idx) =>
Array.isArray(errArr)
? errArr.map((err, j) => (
<Typography
key={`${idx}-${j}`}
color="error"
variant="caption"
sx={{ display: 'block' }}
>
{err.message}
</Typography>
))
: null
)}
</Box>
)}
{editItem.s && (
<Grid>
<Typography sx={{ mt: 1 }} color="warning" variant="body2">
<WarningIcon
fontSize="small"
sx={{ mr: 1, verticalAlign: 'middle' }}
color="warning"
/>
{LL.SYSTEM(0)} {LL.SENSOR(0)}
</Typography>
</Grid>
)}
</DialogContent>
<DialogActions>
{!creating && (
<Box flexGrow={1} sx={{ '& button': { mt: 0 } }}>
<Box sx={{ flexGrow: 1, '& button': { mt: 0 } }}>
<Button
startIcon={<RemoveIcon />}
disabled={editItem.s}
variant="outlined"
color="warning"
onClick={remove}
@@ -413,7 +532,7 @@ const SensorsAnalogDialog = ({
{LL.CANCEL()}
</Button>
<Button
startIcon={<WarningIcon color="warning" />}
startIcon={<DoneIcon />}
variant="outlined"
onClick={save}
color="primary"

View File

@@ -1,9 +1,9 @@
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel';
import DoneIcon from '@mui/icons-material/Done';
import WarningIcon from '@mui/icons-material/Warning';
import {
Box,
Button,
Dialog,
DialogActions,
@@ -21,7 +21,7 @@ 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 { TemperatureSensor } from './types';
@@ -33,6 +33,12 @@ interface SensorsTemperatureDialogProps {
validator: Schema;
}
// Constants
const OFFSET_MIN = -5;
const OFFSET_MAX = 5;
const OFFSET_STEP = 0.1;
const TEMP_UNIT = '°C';
const SensorsTemperatureDialog = ({
open,
onClose,
@@ -43,7 +49,18 @@ const SensorsTemperatureDialog = ({
const { LL } = useI18nContext();
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const [editItem, setEditItem] = useState<TemperatureSensor>(selectedItem);
const updateFormValue = updateValue(setEditItem);
const updateFormValue = useMemo(
() =>
updateValue(
setEditItem as unknown as (
updater: (
prevState: Readonly<Record<string, unknown>>
) => Record<string, unknown>
) => void
),
[setEditItem]
);
useEffect(() => {
if (open) {
@@ -52,40 +69,54 @@ const SensorsTemperatureDialog = ({
}
}, [open, selectedItem]);
const handleClose = (
_event: React.SyntheticEvent,
reason: 'backdropClick' | 'escapeKeyDown'
) => {
if (reason !== 'backdropClick') {
onClose();
}
};
const handleClose = useCallback(
(_event: React.SyntheticEvent, reason?: string) => {
if (reason !== 'backdropClick') {
onClose();
}
},
[onClose]
);
const save = async () => {
const save = useCallback(async () => {
try {
setFieldErrors(undefined);
await validate(validator, editItem);
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>
{LL.EDIT()}&nbsp;{LL.TEMP_SENSOR()}
</DialogTitle>
<DialogTitle>{dialogTitle}</DialogTitle>
<DialogContent dividers>
<Box color="warning.main" p={0} pl={0} pr={0} mt={0} 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
fieldErrors={fieldErrors || {}}
fieldErrors={fieldErrors ?? {}}
name="n"
label={LL.NAME(0)}
value={editItem.n}
@@ -97,22 +128,27 @@ const SensorsTemperatureDialog = ({
<TextField
name="o"
label={LL.OFFSET()}
value={numberValue(editItem.o)}
value={offsetValue}
sx={{ width: '11ch' }}
type="number"
variant="outlined"
onChange={updateFormValue}
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">°C</InputAdornment>
)
},
htmlInput: { min: '-5', max: '5', step: '0.1' }
}}
slotProps={slotProps}
/>
</Grid>
</Grid>
{editItem.s && (
<Grid>
<Typography sx={{ mt: 1 }} color="warning" variant="body2">
<WarningIcon
fontSize="small"
sx={{ mr: 1, verticalAlign: 'middle' }}
color="warning"
/>
{LL.SYSTEM(0)} {LL.SENSOR(0)}
</Typography>
</Grid>
)}
</DialogContent>
<DialogActions>
<Button
@@ -124,7 +160,7 @@ const SensorsTemperatureDialog = ({
{LL.CANCEL()}
</Button>
<Button
startIcon={<WarningIcon color="warning" />}
startIcon={<DoneIcon />}
variant="outlined"
onClick={save}
color="primary"

View File

@@ -0,0 +1,69 @@
import { memo, useCallback, useContext } from 'react';
import PersonIcon from '@mui/icons-material/Person';
import {
Avatar,
Box,
Button,
Divider,
List,
ListItem,
ListItemText,
Typography
} from '@mui/material';
import { AuthenticatedContext } from '@/contexts/authentication';
import { SectionContent, useLayoutTitle } from 'components';
import { LanguageSelector } from 'components/inputs';
import { useI18nContext } from 'i18n/i18n-react';
const UserProfileComponent = () => {
const { LL } = useI18nContext();
const { me, signOut } = useContext(AuthenticatedContext);
useLayoutTitle(LL.USER_PROFILE());
const handleSignOut = useCallback(() => {
signOut(true);
}, [signOut]);
return (
<SectionContent>
<List sx={{ flexGrow: 1 }}>
<ListItem disablePadding>
<Avatar sx={{ bgcolor: '#9e9e9e', color: 'white' }}>
<PersonIcon />
</Avatar>
<ListItemText
sx={{ pl: 2, color: '#2196f3' }}
primary={me.username}
secondary={'(' + (me.admin ? LL.ADMINISTRATOR() : LL.GUEST()) + ')'}
/>
</ListItem>
</List>
<Box sx={{ mt: 2, mb: 2, display: 'flex', alignItems: 'center' }}>
<Typography
sx={{ mr: 2, textAlign: 'center' }}
color="warning"
variant="body1"
>
{LL.LANGUAGE()}:
</Typography>
<LanguageSelector />
</Box>
<Divider />
<Button
sx={{ mt: 2 }}
variant="outlined"
color="primary"
onClick={handleSignOut}
>
{LL.SIGN_OUT()}
</Button>
</SectionContent>
);
};
const UserProfile = memo(UserProfileComponent);
export default UserProfile;

View File

@@ -2,27 +2,30 @@ import type { TranslationFunctions } from 'i18n/i18n-types';
import { DeviceValueUOM, DeviceValueUOM_s } from './types';
// Cache NumberFormat instances for better performance
const numberFormatter = new Intl.NumberFormat();
const numberFormatterWithDecimal = new Intl.NumberFormat(undefined, {
minimumFractionDigits: 1
});
const formatDurationMin = (LL: TranslationFunctions, duration_min: number) => {
const days = Math.trunc((duration_min * 60000) / 86400000);
const hours = Math.trunc((duration_min * 60000) / 3600000) % 24;
const minutes = Math.trunc((duration_min * 60000) / 60000) % 60;
const totalMs = duration_min * 60000;
const days = Math.trunc(totalMs / 86400000);
const hours = Math.trunc(totalMs / 3600000) % 24;
const minutes = Math.trunc(duration_min) % 60;
let formatted = '';
const parts: string[] = [];
if (days) {
formatted += LL.NUM_DAYS({ num: days });
parts.push(LL.NUM_DAYS({ num: days }));
}
if (hours) {
if (formatted) formatted += ' ';
formatted += LL.NUM_HOURS({ num: hours });
parts.push(LL.NUM_HOURS({ num: hours }));
}
if (minutes) {
if (formatted) formatted += ' ';
formatted += LL.NUM_MINUTES({ num: minutes });
parts.push(LL.NUM_MINUTES({ num: minutes }));
}
return formatted;
return parts.join(' ');
};
export function formatValue(
@@ -30,18 +33,21 @@ export function formatValue(
value?: unknown,
uom?: DeviceValueUOM
) {
// Handle non-numeric values or missing data
if (typeof value !== 'number' || uom === undefined || value === undefined) {
if (value === undefined || typeof value === 'boolean') {
return '';
}
// Type assertion is safe here since we know it's not a number, boolean, or undefined
return (
(value as string) +
(value === '' || uom === undefined || uom === 0
(value === '' || uom === undefined || uom === DeviceValueUOM.NONE
? ''
: ' ' + DeviceValueUOM_s[uom])
);
}
// Handle numeric values
switch (uom) {
case DeviceValueUOM.HOURS:
return value ? formatDurationMin(LL, value * 60) : LL.NUM_HOURS({ num: 0 });
@@ -50,18 +56,12 @@ export function formatValue(
case DeviceValueUOM.SECONDS:
return LL.NUM_SECONDS({ num: value });
case DeviceValueUOM.NONE:
return new Intl.NumberFormat().format(value);
return numberFormatter.format(value);
case DeviceValueUOM.DEGREES:
case DeviceValueUOM.DEGREES_R:
case DeviceValueUOM.FAHRENHEIT:
return (
new Intl.NumberFormat(undefined, {
minimumFractionDigits: 1
}).format(value) +
' ' +
DeviceValueUOM_s[uom]
);
return numberFormatterWithDecimal.format(value) + ' ' + DeviceValueUOM_s[uom];
default:
return new Intl.NumberFormat().format(value) + ' ' + DeviceValueUOM_s[uom];
return numberFormatter.format(value) + ' ' + DeviceValueUOM_s[uom];
}
}

View File

@@ -43,6 +43,16 @@ export interface Settings {
modbus_port: number;
modbus_max_clients: number;
modbus_timeout: number;
email_enabled: boolean;
email_ssl?: boolean;
email_starttls?: boolean;
email_server: string;
email_port: number;
email_login: string;
email_pass: string;
email_sender: string;
email_recp: string;
email_subject: string;
developer_mode: boolean;
}
@@ -60,7 +70,7 @@ export interface Stat {
}
export interface Activity {
stats: Stat[];
readonly stats: readonly Stat[];
}
export interface Device {
@@ -82,38 +92,43 @@ export interface TemperatureSensor {
t?: number; // temp, optional
o: number; // offset
u: number; // uom
s: boolean; // system sensor flag
o_n?: string;
}
export interface AnalogSensor {
id: number;
g: number; // GPIO
n: string;
v: number;
u: number;
o: number;
f: number;
t: number;
n: string; // name
v: number; // value
u: number; // uom
o: number; // offset
f: number; // factor
t: number; // type
d: boolean; // deleted flag
o_n?: string;
s: boolean; // system sensor flag
o_n?: string; // original name
}
export interface WriteTemperatureSensor {
id: string;
name: string;
offset: number;
is_system: boolean;
}
export interface SensorData {
ts: TemperatureSensor[];
as: AnalogSensor[];
analog_enabled: boolean;
available_gpios: number[];
exclude_types: number[];
platform: string;
}
export interface CoreData {
connected: boolean;
devices: Device[];
readonly connected: boolean;
readonly devices: readonly Device[];
}
export interface DashboardItem {
@@ -122,11 +137,12 @@ export interface DashboardItem {
n?: string; // name, optional
dv?: DeviceValue; // device value, optional
nodes?: DashboardItem[]; // children nodes, optional
parentNode: DashboardItem; // to stop lint errors
}
export interface DashboardData {
connected: boolean; // true if connected to EMS bus
nodes: DashboardItem[];
readonly connected: boolean; // true if connected to EMS bus
readonly nodes: readonly DashboardItem[];
}
export interface DeviceValue {
@@ -139,10 +155,11 @@ export interface DeviceValue {
s?: string; // steps for up/down, optional
m?: number; // min, optional
x?: number; // max, optional
[key: string]: unknown;
}
export interface DeviceData {
nodes: DeviceValue[];
readonly nodes: readonly DeviceValue[];
}
export interface DeviceEntity {
@@ -189,13 +206,13 @@ export enum DeviceValueUOM {
MBAR,
LH,
CTKWH,
HZ
HERTZ
}
export const DeviceValueUOM_s = [
'',
'°C',
'°C',
'°C Rel',
'%',
'l/min',
'kWh',
@@ -221,11 +238,10 @@ export const DeviceValueUOM_s = [
'l/h',
'ct/kWh',
'Hz'
];
] as const;
export enum AnalogType {
REMOVED = -1,
NOTUSED = 0,
DIGITAL_IN = 1,
COUNTER = 2,
ADC = 3,
@@ -240,31 +256,34 @@ export enum AnalogType {
PULSE = 12,
FREQ_0 = 13,
FREQ_1 = 14,
FREQ_2 = 15
FREQ_2 = 15,
CNT_0 = 16,
CNT_1 = 17,
CNT_2 = 18
}
export const AnalogTypeNames = [
'(disabled)',
'Digital In',
'Counter',
'ADC In',
'Timer',
'Rate',
'Digital Out',
'PWM 0',
'PWM 1',
'PWM 2',
'NTC Temp.',
'RGB Led',
'Pulse',
'Freq 0',
'Freq 1',
'Freq 2'
];
'Digital In', // 1
'Counter', // 2
'ADC In', // 3
'Timer', // 4
'Rate', // 5
'Digital Out', // 6
'PWM 0', // 7
'PWM 1', // 8
'PWM 2', // 9
'NTC Temp', // 10
'RGB Led', // 11
'Pulse', // 12
'Freq 0', // 13
'Freq 1', // 14
'Freq 2', // 15
'Counter 0', // 16
'Counter 1', // 17
'Counter 2' // 18
] as const;
type BoardProfiles = Record<string, string>;
export const BOARD_PROFILES: BoardProfiles = {
export const BOARD_PROFILES = {
S32: 'BBQKees Gateway S32',
S32S3: 'BBQKees Gateway S3',
E32: 'BBQKees Gateway E32',
@@ -278,7 +297,9 @@ export const BOARD_PROFILES: BoardProfiles = {
C3MINI: 'Wemos C3 Mini',
S2MINI: 'Wemos S2 Mini',
S3MINI: 'Liligo S3'
};
} as const;
export type BoardProfileKey = keyof typeof BOARD_PROFILES;
export interface BoardProfile {
board_profile: string;
@@ -315,6 +336,7 @@ export interface WriteAnalogSensor {
uom: number;
type: number;
deleted: boolean;
is_system: boolean;
}
export enum DeviceEntityMask {
@@ -346,7 +368,7 @@ export interface ScheduleItem {
}
export interface Schedule {
schedule: ScheduleItem[];
readonly schedule: readonly ScheduleItem[];
}
export interface ModuleItem {
@@ -364,7 +386,7 @@ export interface ModuleItem {
}
export interface Modules {
modules: ModuleItem[];
readonly modules: readonly ModuleItem[];
}
export enum ScheduleFlag {
@@ -413,7 +435,7 @@ export interface EntityItem {
}
export interface Entities {
entities: EntityItem[];
readonly entities: readonly EntityItem[];
}
// matches emsdevice.h DeviceType
@@ -469,4 +491,4 @@ export const DeviceValueTypeNames = [
'ENUM',
'RAW',
'CMD'
];
] as const;

View File

@@ -11,273 +11,158 @@ import type {
TemperatureSensor
} from './types';
export const GPIO_VALIDATOR = {
validator(
_rule: InternalRuleItem,
value: number,
callback: (error?: string) => void
) {
if (
value &&
(value === 1 ||
(value >= 6 && value <= 11) ||
value === 20 ||
value === 24 ||
(value >= 28 && value <= 31) ||
value > 40 ||
value < 0)
) {
callback('Must be an valid GPIO port');
} else {
callback();
}
// Constants
const ERROR_MESSAGES = {
GPIO_INVALID: 'Must be an valid GPIO port',
NAME_DUPLICATE: 'Name already in use',
GPIO_DUPLICATE: 'GPIO already in use',
VALUE_OUT_OF_RANGE: 'Value out of range',
HEX_REQUIRED: 'Is required and must be in hex format'
} as const;
const VALIDATION_LIMITS = {
PORT_MIN: 0,
PORT_MAX: 65535,
MODBUS_MAX_CLIENTS_MIN: 0,
MODBUS_MAX_CLIENTS_MAX: 50,
MODBUS_TIMEOUT_MIN: 100,
MODBUS_TIMEOUT_MAX: 20000,
SYSLOG_MARK_INTERVAL_MIN: 0,
SYSLOG_MARK_INTERVAL_MAX: 3600,
SHOWER_MIN_DURATION_MIN: 10,
SHOWER_MIN_DURATION_MAX: 360,
SHOWER_ALERT_TRIGGER_MIN: 1,
SHOWER_ALERT_TRIGGER_MAX: 20,
SHOWER_ALERT_COLDSHOT_MIN: 1,
SHOWER_ALERT_COLDSHOT_MAX: 10,
REMOTE_TIMEOUT_MIN: 1,
REMOTE_TIMEOUT_MAX: 240,
OFFSET_MIN: 0,
OFFSET_MAX: 255,
COMMAND_MIN: 1,
COMMAND_MAX: 300,
NAME_MAX_LENGTH: 19,
HEX_BASE: 16
} as const;
type ValidationRules = Array<{
required?: boolean;
message?: string;
[key: string]: unknown;
}>;
export const createSettingsValidator = (settings: Settings) => {
const schema: Record<string, ValidationRules> = {};
// Syslog validations
if (settings.syslog_enabled) {
schema.syslog_host = [
{ required: true, message: 'Host is required' },
IP_OR_HOSTNAME_VALIDATOR
];
schema.syslog_port = [
{ required: true, message: 'Port is required' },
{
type: 'number',
min: VALIDATION_LIMITS.PORT_MIN,
max: VALIDATION_LIMITS.PORT_MAX,
message: 'Invalid Port'
}
];
schema.syslog_mark_interval = [
{ required: true, message: 'Mark interval is required' },
{
type: 'number',
min: VALIDATION_LIMITS.SYSLOG_MARK_INTERVAL_MIN,
max: VALIDATION_LIMITS.SYSLOG_MARK_INTERVAL_MAX,
message: `Must be between ${VALIDATION_LIMITS.SYSLOG_MARK_INTERVAL_MIN} and ${VALIDATION_LIMITS.SYSLOG_MARK_INTERVAL_MAX}`
}
];
}
// Modbus validations
if (settings.modbus_enabled) {
schema.modbus_max_clients = [
{ required: true, message: 'Max clients is required' },
{
type: 'number',
min: VALIDATION_LIMITS.MODBUS_MAX_CLIENTS_MIN,
max: VALIDATION_LIMITS.MODBUS_MAX_CLIENTS_MAX,
message: 'Invalid number'
}
];
schema.modbus_port = [
{ required: true, message: 'Port is required' },
{
type: 'number',
min: VALIDATION_LIMITS.PORT_MIN,
max: VALIDATION_LIMITS.PORT_MAX,
message: 'Invalid Port'
}
];
schema.modbus_timeout = [
{ required: true, message: 'Timeout is required' },
{
type: 'number',
min: VALIDATION_LIMITS.MODBUS_TIMEOUT_MIN,
max: VALIDATION_LIMITS.MODBUS_TIMEOUT_MAX,
message: `Must be between ${VALIDATION_LIMITS.MODBUS_TIMEOUT_MIN} and ${VALIDATION_LIMITS.MODBUS_TIMEOUT_MAX}`
}
];
}
// Shower timer validations
if (settings.shower_timer) {
schema.shower_min_duration = [
{
type: 'number',
min: VALIDATION_LIMITS.SHOWER_MIN_DURATION_MIN,
max: VALIDATION_LIMITS.SHOWER_MIN_DURATION_MAX,
message: `Time must be between ${VALIDATION_LIMITS.SHOWER_MIN_DURATION_MIN} and ${VALIDATION_LIMITS.SHOWER_MIN_DURATION_MAX} seconds`
}
];
}
// Shower alert validations
if (settings.shower_alert) {
schema.shower_alert_trigger = [
{
type: 'number',
min: VALIDATION_LIMITS.SHOWER_ALERT_TRIGGER_MIN,
max: VALIDATION_LIMITS.SHOWER_ALERT_TRIGGER_MAX,
message: `Time must be between ${VALIDATION_LIMITS.SHOWER_ALERT_TRIGGER_MIN} and ${VALIDATION_LIMITS.SHOWER_ALERT_TRIGGER_MAX} minutes`
}
];
schema.shower_alert_coldshot = [
{
type: 'number',
min: VALIDATION_LIMITS.SHOWER_ALERT_COLDSHOT_MIN,
max: VALIDATION_LIMITS.SHOWER_ALERT_COLDSHOT_MAX,
message: `Time must be between ${VALIDATION_LIMITS.SHOWER_ALERT_COLDSHOT_MIN} and ${VALIDATION_LIMITS.SHOWER_ALERT_COLDSHOT_MAX} seconds`
}
];
}
// Remote timeout validations
if (settings.remote_timeout_en) {
schema.remote_timeout = [
{
type: 'number',
min: VALIDATION_LIMITS.REMOTE_TIMEOUT_MIN,
max: VALIDATION_LIMITS.REMOTE_TIMEOUT_MAX,
message: `Timeout must be between ${VALIDATION_LIMITS.REMOTE_TIMEOUT_MIN} and ${VALIDATION_LIMITS.REMOTE_TIMEOUT_MAX} hours`
}
];
}
return new Schema(schema);
};
export const GPIO_VALIDATORR = {
validator(
_rule: InternalRuleItem,
value: number,
callback: (error?: string) => void
) {
if (
value &&
(value === 1 ||
(value >= 6 && value <= 11) ||
(value >= 16 && value <= 17) ||
value === 20 ||
value === 24 ||
(value >= 28 && value <= 31) ||
value > 40 ||
value < 0)
) {
callback('Must be an valid GPIO port');
} else {
callback();
}
}
};
export const GPIO_VALIDATORC3 = {
validator(
_rule: InternalRuleItem,
value: number,
callback: (error?: string) => void
) {
if (value && ((value >= 11 && value <= 19) || value > 21 || value < 0)) {
callback('Must be an valid GPIO port');
} else {
callback();
}
}
};
export const GPIO_VALIDATORS2 = {
validator(
_rule: InternalRuleItem,
value: number,
callback: (error?: string) => void
) {
if (
value &&
((value >= 19 && value <= 20) ||
(value >= 22 && value <= 32) ||
value > 40 ||
value < 0)
) {
callback('Must be an valid GPIO port');
} else {
callback();
}
}
};
export const GPIO_VALIDATORS3 = {
validator(
_rule: InternalRuleItem,
value: number,
callback: (error?: string) => void
) {
if (
value &&
((value >= 19 && value <= 20) ||
(value >= 22 && value <= 37) ||
(value >= 39 && value <= 42) ||
value > 48 ||
value < 0)
) {
callback('Must be an valid GPIO port');
} else {
callback();
}
}
};
export const createSettingsValidator = (settings: Settings) =>
new Schema({
...(settings.board_profile === 'CUSTOM' &&
settings.platform === 'ESP32' && {
led_gpio: [
{ required: true, message: 'LED GPIO is required' },
GPIO_VALIDATOR
],
dallas_gpio: [
{ required: true, message: 'GPIO is required' },
GPIO_VALIDATOR
],
pbutton_gpio: [
{ required: true, message: 'Button GPIO is required' },
GPIO_VALIDATOR
],
tx_gpio: [
{ required: true, message: 'Tx GPIO is required' },
GPIO_VALIDATOR
],
rx_gpio: [{ required: true, message: 'Rx GPIO is required' }, GPIO_VALIDATOR]
}),
...(settings.board_profile === 'CUSTOM' &&
settings.platform === 'ESP32C3' && {
led_gpio: [
{ required: true, message: 'LED GPIO is required' },
GPIO_VALIDATORC3
],
dallas_gpio: [
{ required: true, message: 'GPIO is required' },
GPIO_VALIDATORC3
],
pbutton_gpio: [
{ required: true, message: 'Button GPIO is required' },
GPIO_VALIDATORC3
],
tx_gpio: [
{ required: true, message: 'Tx GPIO is required' },
GPIO_VALIDATORC3
],
rx_gpio: [
{ required: true, message: 'Rx GPIO is required' },
GPIO_VALIDATORC3
]
}),
...(settings.board_profile === 'CUSTOM' &&
settings.platform === 'ESP32S2' && {
led_gpio: [
{ required: true, message: 'LED GPIO is required' },
GPIO_VALIDATORS2
],
dallas_gpio: [
{ required: true, message: 'GPIO is required' },
GPIO_VALIDATORS2
],
pbutton_gpio: [
{ required: true, message: 'Button GPIO is required' },
GPIO_VALIDATORS2
],
tx_gpio: [
{ required: true, message: 'Tx GPIO is required' },
GPIO_VALIDATORS2
],
rx_gpio: [
{ required: true, message: 'Rx GPIO is required' },
GPIO_VALIDATORS2
]
}),
...(settings.board_profile === 'CUSTOM' &&
settings.platform === 'ESP32S3' && {
led_gpio: [
{ required: true, message: 'LED GPIO is required' },
GPIO_VALIDATORS3
],
dallas_gpio: [
{ required: true, message: 'GPIO is required' },
GPIO_VALIDATORS3
],
pbutton_gpio: [
{ required: true, message: 'Button GPIO is required' },
GPIO_VALIDATORS3
],
tx_gpio: [
{ required: true, message: 'Tx GPIO is required' },
GPIO_VALIDATORS3
],
rx_gpio: [
{ required: true, message: 'Rx GPIO is required' },
GPIO_VALIDATORS3
]
}),
...(settings.syslog_enabled && {
syslog_host: [
{ required: true, message: 'Host is required' },
IP_OR_HOSTNAME_VALIDATOR
],
syslog_port: [
{ required: true, message: 'Port is required' },
{ type: 'number', min: 0, max: 65535, message: 'Invalid Port' }
],
syslog_mark_interval: [
{ required: true, message: 'Mark interval is required' },
{ type: 'number', min: 0, max: 10, message: 'Must be between 0 and 10' }
]
}),
...(settings.modbus_enabled && {
modbus_max_clients: [
{ required: true, message: 'Max clients is required' },
{ type: 'number', min: 0, max: 50, message: 'Invalid number' }
],
modbus_port: [
{ required: true, message: 'Port is required' },
{ type: 'number', min: 0, max: 65535, message: 'Invalid Port' }
],
modbus_timeout: [
{ required: true, message: 'Timeout is required' },
{
type: 'number',
min: 100,
max: 20000,
message: 'Must be between 100 and 20000'
}
]
}),
...(settings.shower_timer && {
shower_min_duration: [
{
type: 'number',
min: 10,
max: 360,
message: 'Time must be between 10 and 360 seconds'
}
]
}),
...(settings.shower_alert && {
shower_alert_trigger: [
{
type: 'number',
min: 1,
max: 20,
message: 'Time must be between 1 and 20 minutes'
}
],
shower_alert_coldshot: [
{
type: 'number',
min: 1,
max: 10,
message: 'Time must be between 1 and 10 seconds'
}
]
}),
...(settings.remote_timeout_en && {
remote_timeout: [
{
type: 'number',
min: 1,
max: 240,
message: 'Timeout must be between 1 and 240 hours'
}
]
})
});
export const uniqueNameValidator = (schedule: ScheduleItem[], o_name?: string) => ({
// Generic unique name validator factory
const createUniqueNameValidator = <T extends { name: string }>(
items: T[],
originalName?: string
) => ({
validator(
_rule: InternalRuleItem,
name: string,
@@ -285,43 +170,22 @@ export const uniqueNameValidator = (schedule: ScheduleItem[], o_name?: string) =
) {
if (
name !== '' &&
(o_name === undefined || o_name.toLowerCase() !== name.toLowerCase()) &&
schedule.find((si) => si.name.toLowerCase() === name.toLowerCase())
(originalName === undefined ||
originalName.toLowerCase() !== name.toLowerCase()) &&
items.find((item) => item.name.toLowerCase() === name.toLowerCase())
) {
callback('Name already in use');
} else {
callback();
callback(ERROR_MESSAGES.NAME_DUPLICATE);
return;
}
callback();
}
});
export const schedulerItemValidation = (
schedule: ScheduleItem[],
scheduleItem: ScheduleItem
) =>
new Schema({
name: [
{
type: 'string',
pattern: /^[a-zA-Z0-9_]{0,19}$/,
message: "Must be <20 characters: alphanumeric or '_'"
},
...[uniqueNameValidator(schedule, scheduleItem.o_name)]
],
cmd: [
{ required: true, message: 'Command is required' },
{
type: 'string',
min: 1,
max: 300,
message: 'Command must be 1-300 characters'
}
]
});
export const uniqueCustomNameValidator = (
entity: EntityItem[],
o_name?: string
// Generic field name validator (for cases where the name field has different property names)
const createUniqueFieldNameValidator = <T>(
items: T[],
getName: (item: T) => string,
originalName?: string
) => ({
validator(
_rule: InternalRuleItem,
@@ -329,58 +193,91 @@ export const uniqueCustomNameValidator = (
callback: (error?: string) => void
) {
if (
(o_name === undefined || o_name.toLowerCase() !== name.toLowerCase()) &&
entity.find((ei) => ei.name.toLowerCase() === name.toLowerCase())
name !== '' &&
(originalName === undefined ||
originalName.toLowerCase() !== name.toLowerCase()) &&
items.find((item) => getName(item).toLowerCase() === name.toLowerCase())
) {
callback('Name already in use');
} else {
callback();
callback(ERROR_MESSAGES.NAME_DUPLICATE);
return;
}
callback();
}
});
const NAME_PATTERN_BASE = '[a-zA-Z0-9_]';
const NAME_PATTERN_MESSAGE = `Must be <${VALIDATION_LIMITS.NAME_MAX_LENGTH + 1} characters: alphanumeric or '_'`;
const NAME_PATTERN = {
type: 'string' as const,
pattern: new RegExp(
`^${NAME_PATTERN_BASE}{0,${VALIDATION_LIMITS.NAME_MAX_LENGTH}}$`
),
message: NAME_PATTERN_MESSAGE
};
const NAME_PATTERN_REQUIRED = {
type: 'string' as const,
pattern: new RegExp(
`^${NAME_PATTERN_BASE}{1,${VALIDATION_LIMITS.NAME_MAX_LENGTH}}$`
),
message: NAME_PATTERN_MESSAGE
};
export const uniqueNameValidator = (schedule: ScheduleItem[], o_name?: string) =>
createUniqueNameValidator(schedule, o_name);
export const schedulerItemValidation = (
schedule: ScheduleItem[],
scheduleItem: ScheduleItem
) =>
new Schema({
name: [NAME_PATTERN, uniqueNameValidator(schedule, scheduleItem.o_name)],
cmd: [
{ required: true, message: 'Command is required' },
{
type: 'string',
min: VALIDATION_LIMITS.COMMAND_MIN,
max: VALIDATION_LIMITS.COMMAND_MAX,
message: `Command must be ${VALIDATION_LIMITS.COMMAND_MIN}-${VALIDATION_LIMITS.COMMAND_MAX} characters`
}
]
});
export const uniqueCustomNameValidator = (entity: EntityItem[], o_name?: string) =>
createUniqueNameValidator(entity, o_name);
const hexValidator = {
validator(
_rule: InternalRuleItem,
value: string,
callback: (error?: string) => void
) {
if (!value || Number.isNaN(Number.parseInt(value, VALIDATION_LIMITS.HEX_BASE))) {
callback(ERROR_MESSAGES.HEX_REQUIRED);
return;
}
callback();
}
};
export const entityItemValidation = (entity: EntityItem[], entityItem: EntityItem) =>
new Schema({
name: [
{ required: true, message: 'Name is required' },
{
type: 'string',
pattern: /^[a-zA-Z0-9_]{1,19}$/,
message: "Must be <20 characters: alphanumeric or '_'"
},
...[uniqueCustomNameValidator(entity, entityItem.o_name)]
],
device_id: [
{
validator(
_rule: InternalRuleItem,
value: string,
callback: (error?: string) => void
) {
if (isNaN(parseInt(value, 16))) {
callback('Is required and must be in hex format');
}
callback();
}
}
],
type_id: [
{
validator(
_rule: InternalRuleItem,
value: string,
callback: (error?: string) => void
) {
if (isNaN(parseInt(value, 16))) {
callback('Is required and must be in hex format');
}
callback();
}
}
NAME_PATTERN_REQUIRED,
uniqueCustomNameValidator(entity, entityItem.o_name)
],
device_id: [hexValidator],
type_id: [hexValidator],
offset: [
{ required: true, message: 'Offset is required' },
{ type: 'number', min: 0, max: 255, message: 'Must be between 0 and 255' }
{
type: 'number',
min: VALIDATION_LIMITS.OFFSET_MIN,
max: VALIDATION_LIMITS.OFFSET_MAX,
message: `Must be between ${VALIDATION_LIMITS.OFFSET_MIN} and ${VALIDATION_LIMITS.OFFSET_MAX}`
}
],
factor: [{ required: true, message: 'is required' }]
});
@@ -388,93 +285,34 @@ export const entityItemValidation = (entity: EntityItem[], entityItem: EntityIte
export const uniqueTemperatureNameValidator = (
sensors: TemperatureSensor[],
o_name?: string
) => ({
validator(_rule: InternalRuleItem, n: string, callback: (error?: string) => void) {
if (
(o_name === undefined || o_name.toLowerCase() !== n.toLowerCase()) &&
n !== '' &&
sensors.find((ts) => ts.n.toLowerCase() === n.toLowerCase())
) {
callback('Name already in use');
} else {
callback();
}
}
});
) => createUniqueFieldNameValidator(sensors, (s) => s.n, o_name);
export const temperatureSensorItemValidation = (
sensors: TemperatureSensor[],
sensor: TemperatureSensor
) =>
new Schema({
n: [
{
type: 'string',
pattern: /^[a-zA-Z0-9_]{0,19}$/,
message: "Must be <20 characters: alphanumeric or '_'"
},
...[uniqueTemperatureNameValidator(sensors, sensor.o_n)]
]
n: [NAME_PATTERN, uniqueTemperatureNameValidator(sensors, sensor.o_n)]
});
export const isGPIOUniqueValidator = (sensors: AnalogSensor[]) => ({
validator(
_rule: InternalRuleItem,
gpio: number,
callback: (error?: string) => void
) {
if (sensors.find((as) => as.g === gpio)) {
callback('GPIO already in use');
} else {
callback();
}
}
});
export const uniqueAnalogNameValidator = (
sensors: AnalogSensor[],
o_name?: string
) => ({
validator(_rule: InternalRuleItem, n: string, callback: (error?: string) => void) {
if (
(o_name === undefined || o_name.toLowerCase() !== n.toLowerCase()) &&
n !== '' &&
sensors.find((as) => as.n.toLowerCase() === n.toLowerCase())
) {
callback('Name already in use');
} else {
callback();
}
}
});
) => createUniqueFieldNameValidator(sensors, (s) => s.n, o_name);
export const analogSensorItemValidation = (
sensors: AnalogSensor[],
sensor: AnalogSensor,
creating: boolean,
platform: string
) =>
new Schema({
sensor: AnalogSensor
) => {
return new Schema({
// name is required and must be unique
n: [
{
type: 'string',
pattern: /^[a-zA-Z0-9_]{0,19}$/,
message: "Must be <20 characters: alphanumeric or '_'"
},
...[uniqueAnalogNameValidator(sensors, sensor.o_n)]
],
g: [
{ required: true, message: 'GPIO is required' },
platform === 'ESP32S3'
? GPIO_VALIDATORS3
: platform === 'ESP32S2'
? GPIO_VALIDATORS2
: platform === 'ESP32C3'
? GPIO_VALIDATORC3
: GPIO_VALIDATOR,
...(creating ? [isGPIOUniqueValidator(sensors)] : [])
{ required: true, message: 'Name is required' },
NAME_PATTERN,
uniqueAnalogNameValidator(sensors, sensor.o_n)
]
});
};
export const deviceValueItemValidation = (dv: DeviceValue) =>
new Schema({
@@ -488,11 +326,12 @@ export const deviceValueItemValidation = (dv: DeviceValue) =>
) {
if (
typeof value === 'number' &&
dv.m &&
dv.x &&
dv.m !== undefined &&
dv.x !== undefined &&
(value < dv.m || value > dv.x)
) {
callback('Value out of range');
callback(ERROR_MESSAGES.VALUE_OUT_OF_RANGE);
return;
}
callback();
}

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel';
import WarningIcon from '@mui/icons-material/Warning';
@@ -21,12 +21,25 @@ 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';
export const isAPEnabled = ({ provision_mode }: APSettingsType) =>
provision_mode === APProvisionMode.AP_MODE_ALWAYS ||
provision_mode === APProvisionMode.AP_MODE_DISCONNECTED;
// Efficient range function without recursion
const createRange = (start: number, end: number): number[] => {
const result: number[] = [];
for (let i = start; i <= end; i++) {
result.push(i);
}
return result;
};
// Pre-computed ranges for better performance
const CHANNEL_RANGE = createRange(1, 14);
const MAX_CLIENTS_RANGE = createRange(1, 9);
const APSettings = () => {
const {
loadData,
@@ -50,33 +63,38 @@ const APSettings = () => {
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const updateFormValue = updateValueDirty(
origData,
dirtyFlags,
setDirtyFlags,
updateDataValue as (value: unknown) => void
const updateFormValue = useMemo(
() =>
updateValueDirty(
origData as unknown as Record<string, unknown>,
dirtyFlags,
setDirtyFlags,
updateDataValue as (value: unknown) => void
),
[origData, dirtyFlags, setDirtyFlags, updateDataValue]
);
// Memoize AP enabled state
const apEnabled = useMemo(() => (data ? isAPEnabled(data) : false), [data]);
// Memoize validation and submit handler
const validateAndSubmit = useCallback(async () => {
if (!data) return;
try {
setFieldErrors(undefined);
await validate(createAPSettingsValidator(data), data);
await saveData();
} catch (error) {
setFieldErrors((error as ValidationError).fieldErrors);
}
}, [data, saveData]);
const content = () => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />;
}
const validateAndSubmit = async () => {
try {
setFieldErrors(undefined);
await validate(createAPSettingsValidator(data), data);
await saveData();
} catch (error) {
setFieldErrors(error as ValidateFieldsError);
}
};
// no lodash - https://asleepace.com/blog/typescript-range-without-a-loop/
function range(a: number, b: number): number[] {
return a < b ? [a, ...range(a + 1, b)] : [b];
}
return (
<>
<ValidatedTextField
@@ -100,7 +118,7 @@ const APSettings = () => {
{LL.AP_PROVIDE_TEXT_3()}
</MenuItem>
</ValidatedTextField>
{isAPEnabled(data) && (
{apEnabled && (
<>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
@@ -134,7 +152,7 @@ const APSettings = () => {
onChange={updateFormValue}
margin="normal"
>
{range(1, 14).map((i) => (
{CHANNEL_RANGE.map((i) => (
<MenuItem key={i} value={i}>
{i}
</MenuItem>
@@ -162,7 +180,7 @@ const APSettings = () => {
onChange={updateFormValue}
margin="normal"
>
{range(1, 9).map((i) => (
{MAX_CLIENTS_RANGE.map((i) => (
<MenuItem key={i} value={i}>
{i}
</MenuItem>

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { toast } from 'react-toastify';
import CancelIcon from '@mui/icons-material/Cancel';
@@ -28,22 +28,23 @@ import {
FormLoader,
MessageBox,
SectionContent,
ValidatedPasswordField,
ValidatedTextField,
useLayoutTitle
} from 'components';
import { useI18nContext } from 'i18n/i18n-react';
import { numberValue, updateValueDirty, useRest } from 'utils';
import { validate } from 'validators';
import { ValidationError, validate } from 'validators';
import { API, getBoardProfile, readSettings, writeSettings } from '../../api/app';
import { BOARD_PROFILES } from '../main/types';
import type { APIcall, Settings } from '../main/types';
import type { APIcall, BoardProfileKey, Settings } from '../main/types';
import { createSettingsValidator } from '../main/validators';
export function boardProfileSelectItems() {
return Object.keys(BOARD_PROFILES).map((code) => (
<MenuItem key={code} value={code}>
{BOARD_PROFILES[code]}
{BOARD_PROFILES[code as BoardProfileKey]}
</MenuItem>
));
}
@@ -72,7 +73,7 @@ const ApplicationSettings = () => {
const { LL } = useI18nContext();
const updateFormValue = updateValueDirty(
origData,
origData as unknown as Record<string, unknown>,
dirtyFlags,
setDirtyFlags,
updateDataValue as (value: unknown) => void
@@ -106,50 +107,61 @@ const ApplicationSettings = () => {
});
});
const doRestart = async () => {
// Memoized input props to prevent recreation on every render
const SecondsInputProps = useMemo(
() => ({
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
}),
[LL]
);
const MinutesInputProps = useMemo(
() => ({
endAdornment: <InputAdornment position="end">{LL.MINUTES()}</InputAdornment>
}),
[LL]
);
const HoursInputProps = useMemo(
() => ({
endAdornment: <InputAdornment position="end">{LL.HOURS()}</InputAdornment>
}),
[LL]
);
const doRestart = useCallback(async () => {
setRestarting(true);
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
(error: Error) => {
toast.error(error.message);
}
);
};
}, [sendAPI]);
const updateBoardProfile = async (board_profile: string) => {
await readBoardProfile(board_profile).catch((error: Error) => {
toast.error(error.message);
});
};
const updateBoardProfile = useCallback(
async (board_profile: string) => {
await readBoardProfile(board_profile).catch((error: Error) => {
toast.error(error.message);
});
},
[readBoardProfile]
);
useLayoutTitle(LL.APPLICATION());
const SecondsInputProps = {
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
};
const MinutesInputProps = {
endAdornment: <InputAdornment position="end">{LL.MINUTES()}</InputAdornment>
};
const HoursInputProps = {
endAdornment: <InputAdornment position="end">{LL.HOURS()}</InputAdornment>
};
const content = () => {
if (!data || !hardwareData) {
return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />;
const validateAndSubmit = useCallback(async () => {
try {
setFieldErrors(undefined);
await validate(createSettingsValidator(data), data);
} catch (error) {
setFieldErrors((error as ValidationError).fieldErrors);
} finally {
await saveData();
}
}, [data, saveData]);
const validateAndSubmit = async () => {
try {
setFieldErrors(undefined);
await validate(createSettingsValidator(data), data);
} catch (error) {
setFieldErrors(error as ValidateFieldsError);
} finally {
await saveData();
}
};
const changeBoardProfile = (event: React.ChangeEvent<HTMLInputElement>) => {
const changeBoardProfile = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const boardProfile = event.target.value;
updateFormValue(event);
if (boardProfile === 'CUSTOM') {
@@ -160,12 +172,22 @@ const ApplicationSettings = () => {
} else {
void updateBoardProfile(boardProfile);
}
};
},
[data, updateBoardProfile, updateFormValue, updateDataValue]
);
const restart = async () => {
await validateAndSubmit();
await doRestart();
};
const restart = useCallback(async () => {
await validateAndSubmit();
await doRestart();
}, [validateAndSubmit, doRestart]);
// Memoize board profile select items to prevent recreation
const boardProfileItems = useMemo(() => boardProfileSelectItems(), []);
const content = () => {
if (!data || !hardwareData) {
return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />;
}
return (
<>
@@ -307,9 +329,9 @@ const ApplicationSettings = () => {
>
<MenuItem value={-1}>OFF</MenuItem>
<MenuItem value={3}>ERR</MenuItem>
<MenuItem value={4}>WARN</MenuItem>
<MenuItem value={5}>NOTICE</MenuItem>
<MenuItem value={6}>INFO</MenuItem>
<MenuItem value={7}>DEBUG</MenuItem>
<MenuItem value={9}>ALL</MenuItem>
</TextField>
</Grid>
@@ -330,6 +352,156 @@ 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">
&nbsp; &#40;{LL.IS_REQUIRED('PSRAM')}&#41;
</Typography>
)}
</Typography>
}
/>
{data.email_enabled && (
<>
<Grid
container
spacing={2}
direction="row"
justifyContent="flex-start"
alignItems="flex-start"
>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
name="email_server"
label="SMTP Server"
variant="outlined"
value={data.email_server}
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
sx={{ width: '12ch' }}
name="email_port"
variant="outlined"
label="Port"
value={numberValue(data.email_port)}
type="number"
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid size={4} mt={!data.email_ssl && !data.email_starttls ? 0 : 3}>
{!data.email_starttls && (
<BlockFormControlLabel
sx={{ width: '12ch' }}
control={
<Checkbox
checked={data.email_ssl}
onChange={updateFormValue}
name="email_ssl"
disabled={
data.email_starttls || data.email_ssl === undefined
}
/>
}
label="SSL/TLS"
/>
)}
{!data.email_ssl && (
<BlockFormControlLabel
sx={{ width: '12ch' }}
control={
<Checkbox
checked={data.email_starttls}
onChange={updateFormValue}
name="email_starttls"
disabled={
data.email_ssl || data.email_starttls === undefined
}
/>
}
label="STARTTLS"
/>
)}
</Grid>
</Grid>
<Grid container spacing={2} rowSpacing={0}>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
name="email_login"
label="Login"
variant="outlined"
value={data.email_login}
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid>
<ValidatedPasswordField
fieldErrors={fieldErrors || {}}
name="email_pass"
label="Password"
variant="outlined"
value={data.email_pass}
onChange={updateFormValue}
margin="normal"
/>
</Grid>
</Grid>
<Grid container spacing={2} rowSpacing={0}>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
name="email_sender"
label="From"
variant="outlined"
value={data.email_sender}
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
name="email_recp"
label="To"
variant="outlined"
value={data.email_recp}
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
name="email_subject"
label="Subject"
variant="outlined"
value={data.email_subject}
onChange={updateFormValue}
margin="normal"
/>
</Grid>
</Grid>
</>
)}
<Typography sx={{ pb: 1, pt: 2 }} variant="h6" color="primary">
{LL.SENSORS()}
</Typography>
@@ -468,17 +640,25 @@ const ApplicationSettings = () => {
name="board_profile"
label={LL.BOARD_PROFILE()}
value={data.board_profile}
disabled={processingBoard || hardwareData.model.startsWith('BBQKees')}
disabled={processingBoard}
variant="outlined"
onChange={changeBoardProfile}
margin="normal"
select
>
{boardProfileSelectItems()}
<Divider />
<MenuItem key={'CUSTOM'} value={'CUSTOM'}>
{LL.CUSTOM()}&hellip;
</MenuItem>
{hardwareData.model.startsWith('BBQKees') ? (
<MenuItem key={hardwareData.board} value={hardwareData.board}>
{BOARD_PROFILES[hardwareData.board as BoardProfileKey]}
</MenuItem>
) : (
boardProfileItems
)}
{(data.board_profile === 'CUSTOM' || data.developer_mode) && <Divider />}
{(data.board_profile === 'CUSTOM' || data.developer_mode) && (
<MenuItem key={'CUSTOM'} value={'CUSTOM'}>
{LL.CUSTOM()}&hellip;
</MenuItem>
)}
</TextField>
{data.board_profile === 'CUSTOM' && (
<>
@@ -581,6 +761,7 @@ const ApplicationSettings = () => {
<MenuItem value={0}>{LL.DISABLED(1)}</MenuItem>
<MenuItem value={1}>LAN8720</MenuItem>
<MenuItem value={2}>TLK110</MenuItem>
<MenuItem value={3}>RTL8201</MenuItem>
</TextField>
</Grid>
</Grid>
@@ -741,7 +922,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"
@@ -836,8 +1017,9 @@ const ApplicationSettings = () => {
</Grid>
{restartNeeded && (
<MessageBox my={2} level="warning" message={LL.RESTART_TEXT(0)}>
<MessageBox level="warning" message={LL.RESTART_TEXT(0)}>
<Button
sx={{ ml: 2 }}
startIcon={<PowerSettingsNewIcon />}
variant="contained"
color="error"
@@ -873,10 +1055,12 @@ const ApplicationSettings = () => {
);
};
return (
return restarting ? (
<SystemMonitor />
) : (
<SectionContent>
{blocker ? <BlockNavigation blocker={blocker} /> : null}
{restarting ? <SystemMonitor /> : content()}
{content()}
</SectionContent>
);
};

View File

@@ -1,12 +1,23 @@
import { useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { toast } from 'react-toastify';
import CancelIcon from '@mui/icons-material/Cancel';
import DownloadIcon from '@mui/icons-material/GetApp';
import { 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';
@@ -22,6 +33,8 @@ import { saveFile } from 'utils';
const DownloadUpload = () => {
const { LL } = useI18nContext();
const [confirmBackup, setConfirmBackup] = useState<boolean>(false);
const [restarting, setRestarting] = useState<boolean>(false);
const { send: sendExportData } = useRequest(
@@ -44,95 +57,130 @@ const DownloadUpload = () => {
const { data, send: loadData, error } = useRequest(SystemApi.readSystemStatus);
const doRestart = async () => {
const doRestart = useCallback(async () => {
setRestarting(true);
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
(error: Error) => {
toast.error(error.message);
}
);
};
try {
await sendAPI({ device: 'system', cmd: 'restart', id: 0 });
} catch (error) {
toast.error((error as Error).message);
setRestarting(false);
}
}, [sendAPI]);
useLayoutTitle(LL.DOWNLOAD_UPLOAD());
const content = () => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
}
const handleCloseBackupDialog = useCallback(() => {
setConfirmBackup(false);
}, []);
const renderBackupDialog = useMemo(
() => (
<Dialog
sx={dialogStyle}
open={confirmBackup}
onClose={handleCloseBackupDialog}
>
<DialogTitle>{LL.DOWNLOAD_SYSTEM_BACKUP()}</DialogTitle>
<DialogContent dividers>
<WarningIcon color="warning" sx={{ fontSize: 18 }} />
&nbsp;
{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>
),
[confirmBackup, handleCloseBackupDialog, LL]
);
const handleDownload = useCallback(
(type: string) => () => {
void sendExportData(type);
setConfirmBackup(false);
},
[sendExportData]
);
if (restarting) {
return <SystemMonitor />;
}
if (!data) {
return (
<>
<Typography sx={{ pb: 2 }} variant="h6" color="primary">
{LL.DOWNLOAD(0)}
</Typography>
<SectionContent>
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
</SectionContent>
);
}
<Typography mb={1} variant="body1" color="warning">
{LL.DOWNLOAD_SETTINGS_TEXT()}.
</Typography>
<Grid container spacing={1}>
<Button
sx={{ ml: 2 }}
startIcon={<DownloadIcon />}
variant="outlined"
color="primary"
onClick={() => sendExportData('settings')}
>
{LL.SETTINGS_OF(LL.APPLICATION())}
</Button>
return (
<SectionContent>
{renderBackupDialog}
<Button
sx={{ ml: 2 }}
startIcon={<DownloadIcon />}
variant="outlined"
color="primary"
onClick={() => sendExportData('customizations')}
>
{LL.CUSTOMIZATIONS()}
</Button>
<Button
sx={{ ml: 2 }}
startIcon={<DownloadIcon />}
variant="outlined"
color="primary"
onClick={() => sendExportData('entities')}
>
{LL.CUSTOM_ENTITIES(0)}
</Button>
<Button
sx={{ ml: 2 }}
startIcon={<DownloadIcon />}
variant="outlined"
color="primary"
onClick={() => sendExportData('schedule')}
>
{LL.SCHEDULE(0)}
</Button>
</Grid>
<Typography sx={{ pb: 2 }} variant="h6" color="primary">
{LL.DOWNLOAD(0)}
</Typography>
<Grid
container
spacing={2}
sx={{
alignItems: 'center'
}}
>
<Typography variant="body1" color="warning">
{LL.DOWNLOAD_SETTINGS_TEXT()}:
</Typography>
<Button
sx={{ ml: 2, mt: 2 }}
startIcon={<DownloadIcon />}
variant="outlined"
color="primary"
onClick={() => sendExportData('allvalues')}
onClick={() => setConfirmBackup(true)}
>
{LL.DOWNLOAD_SYSTEM_BACKUP()}
</Button>
</Grid>
<Grid container spacing={2} sx={{ mt: 2, alignItems: 'center' }}>
<Typography variant="body1" color="warning">
{LL.DOWNLOAD_SETTINGS_TEXT2()}:
</Typography>
<Button
startIcon={<DownloadIcon />}
variant="outlined"
color="primary"
onClick={handleDownload('allvalues')}
>
{LL.ALLVALUES()}
</Button>
</Grid>
<Typography sx={{ pt: 2, pb: 2 }} variant="h6" color="primary">
{LL.UPLOAD()}
</Typography>
<Typography sx={{ pt: 2, pb: 2 }} variant="h6" color="primary">
{LL.UPLOAD()}
</Typography>
<Box color="warning.main" sx={{ pb: 2 }}>
<Typography variant="body1">{LL.UPLOAD_TEXT()}.</Typography>
</Box>
<Typography sx={{ pb: 2 }} color="warning" variant="body1">
{LL.UPLOAD_TEXT()}:
</Typography>
<SingleUpload text={LL.UPLOAD_DRAG()} doRestart={doRestart} />
</>
);
};
return (
<SectionContent>{restarting ? <SystemMonitor /> : content()}</SectionContent>
<SingleUpload doRestart={doRestart} />
</SectionContent>
);
};

View File

@@ -1,8 +1,11 @@
import { useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { toast } from 'react-toastify';
import CancelIcon from '@mui/icons-material/Cancel';
import SettingsBackupRestoreIcon from '@mui/icons-material/SettingsBackupRestore';
import WarningIcon from '@mui/icons-material/Warning';
import {
Box,
Button,
Checkbox,
Grid,
@@ -28,7 +31,9 @@ import {
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 { callAction } from '../../api/app';
const MqttSettings = () => {
const {
@@ -52,48 +57,104 @@ const MqttSettings = () => {
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const updateFormValue = updateValueDirty(
origData,
dirtyFlags,
setDirtyFlags,
updateDataValue as (value: unknown) => void
const sendResetMQTT = useCallback(() => {
void callAction({ action: 'resetMQTT' })
.then(() => {
toast.success('MQTT ' + LL.REFRESH() + ' successful');
})
.catch((error) => {
toast.error(String(error.error?.message || 'An error occurred'));
});
}, []);
const updateFormValue = useMemo(
() =>
updateValueDirty(
origData as unknown as Record<string, unknown>,
dirtyFlags,
setDirtyFlags,
updateDataValue as (value: unknown) => void
),
[origData, dirtyFlags, setDirtyFlags, updateDataValue]
);
const SecondsInputProps = {
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
};
const SecondsInputProps = useMemo(
() => ({
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
}),
[LL]
);
const content = () => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />;
const emptyFieldErrors = useMemo(() => ({}), []);
const validateAndSubmit = useCallback(async () => {
if (!data) return;
try {
setFieldErrors(undefined);
await validate(createMqttSettingsValidator(data), data);
await saveData();
} catch (error) {
setFieldErrors((error as ValidationError).fieldErrors);
}
}, [data, saveData]);
const validateAndSubmit = async () => {
try {
setFieldErrors(undefined);
await validate(createMqttSettingsValidator(data), data);
await saveData();
} catch (error) {
setFieldErrors(error as ValidateFieldsError);
}
};
const publishIntervalFields = useMemo(
() => [
{ name: 'publish_time_heartbeat', label: 'Heartbeat', validated: true },
{ name: 'publish_time_boiler', label: LL.MQTT_INT_BOILER(), validated: false },
{
name: 'publish_time_thermostat',
label: LL.MQTT_INT_THERMOSTATS(),
validated: false
},
{ name: 'publish_time_solar', label: LL.MQTT_INT_SOLAR(), validated: false },
{ name: 'publish_time_mixer', label: LL.MQTT_INT_MIXER(), validated: false },
{ name: 'publish_time_water', label: LL.MQTT_INT_WATER(), validated: false },
{ name: 'publish_time_sensor', label: LL.SENSORS(), validated: false },
{ name: 'publish_time_other', label: LL.DEFAULT(0), validated: false }
],
[LL]
);
if (!data) {
return (
<SectionContent>
{blocker ? <BlockNavigation blocker={blocker} /> : null}
<FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />
</SectionContent>
);
}
return (
<SectionContent>
{blocker ? <BlockNavigation blocker={blocker} /> : null}
<>
<BlockFormControlLabel
control={
<Checkbox
name="enabled"
checked={data.enabled}
onChange={updateFormValue}
/>
}
label={LL.ENABLE_MQTT()}
/>
<Box sx={{ display: 'flex', gap: 2, mb: 1 }}>
<BlockFormControlLabel
control={
<Checkbox
name="enabled"
checked={data.enabled}
onChange={updateFormValue}
/>
}
label={LL.ENABLE_MQTT()}
/>
{data.enabled && (
<Button
startIcon={<SettingsBackupRestoreIcon />}
color="secondary"
variant="outlined"
onClick={sendResetMQTT}
>
{LL.REFRESH() + ' MQTT'}
</Button>
)}
</Box>
<Grid container spacing={2} rowSpacing={0}>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
fieldErrors={fieldErrors ?? emptyFieldErrors}
name="host"
label={LL.ADDRESS_OF(LL.BROKER())}
multiline
@@ -105,7 +166,7 @@ const MqttSettings = () => {
</Grid>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
fieldErrors={fieldErrors ?? emptyFieldErrors}
name="port"
label="Port"
variant="outlined"
@@ -117,7 +178,7 @@ const MqttSettings = () => {
</Grid>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
fieldErrors={fieldErrors ?? emptyFieldErrors}
name="base"
label={LL.BASE_TOPIC()}
variant="outlined"
@@ -129,7 +190,7 @@ const MqttSettings = () => {
<Grid>
<TextField
name="client_id"
label={LL.ID_OF(LL.CLIENT()) + ' (' + LL.OPTIONAL() + ')'}
label={`${LL.ID_OF(LL.CLIENT())} (${LL.OPTIONAL()})`}
variant="outlined"
value={data.client_id}
onChange={updateFormValue}
@@ -158,7 +219,7 @@ const MqttSettings = () => {
</Grid>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
fieldErrors={fieldErrors ?? emptyFieldErrors}
name="keep_alive"
label="Keep Alive"
slotProps={{
@@ -205,6 +266,7 @@ const MqttSettings = () => {
label={LL.CERT()}
variant="outlined"
value={data.rootCA}
sx={{ width: '50ch' }}
onChange={updateFormValue}
margin="normal"
/>
@@ -283,188 +345,131 @@ const MqttSettings = () => {
</Grid>
)}
</Grid>
<Grid container spacing={2} rowSpacing={0}>
<Grid>
<BlockFormControlLabel
control={
<Checkbox
name="ha_enabled"
checked={data.ha_enabled}
onChange={updateFormValue}
disabled={data.publish_single}
/>
}
label={LL.MQTT_PUBLISH_TEXT_3()}
/>
</Grid>
{data.ha_enabled && (
<Grid container spacing={2} rowSpacing={0}>
<Grid>
<TextField
name="discovery_type"
label={LL.MQTT_PUBLISH_TEXT_5()}
value={data.discovery_type}
variant="outlined"
onChange={updateFormValue}
margin="normal"
select
>
<MenuItem value={0}>Home Assistant</MenuItem>
<MenuItem value={1}>Domoticz</MenuItem>
<MenuItem value={2}>Domoticz (latest)</MenuItem>
</TextField>
</Grid>
<Grid>
<TextField
name="discovery_prefix"
label={LL.MQTT_PUBLISH_TEXT_4()}
variant="outlined"
value={data.discovery_prefix}
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid>
<TextField
name="entity_format"
label={LL.MQTT_ENTITY_FORMAT()}
value={data.entity_format}
variant="outlined"
onChange={updateFormValue}
margin="normal"
select
>
<MenuItem value={0}>{LL.MQTT_ENTITY_FORMAT_0()}</MenuItem>
<MenuItem value={3}>
{LL.MQTT_ENTITY_FORMAT_1()}&nbsp;(v3.6)
</MenuItem>
<MenuItem value={4}>
{LL.MQTT_ENTITY_FORMAT_2()}&nbsp;(v3.6)
</MenuItem>
<MenuItem value={1}>{LL.MQTT_ENTITY_FORMAT_1()}</MenuItem>
<MenuItem value={2}>{LL.MQTT_ENTITY_FORMAT_2()}</MenuItem>
</TextField>
</Grid>
</Grid>
)}
{/* <Grid container spacing={2} rowSpacing={0}> */}
<Grid>
<BlockFormControlLabel
control={
<Checkbox
name="ha_enabled"
checked={data.ha_enabled}
onChange={updateFormValue}
disabled={data.publish_single}
/>
}
label={LL.MQTT_PUBLISH_TEXT_3()}
/>
</Grid>
{data.ha_enabled && (
<Grid container spacing={2} rowSpacing={0}>
<Grid>
<TextField
name="discovery_type"
label={LL.MQTT_PUBLISH_TEXT_5()}
value={data.discovery_type}
variant="outlined"
onChange={updateFormValue}
margin="normal"
select
>
<MenuItem value={0}>Home Assistant</MenuItem>
<MenuItem value={1}>Domoticz</MenuItem>
<MenuItem value={2}>Domoticz (latest)</MenuItem>
</TextField>
</Grid>
<Grid>
<TextField
name="discovery_prefix"
label={LL.MQTT_PUBLISH_TEXT_4()}
variant="outlined"
value={data.discovery_prefix}
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid>
<TextField
name="entity_format"
label={LL.MQTT_ENTITY_FORMAT()}
value={data.entity_format}
variant="outlined"
onChange={updateFormValue}
margin="normal"
select
>
<MenuItem value={0}>{LL.MQTT_ENTITY_FORMAT_0()}</MenuItem>
<MenuItem value={3}>
{LL.MQTT_ENTITY_FORMAT_1()}&nbsp;(v3.5)
</MenuItem>
<MenuItem value={4}>
{LL.MQTT_ENTITY_FORMAT_2()}&nbsp;(v3.5)
</MenuItem>
<MenuItem value={1}>
{LL.MQTT_ENTITY_FORMAT_1()}&nbsp;(latest)
</MenuItem>
<MenuItem value={2}>
{LL.MQTT_ENTITY_FORMAT_2()}&nbsp;(latest)
</MenuItem>
</TextField>
</Grid>
<Grid>
{data.discovery_type === 0 && (
<TextField
name="ha_number_mode"
label={LL.MQTT_INPUT_NUMBER_FORMAT()}
value={data.ha_number_mode}
variant="outlined"
onChange={updateFormValue}
sx={{ width: '20ch' }}
margin="normal"
select
>
<MenuItem value={0}>Box</MenuItem>
<MenuItem value={1}>Slider</MenuItem>
</TextField>
)}
</Grid>
</Grid>
)}
<Typography sx={{ pt: 2 }} variant="h6" color="primary">
{LL.MQTT_PUBLISH_INTERVALS()}&nbsp;(0=auto)
</Typography>
<Grid container spacing={2} rowSpacing={0}>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
name="publish_time_heartbeat"
label="Heartbeat"
slotProps={{
input: SecondsInputProps
}}
variant="outlined"
value={numberValue(data.publish_time_heartbeat)}
type="number"
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid>
<TextField
name="publish_time_boiler"
label={LL.MQTT_INT_BOILER()}
variant="outlined"
value={numberValue(data.publish_time_boiler)}
type="number"
onChange={updateFormValue}
margin="normal"
slotProps={{
input: SecondsInputProps
}}
/>
</Grid>
<Grid>
<TextField
name="publish_time_thermostat"
label={LL.MQTT_INT_THERMOSTATS()}
variant="outlined"
value={numberValue(data.publish_time_thermostat)}
type="number"
onChange={updateFormValue}
margin="normal"
slotProps={{
input: SecondsInputProps
}}
/>
</Grid>
<Grid>
<TextField
name="publish_time_solar"
label={LL.MQTT_INT_SOLAR()}
variant="outlined"
value={numberValue(data.publish_time_solar)}
type="number"
onChange={updateFormValue}
margin="normal"
slotProps={{
input: SecondsInputProps
}}
/>
</Grid>
<Grid>
<TextField
name="publish_time_mixer"
label={LL.MQTT_INT_MIXER()}
variant="outlined"
value={numberValue(data.publish_time_mixer)}
type="number"
onChange={updateFormValue}
margin="normal"
slotProps={{
input: SecondsInputProps
}}
/>
</Grid>
<Grid>
<TextField
name="publish_time_water"
label={LL.MQTT_INT_WATER()}
variant="outlined"
value={numberValue(data.publish_time_water)}
type="number"
onChange={updateFormValue}
margin="normal"
slotProps={{
input: SecondsInputProps
}}
/>
</Grid>
<Grid>
<TextField
name="publish_time_sensor"
label={LL.SENSORS()}
variant="outlined"
value={numberValue(data.publish_time_sensor)}
type="number"
onChange={updateFormValue}
margin="normal"
slotProps={{
input: SecondsInputProps
}}
/>
</Grid>
<Grid>
<TextField
name="publish_time_other"
label={LL.DEFAULT(0)}
variant="outlined"
value={numberValue(data.publish_time_other)}
type="number"
onChange={updateFormValue}
margin="normal"
slotProps={{
input: SecondsInputProps
}}
/>
</Grid>
{publishIntervalFields.map((field) => (
<Grid key={field.name}>
{field.validated ? (
<ValidatedTextField
fieldErrors={fieldErrors ?? emptyFieldErrors}
name={field.name}
label={field.label}
slotProps={{
input: SecondsInputProps
}}
variant="outlined"
value={numberValue(
data[field.name as keyof MqttSettingsType] as number
)}
type="number"
onChange={updateFormValue}
margin="normal"
/>
) : (
<TextField
name={field.name}
label={field.label}
variant="outlined"
value={numberValue(
data[field.name as keyof MqttSettingsType] as number
)}
type="number"
onChange={updateFormValue}
margin="normal"
slotProps={{
input: SecondsInputProps
}}
/>
)}
</Grid>
))}
</Grid>
{dirtyFlags && dirtyFlags.length !== 0 && (
<ButtonRow>
@@ -491,13 +496,6 @@ const MqttSettings = () => {
</ButtonRow>
)}
</>
);
};
return (
<SectionContent>
{blocker ? <BlockNavigation blocker={blocker} /> : null}
{content()}
</SectionContent>
);
};

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { toast } from 'react-toastify';
import AccessTimeIcon from '@mui/icons-material/AccessTime';
@@ -36,10 +36,10 @@ import {
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 { TIME_ZONES, selectedTimeZone, timeZoneSelectItems } from './TZ';
import { TIME_ZONES, selectedTimeZone, useTimeZoneSelectItems } from './TZ';
const NTPSettings = () => {
const {
@@ -61,9 +61,19 @@ const NTPSettings = () => {
const { LL } = useI18nContext();
useLayoutTitle('NTP');
// Memoized timezone select items for better performance
const timeZoneItems = useTimeZoneSelectItems();
// Memoized selected timezone value
const selectedTzValue = useMemo(
() => (data ? selectedTimeZone(data.tz_label, data.tz_format) : undefined),
[data?.tz_label, data?.tz_format]
);
const [localTime, setLocalTime] = useState<string>('');
const [settingTime, setSettingTime] = useState<boolean>(false);
const [processing, setProcessing] = useState<boolean>(false);
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const { send: updateTime } = useRequest(
(local_time: Time) => NTPApi.updateTime(local_time),
@@ -72,110 +82,79 @@ const NTPSettings = () => {
}
);
const updateFormValue = updateValueDirty(
origData,
dirtyFlags,
setDirtyFlags,
updateDataValue as (value: unknown) => void
// Memoize updateFormValue to prevent recreation on every render
const updateFormValue = useMemo(
() =>
updateValueDirty(
origData as unknown as Record<string, unknown>,
dirtyFlags,
setDirtyFlags,
updateDataValue as (value: unknown) => void
),
[origData, dirtyFlags, setDirtyFlags, updateDataValue]
);
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
// Memoize updateLocalTime handler
const updateLocalTime = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => setLocalTime(event.target.value),
[]
);
const updateLocalTime = (event: React.ChangeEvent<HTMLInputElement>) =>
setLocalTime(event.target.value);
const openSetTime = () => {
// Memoize openSetTime handler
const openSetTime = useCallback(() => {
setLocalTime(formatLocalDateTime(new Date()));
setSettingTime(true);
};
}, []);
const configureTime = async () => {
// Memoize configureTime handler
const configureTime = useCallback(async () => {
setProcessing(true);
await updateTime({ local_time: formatLocalDateTime(new Date(localTime)) })
.then(async () => {
toast.success(LL.TIME_SET());
setSettingTime(false);
await loadData();
})
.catch(() => {
toast.error(LL.PROBLEM_UPDATING());
})
.finally(() => {
setProcessing(false);
});
};
const renderSetTimeDialog = () => (
<Dialog
sx={dialogStyle}
open={settingTime}
onClose={() => setSettingTime(false)}
>
<DialogTitle>{LL.SET_TIME(1)}</DialogTitle>
<DialogContent dividers>
<Box color="warning.main" p={0} pl={0} pr={0} mt={0} mb={2}>
<Typography variant="body2">{LL.SET_TIME_TEXT()}</Typography>
</Box>
<TextField
label={LL.LOCAL_TIME(0)}
type="datetime-local"
value={localTime}
onChange={updateLocalTime}
disabled={processing}
fullWidth
slotProps={{
inputLabel: {
shrink: true
}
}}
/>
</DialogContent>
<DialogActions>
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={() => setSettingTime(false)}
color="secondary"
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<AccessTimeIcon />}
variant="outlined"
onClick={configureTime}
disabled={processing}
color="primary"
>
{LL.UPDATE()}
</Button>
</DialogActions>
</Dialog>
);
const content = () => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />;
try {
await updateTime({ local_time: formatLocalDateTime(new Date(localTime)) });
toast.success(LL.TIME_SET());
setSettingTime(false);
await loadData();
} catch {
toast.error(LL.PROBLEM_UPDATING());
} finally {
setProcessing(false);
}
}, [localTime, updateTime, LL, loadData]);
const validateAndSubmit = async () => {
try {
setFieldErrors(undefined);
await validate(NTP_SETTINGS_VALIDATOR, data);
await saveData();
} catch (error) {
setFieldErrors(error as ValidateFieldsError);
}
};
// Memoize close dialog handler
const handleCloseSetTime = useCallback(() => setSettingTime(false), []);
const changeTimeZone = (event: React.ChangeEvent<HTMLInputElement>) => {
// Memoize validate and submit handler
const validateAndSubmit = useCallback(async () => {
if (!data) return;
try {
setFieldErrors(undefined);
await validate(NTP_SETTINGS_VALIDATOR, data);
await saveData();
} catch (error) {
setFieldErrors((error as 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]
);
// Memoize render content to prevent unnecessary re-renders
const renderContent = useMemo(() => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />;
}
return (
<>
@@ -205,18 +184,18 @@ const NTPSettings = () => {
label={LL.TIME_ZONE()}
fullWidth
variant="outlined"
value={selectedTimeZone(data.tz_label, data.tz_format)}
value={selectedTzValue}
onChange={changeTimeZone}
margin="normal"
select
>
<MenuItem disabled>{LL.TIME_ZONE()}...</MenuItem>
{timeZoneSelectItems()}
{timeZoneItems}
</ValidatedTextField>
<Box display="flex" flexWrap="wrap">
<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}
@@ -230,7 +209,6 @@ const NTPSettings = () => {
</Box>
)}
</Box>
{renderSetTimeDialog()}
{dirtyFlags && dirtyFlags.length !== 0 && (
<ButtonRow>
@@ -258,12 +236,66 @@ const NTPSettings = () => {
)}
</>
);
};
}, [
data,
errorMessage,
loadData,
updateFormValue,
fieldErrors,
selectedTzValue,
changeTimeZone,
timeZoneItems,
dirtyFlags,
openSetTime,
saving,
validateAndSubmit,
LL
]);
return (
<SectionContent>
{blocker ? <BlockNavigation blocker={blocker} /> : null}
{content()}
{renderContent}
<Dialog sx={dialogStyle} open={settingTime} onClose={handleCloseSetTime}>
<DialogTitle>{LL.SET_TIME(1)}</DialogTitle>
<DialogContent dividers>
<Typography color="warning" variant="body2">
{LL.SET_TIME_TEXT()}
</Typography>
<TextField
label={LL.LOCAL_TIME(0)}
type="datetime-local"
value={localTime}
onChange={updateLocalTime}
disabled={processing}
fullWidth
slotProps={{
inputLabel: {
shrink: true
}
}}
/>
</DialogContent>
<DialogActions>
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={handleCloseSetTime}
color="secondary"
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<AccessTimeIcon />}
variant="outlined"
onClick={configureTime}
disabled={processing}
color="primary"
>
{LL.UPDATE()}
</Button>
</DialogActions>
</Dialog>
</SectionContent>
);
};

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
import AccessTimeIcon from '@mui/icons-material/AccessTime';
import CancelIcon from '@mui/icons-material/Cancel';
@@ -30,140 +30,161 @@ import { SectionContent, useLayoutTitle } from 'components';
import ListMenuItem from 'components/layout/ListMenuItem';
import { useI18nContext } from 'i18n/i18n-react';
import SystemMonitor from '../status/SystemMonitor';
const Settings = () => {
const { LL } = useI18nContext();
useLayoutTitle(LL.SETTINGS(0));
const [confirmFactoryReset, setConfirmFactoryReset] = useState<boolean>(false);
const [confirmFactoryReset, setConfirmFactoryReset] = useState(false);
const [restarting, setRestarting] = useState<boolean>();
const { send: sendAPI } = useRequest((data: APIcall) => API(data), {
immediate: false
});
const doFormat = async () => {
const doFormat = useCallback(async () => {
await sendAPI({ device: 'system', cmd: 'format', id: 0 }).then(() => {
setRestarting(true);
setConfirmFactoryReset(false);
});
};
}, [sendAPI]);
const renderFactoryResetDialog = () => (
<Dialog
sx={dialogStyle}
open={confirmFactoryReset}
onClose={() => setConfirmFactoryReset(false)}
>
<DialogTitle>{LL.FACTORY_RESET()}</DialogTitle>
<DialogContent dividers>{LL.SYSTEM_FACTORY_TEXT_DIALOG()}</DialogContent>
<DialogActions>
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={() => setConfirmFactoryReset(false)}
color="secondary"
const handleFactoryResetClose = useCallback(() => {
setConfirmFactoryReset(false);
}, []);
const handleFactoryResetClick = useCallback(() => {
setConfirmFactoryReset(true);
}, []);
const content = useMemo(() => {
return (
<>
<List>
<ListMenuItem
icon={TuneIcon}
bgcolor="#134ba2"
label={LL.APPLICATION()}
text={LL.APPLICATION_SETTINGS_1()}
to="application"
/>
<ListMenuItem
icon={SettingsEthernetIcon}
bgcolor="#40828f"
label={LL.NETWORK(0)}
text={LL.CONFIGURE(LL.SETTINGS_OF(LL.NETWORK(1)))}
to="network"
/>
<ListMenuItem
icon={SettingsInputAntennaIcon}
bgcolor="#5f9a5f"
label={LL.ACCESS_POINT(0)}
text={LL.CONFIGURE(LL.ACCESS_POINT(1))}
to="ap"
/>
<ListMenuItem
icon={AccessTimeIcon}
bgcolor="#c5572c"
label="NTP"
text={LL.CONFIGURE(LL.LOCAL_TIME(1))}
to="ntp"
/>
<ListMenuItem
icon={DeviceHubIcon}
bgcolor="#68374d"
label="MQTT"
text={LL.CONFIGURE('MQTT')}
to="mqtt"
/>
<ListMenuItem
icon={LockIcon}
label={LL.SECURITY(0)}
text={LL.SECURITY_1()}
to="security"
/>
<ListMenuItem
icon={ViewModuleIcon}
bgcolor="#efc34b"
label={LL.MODULES()}
text={LL.MODULES_1()}
to="modules"
/>
<ListMenuItem
icon={ImportExportIcon}
bgcolor="#5d89f7"
label={LL.DOWNLOAD_UPLOAD()}
text={LL.DOWNLOAD_UPLOAD_1()}
to="downloadUpload"
/>
</List>
<Dialog
sx={dialogStyle}
open={confirmFactoryReset}
onClose={handleFactoryResetClose}
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<SettingsBackupRestoreIcon />}
variant="outlined"
onClick={doFormat}
color="error"
<DialogTitle>{LL.FACTORY_RESET()}</DialogTitle>
<DialogContent dividers>{LL.SYSTEM_FACTORY_TEXT_DIALOG()}</DialogContent>
<DialogActions>
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={handleFactoryResetClose}
color="secondary"
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<SettingsBackupRestoreIcon />}
variant="outlined"
onClick={doFormat}
color="error"
>
{LL.FACTORY_RESET()}
</Button>
</DialogActions>
</Dialog>
<Divider />
<Box
sx={{
mt: 2,
display: 'flex',
justifyContent: 'flex-end',
flexWrap: 'nowrap',
whiteSpace: 'nowrap'
}}
>
{LL.FACTORY_RESET()}
</Button>
</DialogActions>
</Dialog>
);
<Button
startIcon={<SettingsBackupRestoreIcon />}
variant="outlined"
onClick={handleFactoryResetClick}
color="error"
>
{LL.FACTORY_RESET()}
</Button>
</Box>
</>
);
}, [
LL,
handleFactoryResetClick,
handleFactoryResetClose,
doFormat,
confirmFactoryReset,
restarting
]);
return (
<SectionContent>
<List>
<ListMenuItem
icon={TuneIcon}
bgcolor="#134ba2"
label={LL.APPLICATION()}
text={LL.APPLICATION_SETTINGS_1()}
to="application"
/>
<ListMenuItem
icon={SettingsEthernetIcon}
bgcolor="#40828f"
label={LL.NETWORK(0)}
text={LL.CONFIGURE(LL.SETTINGS_OF(LL.NETWORK(1)))}
to="network"
/>
<ListMenuItem
icon={SettingsInputAntennaIcon}
bgcolor="#5f9a5f"
label={LL.ACCESS_POINT(0)}
text={LL.CONFIGURE(LL.ACCESS_POINT(1))}
to="ap"
/>
<ListMenuItem
icon={AccessTimeIcon}
bgcolor="#c5572c"
label="NTP"
text={LL.CONFIGURE(LL.LOCAL_TIME(1))}
to="ntp"
/>
<ListMenuItem
icon={DeviceHubIcon}
bgcolor="#68374d"
label="MQTT"
text={LL.CONFIGURE('MQTT')}
to="mqtt"
/>
<ListMenuItem
icon={LockIcon}
label={LL.SECURITY(0)}
text={LL.SECURITY_1()}
to="security"
/>
<ListMenuItem
icon={ViewModuleIcon}
bgcolor="#efc34b"
label={LL.MODULES()}
text={LL.MODULES_1()}
to="modules"
/>
<ListMenuItem
icon={ImportExportIcon}
bgcolor="#5d89f7"
label={LL.DOWNLOAD_UPLOAD()}
text={LL.DOWNLOAD_UPLOAD_1()}
to="downloadUpload"
/>
</List>
{renderFactoryResetDialog()}
<Divider />
<Box
mt={2}
display="flex"
justifyContent="flex-end"
flexWrap="nowrap"
whiteSpace="nowrap"
>
<Button
startIcon={<SettingsBackupRestoreIcon />}
variant="outlined"
onClick={() => setConfirmFactoryReset(true)}
color="error"
>
{LL.FACTORY_RESET()}
</Button>
</Box>
</SectionContent>
);
return restarting ? <SystemMonitor /> : <SectionContent>{content}</SectionContent>;
};
export default Settings;

View File

@@ -1,8 +1,8 @@
import { useMemo } from 'react';
import { MenuItem } from '@mui/material';
type TimeZones = Record<string, string>;
export const TIME_ZONES: TimeZones = {
export const TIME_ZONES: Record<string, string> = {
'Africa/Abidjan': 'GMT0',
'Africa/Accra': 'GMT0',
'Africa/Addis_Ababa': 'EAT-3',
@@ -465,14 +465,33 @@ export const TIME_ZONES: TimeZones = {
'Pacific/Wallis': 'UNK-12'
};
// Pre-compute sorted timezone labels for better performance
export const TIME_ZONE_LABELS = Object.keys(TIME_ZONES).sort();
export function selectedTimeZone(label: string, format: string) {
return TIME_ZONES[label] === format ? label : undefined;
}
export function timeZoneSelectItems() {
return Object.keys(TIME_ZONES).map((label) => (
<MenuItem key={label} value={label}>
{label}
</MenuItem>
));
// Memoized version for use in components
export function useTimeZoneSelectItems() {
return useMemo(
() =>
TIME_ZONE_LABELS.map((label) => (
<MenuItem key={label} value={label}>
{label}
</MenuItem>
)),
[]
);
}
// Fallback export for backward compatibility - now memoized
const precomputedTimeZoneItems = TIME_ZONE_LABELS.map((label) => (
<MenuItem key={label} value={label}>
{label}
</MenuItem>
));
export function timeZoneSelectItems() {
return precomputedTimeZoneItems;
}

View File

@@ -1,4 +1,4 @@
import { useCallback, useState } from 'react';
import { memo, useCallback, useMemo, useState } from 'react';
import {
Navigate,
Route,
@@ -28,8 +28,7 @@ const Network = () => {
[
{
path: '/settings/network/settings',
element: <NetworkSettings />,
dog: 'woof'
element: <NetworkSettings />
},
{ path: '/settings/network/scan', element: <WiFiNetworkScanner /> }
],
@@ -53,14 +52,17 @@ const Network = () => {
setSelectedNetwork(undefined);
}, []);
const contextValue = useMemo(
() => ({
...(selectedNetwork && { selectedNetwork }),
selectNetwork,
deselectNetwork
}),
[selectedNetwork, selectNetwork, deselectNetwork]
);
return (
<WiFiConnectionContext.Provider
value={{
...(selectedNetwork && { selectedNetwork }),
selectNetwork,
deselectNetwork
}}
>
<WiFiConnectionContext.Provider value={contextValue}>
<RouterTabs value={routerTab}>
<Tab
value="/settings/network/settings"
@@ -80,4 +82,4 @@ const Network = () => {
);
};
export default Network;
export default memo(Network);

View File

@@ -1,4 +1,4 @@
import { useContext, useEffect, useState } from 'react';
import { memo, useCallback, useContext, useEffect, useState } from 'react';
import { toast } from 'react-toastify';
import CancelIcon from '@mui/icons-material/Cancel';
@@ -40,7 +40,7 @@ import {
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 SystemMonitor from '../../status/SystemMonitor';
@@ -109,38 +109,37 @@ const NetworkSettings = () => {
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
useEffect(() => deselectNetwork, [deselectNetwork]);
const validateAndSubmit = useCallback(async () => {
if (!data) return;
try {
setFieldErrors(undefined);
await validate(createNetworkSettingsValidator(data), data);
await saveData();
} catch (error) {
setFieldErrors((error as ValidationError).fieldErrors);
}
deselectNetwork();
}, [data, saveData, deselectNetwork]);
const setCancel = useCallback(async () => {
deselectNetwork();
await loadData();
}, [deselectNetwork, loadData]);
const doRestart = useCallback(async () => {
setRestarting(true);
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
(error: Error) => {
toast.error(error.message);
}
);
}, [sendAPI]);
const content = () => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />;
}
const validateAndSubmit = async () => {
try {
setFieldErrors(undefined);
await validate(createNetworkSettingsValidator(data), data);
await saveData();
} catch (error) {
setFieldErrors(error as ValidateFieldsError);
}
deselectNetwork();
};
const setCancel = async () => {
deselectNetwork();
await loadData();
};
const doRestart = async () => {
setRestarting(true);
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
(error: Error) => {
toast.error(error.message);
}
);
};
return (
<>
<Typography variant="h6" color="primary">
@@ -165,7 +164,7 @@ const NetworkSettings = () => {
selectedNetwork.bssid
}
/>
<IconButton onClick={setCancel}>
<IconButton onClick={setCancel} aria-label={LL.CANCEL()}>
<DeleteIcon />
</IconButton>
</ListItem>
@@ -356,8 +355,9 @@ const NetworkSettings = () => {
</>
)}
{restartNeeded && (
<MessageBox my={2} level="warning" message={LL.RESTART_TEXT(0)}>
<MessageBox level="warning" message={LL.RESTART_TEXT(0)}>
<Button
sx={{ ml: 2 }}
startIcon={<PowerSettingsNewIcon />}
variant="contained"
color="error"
@@ -397,12 +397,14 @@ const NetworkSettings = () => {
);
};
return (
return restarting ? (
<SystemMonitor />
) : (
<SectionContent>
{blocker ? <BlockNavigation blocker={blocker} /> : null}
{restarting ? <SystemMonitor /> : content()}
{content()}
</SectionContent>
);
};
export default NetworkSettings;
export default memo(NetworkSettings);

View File

@@ -1,4 +1,4 @@
import { useRef, useState } from 'react';
import { memo, useCallback, useRef, useState } from 'react';
import PermScanWifiIcon from '@mui/icons-material/PermScanWifi';
import { Button } from '@mui/material';
@@ -48,12 +48,12 @@ const WiFiNetworkScanner = () => {
}
});
const renderNetworkScanner = () => {
const renderNetworkScanner = useCallback(() => {
if (!networkList) {
return <FormLoader errorMessage={errorMessage || ''} />;
}
return <WiFiNetworkSelector networkList={networkList} />;
};
}, [networkList, errorMessage]);
return (
<SectionContent>
@@ -73,4 +73,4 @@ const WiFiNetworkScanner = () => {
);
};
export default WiFiNetworkScanner;
export default memo(WiFiNetworkScanner);

View File

@@ -1,4 +1,4 @@
import { useContext } from 'react';
import { memo, useCallback, useContext } from 'react';
import LockIcon from '@mui/icons-material/Lock';
import LockOpenIcon from '@mui/icons-material/LockOpen';
@@ -63,38 +63,41 @@ const WiFiNetworkSelector = ({ networkList }: { networkList: WiFiNetworkList })
const wifiConnectionContext = useContext(WiFiConnectionContext);
const renderNetwork = (network: WiFiNetwork) => (
<ListItem
key={network.bssid}
onClick={() => wifiConnectionContext.selectNetwork(network)}
>
<ListItemAvatar>
<Avatar>{isNetworkOpen(network) ? <LockOpenIcon /> : <LockIcon />}</Avatar>
</ListItemAvatar>
<ListItemText
primary={network.ssid}
secondary={
'Security: ' +
networkSecurityMode(network) +
', Ch: ' +
network.channel +
', bssid: ' +
network.bssid
}
/>
<ListItemIcon>
<Badge badgeContent={network.rssi + 'dBm'}>
<WifiIcon sx={{ color: networkQualityHighlight(network, theme) }} />
</Badge>
</ListItemIcon>
</ListItem>
const renderNetwork = useCallback(
(network: WiFiNetwork) => (
<ListItem
key={network.bssid}
onClick={() => wifiConnectionContext.selectNetwork(network)}
>
<ListItemAvatar>
<Avatar>{isNetworkOpen(network) ? <LockOpenIcon /> : <LockIcon />}</Avatar>
</ListItemAvatar>
<ListItemText
primary={network.ssid}
secondary={
'Security: ' +
networkSecurityMode(network) +
', Ch: ' +
network.channel +
', bssid: ' +
network.bssid
}
/>
<ListItemIcon>
<Badge badgeContent={network.rssi + 'dBm'}>
<WifiIcon sx={{ color: networkQualityHighlight(network, theme) }} />
</Badge>
</ListItemIcon>
</ListItem>
),
[wifiConnectionContext, theme]
);
if (networkList.networks.length === 0) {
return <MessageBox mt={2} mb={1} message={LL.NETWORK_NO_WIFI()} level="info" />;
return <MessageBox message={LL.NETWORK_NO_WIFI()} level="info" />;
}
return <List>{networkList.networks.map(renderNetwork)}</List>;
};
export default WiFiNetworkSelector;
export default memo(WiFiNetworkSelector);

View File

@@ -1,4 +1,4 @@
import { useEffect } from 'react';
import { memo, useEffect } from 'react';
import CloseIcon from '@mui/icons-material/Close';
import {
@@ -40,7 +40,7 @@ const GenerateToken = ({ username, onClose }: GenerateTokenProps) => {
if (open) {
void generateToken();
}
}, [open]);
}, [open, generateToken]);
return (
<Dialog
@@ -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()}&hellip;</Typography>
</Box>
@@ -86,4 +94,4 @@ const GenerateToken = ({ username, onClose }: GenerateTokenProps) => {
);
};
export default GenerateToken;
export default memo(GenerateToken);

View File

@@ -1,4 +1,4 @@
import { useContext, useState } from 'react';
import { memo, useCallback, useContext, useMemo, useState } from 'react';
import { useBlocker } from 'react-router';
import CancelIcon from '@mui/icons-material/Cancel';
@@ -55,14 +55,16 @@ const ManageUsers = () => {
const blocker = useBlocker(changed !== 0);
const { LL } = useI18nContext();
const table_theme = useTheme({
Table: `
const table_theme = useMemo(
() =>
useTheme({
Table: `
--data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) minmax(120px, max-content) 120px;
`,
BaseRow: `
BaseRow: `
font-size: 14px;
`,
HeaderRow: `
HeaderRow: `
text-transform: uppercase;
background-color: black;
color: #90CAF9;
@@ -72,7 +74,7 @@ const ManageUsers = () => {
border-bottom: 1px solid #565656;
}
`,
Row: `
Row: `
.td {
padding: 8px;
border-top: 1px solid #565656;
@@ -85,7 +87,7 @@ const ManageUsers = () => {
background-color: #1e1e1e;
}
`,
BaseCell: `
BaseCell: `
&:nth-of-type(2) {
text-align: center;
}
@@ -93,72 +95,81 @@ const ManageUsers = () => {
text-align: right;
}
`
});
}),
[]
);
const noAdminConfigured = useCallback(
() => !data?.users.find((u) => u.admin),
[data]
);
const removeUser = useCallback(
(toRemove: UserType) => {
if (!data) return;
const users = data.users.filter((u) => u.username !== toRemove.username);
updateDataValue({ ...data, users });
setChanged(changed + 1);
},
[data, updateDataValue, changed]
);
const createUser = useCallback(() => {
setCreating(true);
setUser({
username: '',
password: '',
admin: true
});
}, []);
const editUser = useCallback((toEdit: UserType) => {
setCreating(false);
setUser({ ...toEdit });
}, []);
const cancelEditingUser = useCallback(() => {
setUser(undefined);
}, []);
const doneEditingUser = useCallback(() => {
if (user && data) {
const users = [
...data.users.filter(
(u: { username: string }) => u.username !== user.username
),
user
];
updateDataValue({ ...data, users });
setUser(undefined);
setChanged(changed + 1);
}
}, [user, data, updateDataValue, changed]);
const closeGenerateToken = useCallback(() => {
setGeneratingToken(undefined);
}, []);
const generateTokenForUser = useCallback((username: string) => {
setGeneratingToken(username);
}, []);
const onSubmit = useCallback(async () => {
await saveData();
await authenticatedContext.refresh();
setChanged(0);
}, [saveData, authenticatedContext]);
const onCancelSubmit = useCallback(async () => {
await loadData();
setChanged(0);
}, [loadData]);
const content = () => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />;
}
const noAdminConfigured = () => !data.users.find((u) => u.admin);
const removeUser = (toRemove: UserType) => {
const users = data.users.filter((u) => u.username !== toRemove.username);
updateDataValue({ ...data, users });
setChanged(changed + 1);
};
const createUser = () => {
setCreating(true);
setUser({
username: '',
password: '',
admin: true
});
};
const editUser = (toEdit: UserType) => {
setCreating(false);
setUser({ ...toEdit });
};
const cancelEditingUser = () => {
setUser(undefined);
};
const doneEditingUser = () => {
if (user) {
const users = [
...data.users.filter(
(u: { username: string }) => u.username !== user.username
),
user
];
updateDataValue({ ...data, users });
setUser(undefined);
setChanged(changed + 1);
}
};
const closeGenerateToken = () => {
setGeneratingToken(undefined);
};
const generateToken = (username: string) => {
setGeneratingToken(username);
};
const onSubmit = async () => {
await saveData();
await authenticatedContext.refresh();
setChanged(0);
};
const onCancelSubmit = async () => {
await loadData();
setChanged(0);
};
interface UserType2 {
id: string;
username: string;
@@ -167,10 +178,14 @@ const ManageUsers = () => {
}
// add id to the type, needed for the table
const user_table = data.users.map((u) => ({
...u,
id: u.username
})) as UserType2[];
const user_table = useMemo(
() =>
data.users.map((u) => ({
...u,
id: u.username
})) as UserType2[],
[data.users]
);
return (
<>
@@ -196,15 +211,24 @@ const ManageUsers = () => {
<Cell stiff>
<IconButton
size="small"
aria-label={LL.GENERATING_TOKEN()}
disabled={!authenticatedContext.me.admin}
onClick={() => generateToken(u.username)}
onClick={() => generateTokenForUser(u.username)}
>
<VpnKeyIcon />
</IconButton>
<IconButton size="small" onClick={() => removeUser(u)}>
<IconButton
size="small"
onClick={() => removeUser(u)}
aria-label={LL.REMOVE()}
>
<DeleteIcon />
</IconButton>
<IconButton size="small" onClick={() => editUser(u)}>
<IconButton
size="small"
onClick={() => editUser(u)}
aria-label={LL.EDIT()}
>
<EditIcon />
</IconButton>
</Cell>
@@ -216,12 +240,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 />}
@@ -246,7 +274,7 @@ const ManageUsers = () => {
</ButtonRow>
</Box>
)}
<Box flexWrap="nowrap" whiteSpace="nowrap">
<Box sx={{ flexWrap: 'nowrap', whiteSpace: 'nowrap' }}>
<ButtonRow>
<Button
startIcon={<PersonAddIcon />}
@@ -286,4 +314,4 @@ const ManageUsers = () => {
);
};
export default ManageUsers;
export default memo(ManageUsers);

View File

@@ -1,3 +1,4 @@
import { memo, useMemo } from 'react';
import { Navigate, Route, Routes, matchRoutes, useLocation } from 'react-router';
import { Tab } from '@mui/material';
@@ -12,12 +13,21 @@ const Security = () => {
const { LL } = useI18nContext();
useLayoutTitle(LL.SECURITY(0));
const matchedRoutes = matchRoutes(
[
{ path: '/settings/security/settings', element: <ManageUsers />, dog: 'woof' },
{ path: '/settings/security/users', element: <SecuritySettings /> }
],
useLocation()
const location = useLocation();
const matchedRoutes = useMemo(
() =>
matchRoutes(
[
{
path: '/settings/security/settings',
element: <ManageUsers />
},
{ path: '/settings/security/users', element: <SecuritySettings /> }
],
location
),
[location]
);
const routerTab = matchedRoutes?.[0]?.route.path || false;
@@ -42,4 +52,4 @@ const Security = () => {
);
};
export default Security;
export default memo(Security);

View File

@@ -1,4 +1,4 @@
import { useContext, useState } from 'react';
import { memo, useCallback, useContext, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel';
import WarningIcon from '@mui/icons-material/Warning';
@@ -19,7 +19,7 @@ import { AuthenticatedContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react';
import type { SecuritySettingsType } from 'types';
import { updateValueDirty, useRest } from 'utils';
import { SECURITY_SETTINGS_VALIDATOR, validate } from 'validators';
import { SECURITY_SETTINGS_VALIDATOR, ValidationError, validate } from 'validators';
const SecuritySettings = () => {
const { LL } = useI18nContext();
@@ -44,28 +44,29 @@ const SecuritySettings = () => {
const authenticatedContext = useContext(AuthenticatedContext);
const updateFormValue = updateValueDirty(
origData,
origData as unknown as Record<string, unknown>,
dirtyFlags,
setDirtyFlags,
updateDataValue as (value: unknown) => void
);
const validateAndSubmit = useCallback(async () => {
if (!data) return;
try {
setFieldErrors(undefined);
await validate(SECURITY_SETTINGS_VALIDATOR, data);
await saveData();
await authenticatedContext.refresh();
} catch (error) {
setFieldErrors((error as ValidationError).fieldErrors);
}
}, [data, saveData, authenticatedContext]);
const content = () => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />;
}
const validateAndSubmit = async () => {
try {
setFieldErrors(undefined);
await validate(SECURITY_SETTINGS_VALIDATOR, data);
await saveData();
await authenticatedContext.refresh();
} catch (error) {
setFieldErrors(error as ValidateFieldsError);
}
};
return (
<>
<ValidatedPasswordField
@@ -115,4 +116,4 @@ const SecuritySettings = () => {
);
};
export default SecuritySettings;
export default memo(SecuritySettings);

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { memo, useCallback, useEffect, useState } from 'react';
import type { FC } from 'react';
import CancelIcon from '@mui/icons-material/Cancel';
@@ -24,7 +24,7 @@ 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';
interface UserFormProps {
creating: boolean;
@@ -45,7 +45,14 @@ const User: FC<UserFormProps> = ({
}) => {
const { LL } = useI18nContext();
const updateFormValue = updateValue(setUser);
const updateFormValue = updateValue((updater) => {
setUser((prevState) => {
if (!prevState) return prevState;
return updater(
prevState as unknown as Record<string, unknown>
) as unknown as UserType;
});
});
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const open = !!user;
@@ -55,17 +62,17 @@ const User: FC<UserFormProps> = ({
}
}, [open]);
const validateAndDone = async () => {
const validateAndDone = useCallback(async () => {
if (user) {
try {
setFieldErrors(undefined);
await validate(validator, user);
onDoneEditing();
} catch (error) {
setFieldErrors(error as ValidateFieldsError);
setFieldErrors((error as ValidationError).fieldErrors);
}
}
};
}, [user, validator, onDoneEditing]);
return (
<Dialog
@@ -137,4 +144,4 @@ const User: FC<UserFormProps> = ({
);
};
export default User;
export default memo(User);

View File

@@ -34,37 +34,43 @@ export const apStatusHighlight = ({ status }: APStatusType, theme: Theme) => {
}
};
const getApStatusText = (
status: APNetworkStatus,
LL: ReturnType<typeof useI18nContext>['LL']
) => {
switch (status) {
case APNetworkStatus.ACTIVE:
return LL.ACTIVE();
case APNetworkStatus.INACTIVE:
return LL.INACTIVE(0);
case APNetworkStatus.LINGERING:
return 'Lingering until idle';
default:
return LL.UNKNOWN();
}
};
const APStatus = () => {
const { data, send: loadData, error } = useRequest(APApi.readAPStatus);
const { LL } = useI18nContext();
const theme = useTheme();
useLayoutTitle(LL.ACCESS_POINT(0));
useInterval(() => {
void loadData();
});
const { LL } = useI18nContext();
useLayoutTitle(LL.ACCESS_POINT(0));
const theme = useTheme();
const apStatus = ({ status }: APStatusType) => {
switch (status) {
case APNetworkStatus.ACTIVE:
return LL.ACTIVE();
case APNetworkStatus.INACTIVE:
return LL.INACTIVE(0);
case APNetworkStatus.LINGERING:
return 'Lingering until idle';
default:
return LL.UNKNOWN();
}
};
const content = () => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
}
if (!data) {
return (
<SectionContent>
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
</SectionContent>
);
}
return (
<SectionContent>
<List>
<ListItem>
<ListItemAvatar>
@@ -72,19 +78,26 @@ const APStatus = () => {
<SettingsInputAntennaIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary={LL.STATUS_OF('')} secondary={apStatus(data)} />
<ListItemText
primary={LL.STATUS_OF('')}
secondary={getApStatusText(data.status, LL)}
/>
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar>IP</Avatar>
<Avatar sx={{ bgcolor: 'primary.main' }}>IP</Avatar>
</ListItemAvatar>
<ListItemText primary={LL.ADDRESS_OF('IP')} secondary={data.ip_address} />
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar>
<Avatar sx={{ bgcolor: 'primary.main' }}>
<DeviceHubIcon />
</Avatar>
</ListItemAvatar>
@@ -93,21 +106,22 @@ const APStatus = () => {
secondary={data.mac_address}
/>
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar>
<Avatar sx={{ bgcolor: 'primary.main' }}>
<ComputerIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary={LL.AP_CLIENTS()} secondary={data.station_num} />
</ListItem>
<Divider variant="inset" component="li" />
</List>
);
};
return <SectionContent>{content()}</SectionContent>;
</SectionContent>
);
};
export default APStatus;

View File

@@ -1,3 +1,5 @@
import { useCallback, useMemo } from 'react';
import {
Body,
Cell,
@@ -17,6 +19,12 @@ import { useInterval } from 'utils';
import { readActivity } from '../../api/app';
import type { Stat } from '../main/types';
const QUALITY_COLORS = {
PERFECT: '#00FF7F',
WARNING: 'orange',
POOR: 'red'
} as const;
const SystemActivity = () => {
const { data, send: loadData, error } = useRequest(readActivity);
@@ -28,14 +36,16 @@ const SystemActivity = () => {
useLayoutTitle(LL.DATA_TRAFFIC());
const stats_theme = tableTheme({
Table: `
const stats_theme = tableTheme(
useMemo(
() => ({
Table: `
--data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) 90px 90px 80px;
`,
BaseRow: `
BaseRow: `
font-size: 14px;
`,
HeaderRow: `
HeaderRow: `
text-transform: uppercase;
background-color: black;
color: #90CAF9;
@@ -45,7 +55,7 @@ const SystemActivity = () => {
border-bottom: 1px solid #565656;
}
`,
Row: `
Row: `
.td {
padding: 8px;
border-top: 1px solid #565656;
@@ -59,34 +69,40 @@ const SystemActivity = () => {
background-color: #1e1e1e;
}
`,
BaseCell: `
BaseCell: `
&:not(:first-of-type) {
text-align: center;
}
`
});
}),
[]
)
);
const showName = (id: number) => {
const name: keyof Translation['STATUS_NAMES'] =
id.toString() as keyof Translation['STATUS_NAMES'];
return LL.STATUS_NAMES[name]();
};
const showName = useCallback(
(id: number) => {
const name: keyof Translation['STATUS_NAMES'] =
id.toString() as keyof Translation['STATUS_NAMES'];
return LL.STATUS_NAMES[name]();
},
[LL]
);
const showQuality = (stat: Stat) => {
const showQuality = useCallback((stat: Stat) => {
if (stat.q === 0 || stat.s + stat.f === 0) {
return;
}
if (stat.q === 100) {
return <div style={{ color: '#00FF7F' }}>{stat.q}%</div>;
return <div style={{ color: QUALITY_COLORS.PERFECT }}>{stat.q}%</div>;
}
if (stat.q >= 95) {
return <div style={{ color: 'orange' }}>{stat.q}%</div>;
return <div style={{ color: QUALITY_COLORS.WARNING }}>{stat.q}%</div>;
} else {
return <div style={{ color: 'red' }}>{stat.q}%</div>;
return <div style={{ color: QUALITY_COLORS.POOR }}>{stat.q}%</div>;
}
};
}, []);
const content = () => {
const content = useMemo(() => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
}
@@ -121,9 +137,9 @@ const SystemActivity = () => {
)}
</Table>
);
};
}, [data, loadData, error?.message, stats_theme, LL, showName, showQuality]);
return <SectionContent>{content()}</SectionContent>;
return <SectionContent>{content}</SectionContent>;
};
export default SystemActivity;

View File

@@ -1,3 +1,5 @@
import { ReactElement } from 'react';
import AppsIcon from '@mui/icons-material/Apps';
import DeveloperBoardIcon from '@mui/icons-material/DeveloperBoard';
import DevicesIcon from '@mui/icons-material/Devices';
@@ -24,10 +26,61 @@ import { useInterval } from 'utils';
import BBQKeesIcon from './bbqkees.svg';
// Constants
const AVATAR_COLORS = {
DEFAULT: '#5f9a5f',
BBQKEES: '#003289'
} as const;
const TEMP_THRESHOLD_CELSIUS = 90; // Temperature threshold to determine F vs C
function formatNumber(num: number) {
return new Intl.NumberFormat().format(num);
}
function formatTemperature(temp?: number): string {
if (!temp) return '';
const unit = temp > TEMP_THRESHOLD_CELSIUS ? 'F' : 'C';
return `, T: ${temp} °${unit}`;
}
function formatFlashSpeed(speed: number): string {
return (speed / 1000000).toFixed(0) + ' MHz';
}
function formatCPUCores(cores: number): string {
return cores === 1 ? 'single-core)' : 'dual-core)';
}
// Reusable component for hardware status list items
interface HardwareListItemProps {
icon: ReactElement;
primary: string;
secondary: string;
avatarColor?: string;
customIcon?: ReactElement | undefined;
}
const HardwareListItem = ({
icon,
primary,
secondary,
avatarColor = AVATAR_COLORS.DEFAULT,
customIcon
}: HardwareListItemProps) => (
<>
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: avatarColor, color: 'white' }}>
{customIcon || icon}
</Avatar>
</ListItemAvatar>
<ListItemText primary={primary} secondary={secondary} />
</ListItem>
<Divider variant="inset" component="li" />
</>
);
const HardwareStatus = () => {
const { LL } = useI18nContext();
@@ -39,175 +92,72 @@ const HardwareStatus = () => {
void loadData();
});
const content = () => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
}
if (!data) {
return (
<List>
<ListItem>
<ListItemAvatar>
{data.model ? (
<Avatar sx={{ bgcolor: '#003289', color: 'white' }}>
<img
alt="BBQKees"
src={BBQKeesIcon}
style={{ width: 16, verticalAlign: 'middle' }}
/>
</Avatar>
) : (
<Avatar sx={{ bgcolor: '#5f9a5f', color: 'white' }}>
<TapAndPlayIcon />
</Avatar>
)}
</ListItemAvatar>
<ListItemText
primary={LL.HARDWARE() + ' ' + LL.DEVICE()}
secondary={data.model ? data.model : data.cpu_type}
/>
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: '#5f9a5f', color: 'white' }}>
<DevicesIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary="SDK"
secondary={data.arduino_version + ' / ESP-IDF ' + data.sdk_version}
/>
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: '#5f9a5f', color: 'white' }}>
<DeveloperBoardIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary="CPU"
secondary={
data.esp_platform +
'/' +
data.cpu_type +
' (rev.' +
data.cpu_rev +
', ' +
(data.cpu_cores === 1 ? 'single-core)' : 'dual-core)') +
' @ ' +
data.cpu_freq_mhz +
' Mhz' +
// bit of a hack : if the CPU temp is higher than 90 (=32 Fahrenheit if using Celsius), show F, otherwise C
(data.temperature
? ', T: ' +
data.temperature +
' °' +
(data.temperature > 90 ? 'F' : 'C')
: '')
}
/>
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: '#5f9a5f', color: 'white' }}>
<MemoryIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={LL.FREE_MEMORY()}
secondary={
formatNumber(data.free_heap) +
' KB (' +
formatNumber(data.max_alloc_heap) +
' KB max alloc, ' +
formatNumber(data.free_caps) +
' KB caps)'
}
/>
</ListItem>
{data.psram_size !== undefined && data.free_psram !== undefined && (
<>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: '#5f9a5f', color: 'white' }}>
<AppsIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={LL.PSRAM()}
secondary={
formatNumber(data.psram_size) +
' KB / ' +
formatNumber(data.free_psram) +
' KB'
}
/>
</ListItem>
</>
)}
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: '#5f9a5f', color: 'white' }}>
<SdStorageIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={LL.FLASH()}
secondary={
formatNumber(data.flash_chip_size) +
' KB , ' +
(data.flash_chip_speed / 1000000).toFixed(0) +
' MHz'
}
/>
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: '#5f9a5f', color: 'white' }}>
<SdCardAlertIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={LL.APPSIZE()}
secondary={
data.partition +
': ' +
formatNumber(data.app_used) +
' KB / ' +
formatNumber(data.app_free) +
' KB'
}
/>
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: '#5f9a5f', color: 'white' }}>
<FolderIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={LL.FILESYSTEM()}
secondary={
formatNumber(data.fs_used) +
' KB / ' +
formatNumber(data.fs_free) +
' KB'
}
/>
</ListItem>
<Divider variant="inset" component="li" />
</List>
<SectionContent>
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
</SectionContent>
);
};
}
return <SectionContent>{content()}</SectionContent>;
return (
<SectionContent>
<List>
<HardwareListItem
icon={<TapAndPlayIcon />}
primary={`${LL.HARDWARE()} ${LL.DEVICE()}`}
secondary={data.model || data.cpu_type}
avatarColor={data.model ? AVATAR_COLORS.BBQKEES : AVATAR_COLORS.DEFAULT}
customIcon={
data.model ? (
<img
alt="BBQKees"
src={BBQKeesIcon}
style={{ width: 16, verticalAlign: 'middle' }}
/>
) : undefined
}
/>
<HardwareListItem
icon={<DevicesIcon />}
primary="SDK"
secondary={`${data.arduino_version} / ESP-IDF ${data.sdk_version}`}
/>
<HardwareListItem
icon={<DeveloperBoardIcon />}
primary="CPU"
secondary={`${data.esp_platform}/${data.cpu_type} (rev.${data.cpu_rev}, ${formatCPUCores(data.cpu_cores)} @ ${data.cpu_freq_mhz} Mhz${formatTemperature(data.temperature)}`}
/>
<HardwareListItem
icon={<MemoryIcon />}
primary={LL.FREE_MEMORY()}
secondary={`${formatNumber(data.free_heap)} KB (${formatNumber(data.max_alloc_heap)} KB max alloc, ${formatNumber(data.free_caps)} KB caps)`}
/>
{data.psram_size !== undefined && data.free_psram !== undefined && (
<HardwareListItem
icon={<AppsIcon />}
primary={LL.PSRAM()}
secondary={`${formatNumber(data.psram_size)} KB / ${formatNumber(data.free_psram)} KB`}
/>
)}
<HardwareListItem
icon={<SdStorageIcon />}
primary={LL.FLASH()}
secondary={`${formatNumber(data.flash_chip_size)} KB , ${formatFlashSpeed(data.flash_chip_speed)}`}
/>
<HardwareListItem
icon={<SdCardAlertIcon />}
primary={LL.APPSIZE()}
secondary={`${data.partition}: ${formatNumber(data.app_used)} KB / ${formatNumber(data.app_free)} KB`}
/>
<HardwareListItem
icon={<FolderIcon />}
primary={LL.FILESYSTEM()}
secondary={`${formatNumber(data.fs_used)} KB / ${formatNumber(data.fs_free)} KB`}
/>
</List>
</SectionContent>
);
};
export default HardwareStatus;

View File

@@ -1,3 +1,5 @@
import { type FC, memo, useMemo } from 'react';
import AutoAwesomeMotionIcon from '@mui/icons-material/AutoAwesomeMotion';
import DeviceHubIcon from '@mui/icons-material/DeviceHub';
import ReportIcon from '@mui/icons-material/Report';
@@ -22,17 +24,28 @@ import type { MqttStatusType } from 'types';
import { MqttDisconnectReason } from 'types';
import { useInterval } from 'utils';
// Disconnect reason lookup table - created once, reused across renders
const DISCONNECT_REASONS: Record<MqttDisconnectReason, string> = {
[MqttDisconnectReason.USER_OK]: 'User disconnected',
[MqttDisconnectReason.TCP_DISCONNECTED]: 'TCP disconnected',
[MqttDisconnectReason.MQTT_UNACCEPTABLE_PROTOCOL_VERSION]:
'Unacceptable protocol version',
[MqttDisconnectReason.MQTT_IDENTIFIER_REJECTED]: 'Client ID rejected',
[MqttDisconnectReason.MQTT_SERVER_UNAVAILABLE]: 'Server unavailable',
[MqttDisconnectReason.MQTT_MALFORMED_CREDENTIALS]: 'Malformed credentials',
[MqttDisconnectReason.MQTT_NOT_AUTHORIZED]: 'Not authorized',
[MqttDisconnectReason.TLS_BAD_FINGERPRINT]: 'TLS fingerprint invalid'
};
const getDisconnectReason = (disconnect_reason: MqttDisconnectReason): string =>
DISCONNECT_REASONS[disconnect_reason] ?? 'Unknown';
export const mqttStatusHighlight = (
{ enabled, connected }: MqttStatusType,
theme: Theme
) => {
if (!enabled) {
return theme.palette.info.main;
}
if (connected) {
return theme.palette.success.main;
}
return theme.palette.error.main;
if (!enabled) return theme.palette.info.main;
return connected ? theme.palette.success.main : theme.palette.error.main;
};
export const mqttPublishHighlight = (
@@ -41,114 +54,100 @@ export const mqttPublishHighlight = (
) => {
if (mqtt_fails === 0) return theme.palette.success.main;
if (mqtt_fails < 10) return theme.palette.warning.main;
return theme.palette.error.main;
};
export const mqttQueueHighlight = (
{ mqtt_queued }: MqttStatusType,
theme: Theme
) => {
if (mqtt_queued <= 1) return theme.palette.success.main;
export const mqttQueueHighlight = ({ mqtt_queued }: MqttStatusType, theme: Theme) =>
mqtt_queued <= 1 ? theme.palette.success.main : theme.palette.warning.main;
return theme.palette.warning.main;
};
interface ConnectionStatusProps {
data: MqttStatusType;
theme: Theme;
}
// Memoized component to prevent unnecessary re-renders when parent updates
const ConnectionStatus: FC<ConnectionStatusProps> = memo(({ data, theme }) => {
const { LL } = useI18nContext();
return (
<>
{!data.connected && (
<>
<ListItem>
<ListItemAvatar>
<Avatar>
<ReportIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={LL.DISCONNECT_REASON()}
secondary={getDisconnectReason(data.disconnect_reason)}
/>
</ListItem>
<Divider variant="inset" component="li" />
</>
)}
<ListItem>
<ListItemAvatar>
<Avatar>#</Avatar>
</ListItemAvatar>
<ListItemText primary={LL.ID_OF(LL.CLIENT())} secondary={data.client_id} />
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: mqttQueueHighlight(data, theme) }}>
<AutoAwesomeMotionIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary={LL.MQTT_QUEUE()} secondary={data.mqtt_queued} />
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: mqttPublishHighlight(data, theme) }}>
<SpeakerNotesOffIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary={LL.ERRORS_OF('MQTT')} secondary={data.mqtt_fails} />
</ListItem>
<Divider variant="inset" component="li" />
</>
);
});
const MqttStatus = () => {
const { data, send: loadData, error } = useRequest(MqttApi.readMqttStatus);
const { LL } = useI18nContext();
const theme = useTheme();
useLayoutTitle('MQTT');
useInterval(() => {
void loadData();
});
const { LL } = useI18nContext();
useLayoutTitle('MQTT');
// Memoize error message separately to avoid re-renders on error object changes
const errorMessage = error?.message || '';
const theme = useTheme();
const mqttStatus = ({ enabled, connected, connect_count }: MqttStatusType) => {
if (!enabled) {
return LL.NOT_ENABLED();
}
if (connected) {
return LL.CONNECTED(0) + ' (' + connect_count + ')';
}
return LL.DISCONNECTED() + ' (' + connect_count + ')';
};
const disconnectReason = ({ disconnect_reason }: MqttStatusType) => {
switch (disconnect_reason) {
case MqttDisconnectReason.TCP_DISCONNECTED:
return 'TCP disconnected';
case MqttDisconnectReason.MQTT_UNACCEPTABLE_PROTOCOL_VERSION:
return 'Unacceptable protocol version';
case MqttDisconnectReason.MQTT_IDENTIFIER_REJECTED:
return 'Client ID rejected';
case MqttDisconnectReason.MQTT_SERVER_UNAVAILABLE:
return 'Server unavailable';
case MqttDisconnectReason.MQTT_MALFORMED_CREDENTIALS:
return 'Malformed credentials';
case MqttDisconnectReason.MQTT_NOT_AUTHORIZED:
return 'Not authorized';
case MqttDisconnectReason.TLS_BAD_FINGERPRINT:
return 'TLS fingerprint invalid';
default:
return 'Unknown';
}
};
const content = () => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
}
const renderConnectionStatus = () => (
<>
{!data.connected && (
<>
<ListItem>
<ListItemAvatar>
<Avatar>
<ReportIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={LL.DISCONNECT_REASON()}
secondary={disconnectReason(data)}
/>
</ListItem>
<Divider variant="inset" component="li" />
</>
)}
<ListItem>
<ListItemAvatar>
<Avatar>#</Avatar>
</ListItemAvatar>
<ListItemText primary={LL.ID_OF(LL.CLIENT())} secondary={data.client_id} />
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: mqttQueueHighlight(data, theme) }}>
<AutoAwesomeMotionIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary={LL.MQTT_QUEUE()} secondary={data.mqtt_queued} />
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: mqttPublishHighlight(data, theme) }}>
<SpeakerNotesOffIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary={LL.ERRORS_OF('MQTT')} secondary={data.mqtt_fails} />
</ListItem>
<Divider variant="inset" component="li" />
</>
);
const mqttStatusText = useMemo(() => {
if (!data) return '';
if (!data.enabled) return LL.NOT_ENABLED();
return data.connected
? `${LL.CONNECTED(0)} (${data.connect_count})`
: `${LL.DISCONNECTED()} (${data.connect_count})`;
}, [data, LL]);
if (!data) {
return (
<SectionContent>
<FormLoader onRetry={loadData} errorMessage={errorMessage} />
</SectionContent>
);
}
return (
<SectionContent>
<List>
<ListItem>
<ListItemAvatar>
@@ -156,15 +155,13 @@ const MqttStatus = () => {
<DeviceHubIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary={LL.STATUS_OF('')} secondary={mqttStatus(data)} />
<ListItemText primary={LL.STATUS_OF('')} secondary={mqttStatusText} />
</ListItem>
<Divider variant="inset" component="li" />
{data.enabled && renderConnectionStatus()}
{data.enabled && <ConnectionStatus data={data} theme={theme} />}
</List>
);
};
return <SectionContent>{content()}</SectionContent>;
</SectionContent>
);
};
export default MqttStatus;

View File

@@ -1,3 +1,5 @@
import { useMemo } from 'react';
import AccessTimeIcon from '@mui/icons-material/AccessTime';
import DnsIcon from '@mui/icons-material/Dns';
import SwapVerticalCircleIcon from '@mui/icons-material/SwapVerticalCircle';
@@ -23,6 +25,23 @@ import { NTPSyncStatus } from 'types';
import { useInterval } from 'utils';
import { formatDateTime } from 'utils';
// Utility functions
const isNtpEnabled = ({ status }: NTPStatusType) =>
status !== NTPSyncStatus.NTP_DISABLED;
const ntpStatusHighlight = ({ status }: NTPStatusType, theme: Theme) => {
switch (status) {
case NTPSyncStatus.NTP_DISABLED:
return theme.palette.info.main;
case NTPSyncStatus.NTP_INACTIVE:
return theme.palette.error.main;
case NTPSyncStatus.NTP_ACTIVE:
return theme.palette.success.main;
default:
return theme.palette.error.main;
}
};
const NTPStatus = () => {
const { data, send: loadData, error } = useRequest(NTPApi.readNTPStatus);
@@ -33,24 +52,6 @@ const NTPStatus = () => {
const { LL } = useI18nContext();
useLayoutTitle('NTP');
NTPApi.updateTime;
const isNtpEnabled = ({ status }: NTPStatusType) =>
status !== NTPSyncStatus.NTP_DISABLED;
const ntpStatusHighlight = ({ status }: NTPStatusType, theme: Theme) => {
switch (status) {
case NTPSyncStatus.NTP_DISABLED:
return theme.palette.info.main;
case NTPSyncStatus.NTP_INACTIVE:
return theme.palette.error.main;
case NTPSyncStatus.NTP_ACTIVE:
return theme.palette.success.main;
default:
return theme.palette.error.main;
}
};
const theme = useTheme();
const ntpStatus = ({ status }: NTPStatusType) => {
@@ -66,66 +67,64 @@ const NTPStatus = () => {
}
};
const content = () => {
const content = useMemo(() => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
}
return (
<>
<List>
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: ntpStatusHighlight(data, theme) }}>
<UpdateIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary={LL.STATUS_OF('')} secondary={ntpStatus(data)} />
</ListItem>
<Divider variant="inset" component="li" />
{isNtpEnabled(data) && (
<>
<ListItem>
<ListItemAvatar>
<Avatar>
<DnsIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary={LL.NTP_SERVER()} secondary={data.server} />
</ListItem>
<Divider variant="inset" component="li" />
</>
)}
<ListItem>
<ListItemAvatar>
<Avatar>
<AccessTimeIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={LL.LOCAL_TIME(0)}
secondary={formatDateTime(data.local_time)}
/>
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar>
<SwapVerticalCircleIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={LL.UTC_TIME()}
secondary={formatDateTime(data.utc_time)}
/>
</ListItem>
<Divider variant="inset" component="li" />
</List>
</>
<List>
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: ntpStatusHighlight(data, theme) }}>
<UpdateIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary={LL.STATUS_OF('')} secondary={ntpStatus(data)} />
</ListItem>
<Divider variant="inset" component="li" />
{isNtpEnabled(data) && (
<>
<ListItem>
<ListItemAvatar>
<Avatar>
<DnsIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary={LL.NTP_SERVER()} secondary={data.server} />
</ListItem>
<Divider variant="inset" component="li" />
</>
)}
<ListItem>
<ListItemAvatar>
<Avatar>
<AccessTimeIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={LL.LOCAL_TIME(0)}
secondary={formatDateTime(data.local_time)}
/>
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar>
<SwapVerticalCircleIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={LL.UTC_TIME()}
secondary={formatDateTime(data.utc_time)}
/>
</ListItem>
<Divider variant="inset" component="li" />
</List>
);
};
}, [data, error, loadData, LL, theme]);
return <SectionContent>{content()}</SectionContent>;
return <SectionContent>{content}</SectionContent>;
};
export default NTPStatus;

View File

@@ -1,3 +1,5 @@
import { useMemo } from 'react';
import DeviceHubIcon from '@mui/icons-material/DeviceHub';
import DnsIcon from '@mui/icons-material/Dns';
import GiteIcon from '@mui/icons-material/Gite';
@@ -25,10 +27,17 @@ import type { NetworkStatusType } from 'types';
import { NetworkConnectionStatus } from 'types';
import { useInterval } from 'utils';
// Utility functions
const isConnected = ({ status }: NetworkStatusType) =>
status === NetworkConnectionStatus.WIFI_STATUS_CONNECTED ||
status === NetworkConnectionStatus.ETHERNET_STATUS_CONNECTED;
export const isWiFi = ({ status }: NetworkStatusType) =>
status === NetworkConnectionStatus.WIFI_STATUS_CONNECTED;
export const isEthernet = ({ status }: NetworkStatusType) =>
status === NetworkConnectionStatus.ETHERNET_STATUS_CONNECTED;
const networkStatusHighlight = ({ status }: NetworkStatusType, theme: Theme) => {
switch (status) {
case NetworkConnectionStatus.WIFI_STATUS_IDLE:
@@ -55,11 +64,6 @@ const networkQualityHighlight = ({ rssi }: NetworkStatusType, theme: Theme) => {
return theme.palette.success.main;
};
export const isWiFi = ({ status }: NetworkStatusType) =>
status === NetworkConnectionStatus.WIFI_STATUS_CONNECTED;
export const isEthernet = ({ status }: NetworkStatusType) =>
status === NetworkConnectionStatus.ETHERNET_STATUS_CONNECTED;
const dnsServers = ({ dns_ip_1, dns_ip_2 }: NetworkStatusType) => {
if (!dns_ip_1) {
return 'none';
@@ -81,6 +85,33 @@ const IPs = (status: NetworkStatusType) => {
return status.local_ip + ', ' + status.local_ipv6;
};
const getNetworkStatusText = (
status: NetworkConnectionStatus,
reconnectCount: number,
LL: ReturnType<typeof useI18nContext>['LL']
) => {
switch (status) {
case NetworkConnectionStatus.ETHERNET_STATUS_CONNECTED:
return LL.CONNECTED(0) + ' (Ethernet)';
case NetworkConnectionStatus.WIFI_STATUS_NO_SHIELD:
return LL.INACTIVE(1);
case NetworkConnectionStatus.WIFI_STATUS_IDLE:
return LL.IDLE();
case NetworkConnectionStatus.WIFI_STATUS_NO_SSID_AVAIL:
return 'No SSID Available';
case NetworkConnectionStatus.WIFI_STATUS_CONNECTED:
return LL.CONNECTED(0) + ' (WiFi) (' + reconnectCount + ')';
case NetworkConnectionStatus.WIFI_STATUS_CONNECT_FAILED:
return LL.CONNECTED(1) + ' ' + LL.FAILED(0) + ' (' + reconnectCount + ')';
case NetworkConnectionStatus.WIFI_STATUS_CONNECTION_LOST:
return LL.CONNECTED(1) + ' ' + LL.LOST() + ' (' + reconnectCount + ')';
case NetworkConnectionStatus.WIFI_STATUS_DISCONNECTED:
return LL.DISCONNECTED();
default:
return LL.UNKNOWN();
}
};
const NetworkStatus = () => {
const { data, send: loadData, error } = useRequest(NetworkApi.readNetworkStatus);
@@ -93,51 +124,30 @@ const NetworkStatus = () => {
const theme = useTheme();
const networkStatus = ({ status }: NetworkStatusType) => {
switch (status) {
case NetworkConnectionStatus.ETHERNET_STATUS_CONNECTED:
return LL.CONNECTED(0) + ' (Ethernet)';
case NetworkConnectionStatus.WIFI_STATUS_NO_SHIELD:
return LL.INACTIVE(1);
case NetworkConnectionStatus.WIFI_STATUS_IDLE:
return LL.IDLE();
case NetworkConnectionStatus.WIFI_STATUS_NO_SSID_AVAIL:
return 'No SSID Available';
case NetworkConnectionStatus.WIFI_STATUS_CONNECTED:
return LL.CONNECTED(0) + ' (WiFi) (' + data.reconnect_count + ')';
case NetworkConnectionStatus.WIFI_STATUS_CONNECT_FAILED:
return (
LL.CONNECTED(1) + ' ' + LL.FAILED(0) + ' (' + data.reconnect_count + ')'
);
case NetworkConnectionStatus.WIFI_STATUS_CONNECTION_LOST:
return LL.CONNECTED(1) + ' ' + LL.LOST() + ' (' + data.reconnect_count + ')';
case NetworkConnectionStatus.WIFI_STATUS_DISCONNECTED:
return LL.DISCONNECTED();
default:
return LL.UNKNOWN();
}
};
const content = () => {
const content = useMemo(() => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
}
const statusText = getNetworkStatusText(data.status, data.reconnect_count, LL);
const statusColor = networkStatusHighlight(data, theme);
const qualityColor = networkQualityHighlight(data, theme);
return (
<List>
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: networkStatusHighlight(data, theme) }}>
<Avatar sx={{ bgcolor: statusColor }}>
{isWiFi(data) && <WifiIcon />}
{isEthernet(data) && <RouterIcon />}
</Avatar>
</ListItemAvatar>
<ListItemText primary="Status" secondary={networkStatus(data)} />
<ListItemText primary="Status" secondary={statusText} />
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: networkStatusHighlight(data, theme) }}>
<Avatar sx={{ bgcolor: statusColor }}>
<GiteIcon />
</Avatar>
</ListItemAvatar>
@@ -148,13 +158,13 @@ const NetworkStatus = () => {
<>
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: networkQualityHighlight(data, theme) }}>
<Avatar sx={{ bgcolor: qualityColor }}>
<SettingsInputAntennaIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary="SSID (RSSI)"
secondary={data.ssid + ' (' + data.rssi + ' dBm)'}
secondary={`${data.ssid} (${data.rssi} dBm)`}
/>
</ListItem>
<Divider variant="inset" component="li" />
@@ -218,9 +228,9 @@ const NetworkStatus = () => {
)}
</List>
);
};
}, [data, error, loadData, LL, theme]);
return <SectionContent>{content()}</SectionContent>;
return <SectionContent>{content}</SectionContent>;
};
export default NetworkStatus;

View File

@@ -1,4 +1,4 @@
import { useContext, useState } from 'react';
import { useCallback, useContext, useMemo, useState } from 'react';
import { toast } from 'react-toastify';
import AccessTimeIcon from '@mui/icons-material/AccessTime';
@@ -8,10 +8,10 @@ import DeviceHubIcon from '@mui/icons-material/DeviceHub';
import DirectionsBusIcon from '@mui/icons-material/DirectionsBus';
import LogoDevIcon from '@mui/icons-material/LogoDev';
import MemoryIcon from '@mui/icons-material/Memory';
import MonitorHeartIcon from '@mui/icons-material/MonitorHeart';
import PowerSettingsNewIcon from '@mui/icons-material/PowerSettingsNew';
import RouterIcon from '@mui/icons-material/Router';
import SettingsInputAntennaIcon from '@mui/icons-material/SettingsInputAntenna';
import TimerIcon from '@mui/icons-material/Timer';
import WifiIcon from '@mui/icons-material/Wifi';
import {
Avatar,
@@ -37,12 +37,34 @@ import { FormLoader, SectionContent, useLayoutTitle } from 'components';
import ListMenuItem from 'components/layout/ListMenuItem';
import { AuthenticatedContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react';
import { NTPSyncStatus, NetworkConnectionStatus } from 'types';
import { NTPSyncStatus, NetworkConnectionStatus, SystemStatusCodes } from 'types';
import { useInterval } from 'utils';
import { formatDateTime } from 'utils/time';
import SystemMonitor from './SystemMonitor';
// Pure functions moved outside component to avoid recreation on each render
const formatNumber = (num: number) => new Intl.NumberFormat().format(num);
const formatDurationSec = (
duration_sec: number,
LL: ReturnType<typeof useI18nContext>['LL']
) => {
const ms = duration_sec * 1000;
const days = Math.trunc(ms / 86400000);
const hours = Math.trunc(ms / 3600000) % 24;
const minutes = Math.trunc(ms / 60000) % 60;
const seconds = Math.trunc(ms / 1000) % 60;
const parts: string[] = [];
if (days) parts.push(LL.NUM_DAYS({ num: days }));
if (hours) parts.push(LL.NUM_HOURS({ num: hours }));
if (minutes) parts.push(LL.NUM_MINUTES({ num: minutes }));
parts.push(LL.NUM_SECONDS({ num: seconds }));
return parts.join(' ');
};
const SystemStatus = () => {
const { LL } = useI18nContext();
@@ -62,7 +84,6 @@ const SystemStatus = () => {
send: loadData,
error
} = useRequest(readSystemStatus, {
initialData: [],
async middleware(_, next) {
if (!restarting) {
await next();
@@ -76,51 +97,46 @@ const SystemStatus = () => {
const theme = useTheme();
const formatDurationSec = (duration_sec: number) => {
const days = Math.trunc((duration_sec * 1000) / 86400000);
const hours = Math.trunc((duration_sec * 1000) / 3600000) % 24;
const minutes = Math.trunc((duration_sec * 1000) / 60000) % 60;
const seconds = Math.trunc((duration_sec * 1000) / 1000) % 60;
// Memoize derived status values to avoid recalculation on every render
const busStatus = useMemo(() => {
if (!data) return 'EMS state unknown';
let formatted = '';
if (days) {
formatted += LL.NUM_DAYS({ num: days }) + ' ';
switch (data.bus_status) {
case busConnectionStatus.BUS_STATUS_CONNECTED:
return `EMS ${LL.CONNECTED(0)} (${formatDurationSec(data.bus_uptime, LL)})`;
case busConnectionStatus.BUS_STATUS_TX_ERRORS:
return 'EMS ' + LL.TX_ISSUES();
case busConnectionStatus.BUS_STATUS_OFFLINE:
return 'EMS ' + LL.DISCONNECTED();
default:
return 'EMS state unknown';
}
if (hours) {
formatted += LL.NUM_HOURS({ num: hours }) + ' ';
}
if (minutes) {
formatted += LL.NUM_MINUTES({ num: minutes }) + ' ';
}
formatted += LL.NUM_SECONDS({ num: seconds });
return formatted;
};
}, [data?.bus_status, data?.bus_uptime, LL]);
function formatNumber(num: number) {
return new Intl.NumberFormat().format(num);
}
// Memoize derived status values to avoid recalculation on every render
const systemStatus = useMemo(() => {
if (!data) return '??';
const busStatus = () => {
if (data) {
switch (data.bus_status) {
case busConnectionStatus.BUS_STATUS_CONNECTED:
return (
'EMS ' +
LL.CONNECTED(0) +
' (' +
formatDurationSec(data.bus_uptime) +
')'
);
case busConnectionStatus.BUS_STATUS_TX_ERRORS:
return 'EMS ' + LL.TX_ISSUES();
case busConnectionStatus.BUS_STATUS_OFFLINE:
return 'EMS ' + LL.DISCONNECTED();
}
switch (data.status) {
case SystemStatusCodes.SYSTEM_STATUS_PENDING_UPLOAD:
case SystemStatusCodes.SYSTEM_STATUS_UPLOADING:
return LL.WAIT_FIRMWARE();
case SystemStatusCodes.SYSTEM_STATUS_ERROR_UPLOAD:
return LL.ERROR();
case SystemStatusCodes.SYSTEM_STATUS_PENDING_RESTART:
case SystemStatusCodes.SYSTEM_STATUS_RESTART_REQUESTED:
return LL.RESTARTING_PRE();
case SystemStatusCodes.SYSTEM_STATUS_INVALID_GPIO:
return LL.GPIO_OF(LL.FAILED(0));
default:
// SystemStatusCodes.SYSTEM_STATUS_NORMAL
return 'OK';
}
return 'EMS state unknown';
};
}, [data?.status, LL]);
const busStatusHighlight = useMemo(() => {
if (!data) return theme.palette.warning.main;
const busStatusHighlight = () => {
switch (data.bus_status) {
case busConnectionStatus.BUS_STATUS_TX_ERRORS:
return theme.palette.warning.main;
@@ -131,27 +147,28 @@ const SystemStatus = () => {
default:
return theme.palette.warning.main;
}
};
}, [data?.bus_status, theme.palette]);
const ntpStatus = useMemo(() => {
if (!data) return LL.UNKNOWN();
const ntpStatus = () => {
switch (data.ntp_status) {
case NTPSyncStatus.NTP_DISABLED:
return LL.NOT_ENABLED();
case NTPSyncStatus.NTP_INACTIVE:
return LL.INACTIVE(0);
case NTPSyncStatus.NTP_ACTIVE:
return (
LL.ACTIVE() +
(data.ntp_time !== undefined
? ' (' + formatDateTime(data.ntp_time) + ')'
: '')
);
return data.ntp_time
? `${LL.ACTIVE()} (${formatDateTime(data.ntp_time)})`
: LL.ACTIVE();
default:
return LL.UNKNOWN();
}
};
}, [data?.ntp_status, data?.ntp_time, LL]);
const ntpStatusHighlight = useMemo(() => {
if (!data) return theme.palette.error.main;
const ntpStatusHighlight = () => {
switch (data.ntp_status) {
case NTPSyncStatus.NTP_DISABLED:
return theme.palette.info.main;
@@ -162,9 +179,11 @@ const SystemStatus = () => {
default:
return theme.palette.error.main;
}
};
}, [data?.ntp_status, theme.palette]);
const networkStatusHighlight = useMemo(() => {
if (!data) return theme.palette.warning.main;
const networkStatusHighlight = () => {
switch (data.network_status) {
case NetworkConnectionStatus.WIFI_STATUS_IDLE:
case NetworkConnectionStatus.WIFI_STATUS_DISCONNECTED:
@@ -179,9 +198,11 @@ const SystemStatus = () => {
default:
return theme.palette.warning.main;
}
};
}, [data?.network_status, theme.palette]);
const networkStatus = useMemo(() => {
if (!data) return LL.UNKNOWN();
const networkStatus = () => {
switch (data.network_status) {
case NetworkConnectionStatus.WIFI_STATUS_NO_SHIELD:
return LL.INACTIVE(1);
@@ -190,24 +211,27 @@ const SystemStatus = () => {
case NetworkConnectionStatus.WIFI_STATUS_NO_SSID_AVAIL:
return 'No SSID Available';
case NetworkConnectionStatus.WIFI_STATUS_CONNECTED:
return LL.CONNECTED(0) + ' (WiFi, ' + data.wifi_rssi + ' dBm)';
return `${LL.CONNECTED(0)} (WiFi, ${data.wifi_rssi} dBm)`;
case NetworkConnectionStatus.ETHERNET_STATUS_CONNECTED:
return LL.CONNECTED(0) + ' (Ethernet)';
return `${LL.CONNECTED(0)} (Ethernet)`;
case NetworkConnectionStatus.WIFI_STATUS_CONNECT_FAILED:
return LL.CONNECTED(1) + ' ' + LL.FAILED(0);
return `${LL.CONNECTED(1)} ${LL.FAILED(0)}`;
case NetworkConnectionStatus.WIFI_STATUS_CONNECTION_LOST:
return LL.CONNECTED(1) + ' ' + LL.LOST();
return `${LL.CONNECTED(1)} ${LL.LOST()}`;
case NetworkConnectionStatus.WIFI_STATUS_DISCONNECTED:
return LL.DISCONNECTED();
default:
return LL.UNKNOWN();
}
};
}, [data?.network_status, data?.wifi_rssi, LL]);
const activeHighlight = (value: boolean) =>
value ? theme.palette.success.main : theme.palette.info.main;
const activeHighlight = useCallback(
(value: boolean) =>
value ? theme.palette.success.main : theme.palette.info.main,
[theme.palette]
);
const doRestart = async () => {
const doRestart = useCallback(async () => {
setConfirmRestart(false);
setRestarting(true);
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
@@ -215,38 +239,83 @@ const SystemStatus = () => {
toast.error(error.message);
}
);
};
}, [sendAPI]);
const renderRestartDialog = () => (
<Dialog
sx={dialogStyle}
open={confirmRestart}
onClose={() => setConfirmRestart(false)}
>
<DialogTitle>{LL.RESTART()}</DialogTitle>
<DialogContent dividers>{LL.RESTART_CONFIRM()}</DialogContent>
<DialogActions>
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={() => setConfirmRestart(false)}
color="secondary"
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<PowerSettingsNewIcon />}
variant="outlined"
onClick={doRestart}
color="error"
>
{LL.RESTART()}
</Button>
</DialogActions>
</Dialog>
const handleCloseRestartDialog = useCallback(() => {
setConfirmRestart(false);
}, []);
const renderRestartDialog = useMemo(
() => (
<Dialog
sx={dialogStyle}
open={confirmRestart}
onClose={handleCloseRestartDialog}
>
<DialogTitle>{LL.RESTART()}</DialogTitle>
<DialogContent dividers>{LL.RESTART_CONFIRM()}</DialogContent>
<DialogActions>
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={handleCloseRestartDialog}
color="secondary"
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<PowerSettingsNewIcon />}
variant="outlined"
onClick={doRestart}
color="error"
>
{LL.RESTART()}
</Button>
</DialogActions>
</Dialog>
),
[confirmRestart, handleCloseRestartDialog, doRestart, LL]
);
const content = () => {
// Memoize formatted values
const firmwareVersion = useMemo(
() => `v${data?.emsesp_version || ''}`,
[data?.emsesp_version]
);
const uptimeText = useMemo(
() => (data ? formatDurationSec(data.uptime, LL) : ''),
[data?.uptime, LL]
);
const freeMemoryText = useMemo(
() => (data ? `${formatNumber(data.free_heap)} KB ${LL.FREE_MEMORY()}` : ''),
[data?.free_heap, LL]
);
const networkIcon = useMemo(
() =>
data?.network_status === NetworkConnectionStatus.WIFI_STATUS_CONNECTED
? WifiIcon
: RouterIcon,
[data?.network_status]
);
const mqttStatusText = useMemo(
() => (data?.mqtt_status ? LL.CONNECTED(0) : LL.INACTIVE(0)),
[data?.mqtt_status, LL]
);
const apStatusText = useMemo(
() => (data?.ap_status ? LL.ACTIVE() : LL.INACTIVE(0)),
[data?.ap_status, LL]
);
const handleRestartClick = useCallback(() => {
setConfirmRestart(true);
}, []);
const content = useMemo(() => {
if (!data || !LL) {
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
}
@@ -258,26 +327,26 @@ const SystemStatus = () => {
icon={BuildIcon}
bgcolor="#72caf9"
label="EMS-ESP Firmware"
text={'v' + data.emsesp_version}
text={firmwareVersion}
to="version"
/>
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: '#c5572c', color: 'white' }}>
<TimerIcon />
<MonitorHeartIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={LL.UPTIME()}
secondary={formatDurationSec(data.uptime)}
primary={LL.STATUS_OF(LL.SYSTEM(0))}
secondary={`${systemStatus} (${LL.UPTIME()}: ${uptimeText})`}
/>
{me.admin && (
<Button
startIcon={<PowerSettingsNewIcon />}
variant="outlined"
color="error"
onClick={() => setConfirmRestart(true)}
onClick={handleRestartClick}
>
{LL.RESTART()}
</Button>
@@ -289,29 +358,25 @@ const SystemStatus = () => {
icon={MemoryIcon}
bgcolor="#68374d"
label={LL.HARDWARE()}
text={formatNumber(data.free_heap) + ' KB' + ' ' + LL.FREE_MEMORY()}
text={freeMemoryText}
to="/status/hardwarestatus"
/>
<ListMenuItem
disabled={!me.admin}
icon={DirectionsBusIcon}
bgcolor={busStatusHighlight()}
bgcolor={busStatusHighlight}
label={LL.DATA_TRAFFIC()}
text={busStatus()}
text={busStatus}
to="/status/activity"
/>
<ListMenuItem
disabled={!me.admin}
icon={
data.network_status === NetworkConnectionStatus.WIFI_STATUS_CONNECTED
? WifiIcon
: RouterIcon
}
bgcolor={networkStatusHighlight()}
icon={networkIcon}
bgcolor={networkStatusHighlight}
label={LL.NETWORK(1)}
text={networkStatus()}
text={networkStatus}
to="/status/network"
/>
@@ -320,16 +385,16 @@ const SystemStatus = () => {
icon={DeviceHubIcon}
bgcolor={activeHighlight(data.mqtt_status)}
label="MQTT"
text={data.mqtt_status ? LL.CONNECTED(0) : LL.INACTIVE(0)}
text={mqttStatusText}
to="/status/mqtt"
/>
<ListMenuItem
disabled={!me.admin}
icon={AccessTimeIcon}
bgcolor={ntpStatusHighlight()}
bgcolor={ntpStatusHighlight}
label="NTP"
text={ntpStatus()}
text={ntpStatus}
to="/status/ntp"
/>
@@ -338,7 +403,7 @@ const SystemStatus = () => {
icon={SettingsInputAntennaIcon}
bgcolor={activeHighlight(data.ap_status)}
label={LL.ACCESS_POINT(0)}
text={data.ap_status ? LL.ACTIVE() : LL.INACTIVE(0)}
text={apStatusText}
to="/status/ap"
/>
@@ -352,14 +417,33 @@ const SystemStatus = () => {
/>
</List>
{renderRestartDialog()}
{renderRestartDialog}
</>
);
};
}, [
data,
LL,
firmwareVersion,
uptimeText,
freeMemoryText,
networkIcon,
mqttStatusText,
apStatusText,
busStatus,
busStatusHighlight,
networkStatusHighlight,
networkStatus,
ntpStatusHighlight,
ntpStatus,
activeHighlight,
me.admin,
handleRestartClick,
error,
loadData,
renderRestartDialog
]);
return (
<SectionContent>{restarting ? <SystemMonitor /> : content()}</SectionContent>
);
return restarting ? <SystemMonitor /> : <SectionContent>{content}</SectionContent>;
};
export default SystemStatus;

View File

@@ -1,4 +1,11 @@
import { useEffect, useRef, useState } from 'react';
import {
memo,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState
} from 'react';
import { toast } from 'react-toastify';
import DownloadIcon from '@mui/icons-material/GetApp';
@@ -47,11 +54,6 @@ const LogEntryLine = styled('span')(
})
);
const topOffset = () =>
document.getElementById('log-window')?.getBoundingClientRect().bottom || 0;
const leftOffset = () =>
document.getElementById('log-window')?.getBoundingClientRect().left || 0;
const levelLabel = (level: LogLevel) => {
switch (level) {
case LogLevel.ERROR:
@@ -71,6 +73,39 @@ const levelLabel = (level: LogLevel) => {
}
};
const paddedLevelLabel = (level: LogLevel, compact: boolean) => {
const label = levelLabel(level);
return compact ? ' ' + label[0] : label.padStart(8, '\xa0');
};
const paddedNameLabel = (name: string, compact: boolean) => {
const label = '[' + name + ']';
return compact ? label : label.padEnd(12, '\xa0');
};
const paddedIDLabel = (id: number, compact: boolean) => {
const label = id + ':';
return compact ? label : label.padEnd(7, '\xa0');
};
// Memoized log entry component to prevent unnecessary re-renders
const LogEntryItem = memo(
({ entry, compact }: { entry: LogEntry; compact: boolean }) => {
return (
<div style={{ font: '13px monospace', whiteSpace: 'nowrap' }}>
<span>{entry.t}</span>
<span>{paddedLevelLabel(entry.l, compact)}&nbsp;</span>
<span>{paddedIDLabel(entry.i, compact)} </span>
<span>{paddedNameLabel(entry.n, compact)} </span>
<LogEntryLine details={{ level: entry.l }}>{entry.m}</LogEntryLine>
</div>
);
},
(prevProps, nextProps) =>
prevProps.entry.i === nextProps.entry.i &&
prevProps.compact === nextProps.compact
);
const SystemLog = () => {
const { LL } = useI18nContext();
@@ -102,54 +137,85 @@ const SystemLog = () => {
const [readOpen, setReadOpen] = useState(false);
const [logEntries, setLogEntries] = useState<LogEntry[]>([]);
const [autoscroll, setAutoscroll] = useState(true);
const [lastId, setLastId] = useState<number>(-1);
const [boxPosition, setBoxPosition] = useState({ top: 0, left: 0 });
const ALPHA_NUMERIC_DASH_REGEX = /^[a-fA-F0-9 ]+$/;
const updateFormValue = updateValueDirty(
origData,
origData as unknown as Record<string, unknown>,
dirtyFlags,
setDirtyFlags,
updateDataValue as (value: unknown) => void
);
// Calculate box position after layout
useLayoutEffect(() => {
const logWindow = document.getElementById('log-window');
if (!logWindow) {
return;
}
const updatePosition = () => {
const windowElement = document.getElementById('log-window');
if (!windowElement) {
return;
}
const rect = windowElement.getBoundingClientRect();
setBoxPosition({ top: rect.bottom, left: rect.left });
};
updatePosition();
// Debounce resize events with requestAnimationFrame
let rafId: number;
const handleResize = () => {
cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(updatePosition);
};
// Update position on window resize
window.addEventListener('resize', handleResize);
const resizeObserver = new ResizeObserver(handleResize);
resizeObserver.observe(logWindow);
return () => {
window.removeEventListener('resize', handleResize);
resizeObserver.disconnect();
cancelAnimationFrame(rafId);
};
}, [data]); // Recalculate when data changes (in case layout shifts)
// Memoize message handler to avoid recreating on every render
const handleLogMessage = useCallback((message: { data: string }) => {
const rawData = message.data;
const logentry = JSON.parse(rawData) as LogEntry;
setLogEntries((log) => {
// Skip if this is a duplicate entry (check last entry id)
if (log.length > 0) {
const lastEntry = log[log.length - 1];
if (lastEntry && logentry.i <= lastEntry.i) {
return log;
}
}
const newLog = [...log, logentry];
return newLog;
});
}, []);
useSSE(fetchLogES, {
immediate: true,
interceptByGlobalResponded: false
})
.onMessage((message: { data: string }) => {
const rawData = message.data;
const logentry = JSON.parse(rawData) as LogEntry;
if (lastId < logentry.i) {
setLogEntries((log) => [...log, logentry]);
setLastId(logentry.i);
}
})
.onMessage(handleLogMessage)
.onError(() => {
toast.error('No connection to Log service');
});
const paddedLevelLabel = (level: LogLevel) => {
const label = levelLabel(level);
return data?.compact ? ' ' + label[0] : label.padStart(8, '\xa0');
};
const onDownload = useCallback(() => {
const result = logEntries
.map((i) => `${i.t} ${levelLabel(i.l)} ${i.i}: [${i.n}] ${i.m}`)
.join('\n');
const paddedNameLabel = (name: string) => {
const label = '[' + name + ']';
return data?.compact ? label : label.padEnd(12, '\xa0');
};
const paddedIDLabel = (id: number) => {
const label = id + ':';
return data?.compact ? label : label.padEnd(7, '\xa0');
};
const onDownload = () => {
let result = '';
for (const i of logEntries) {
result +=
i.t + ' ' + levelLabel(i.l) + ' ' + i.i + ': [' + i.n + '] ' + i.m + '\n';
}
const a = document.createElement('a');
a.setAttribute(
'href',
@@ -159,24 +225,28 @@ const SystemLog = () => {
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
};
}, [logEntries]);
const saveSettings = async () => {
const saveSettings = useCallback(async () => {
await saveData();
};
}, [saveData]);
// handle scrolling
// handle scrolling - optimized to only scroll when needed
const ref = useRef<HTMLDivElement>(null);
const logWindowRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (logEntries.length && autoscroll) {
ref.current?.scrollIntoView({
behavior: 'smooth',
block: 'end'
});
const container = logWindowRef.current;
if (container) {
requestAnimationFrame(() => {
container.scrollTop = container.scrollHeight;
});
}
}
}, [logEntries.length]);
}, [logEntries.length, autoscroll]);
const sendReadCommand = () => {
const sendReadCommand = useCallback(() => {
if (readValue === '') {
setReadOpen(!readOpen);
return;
@@ -187,7 +257,7 @@ const SystemLog = () => {
setReadOpen(false);
setReadValue('');
}
};
}, [readValue, readOpen, send]);
const content = () => {
if (!data) {
@@ -196,7 +266,7 @@ const SystemLog = () => {
return (
<>
<Grid container spacing={2} alignItems="center">
<Grid container spacing={2} sx={{ alignItems: 'center' }}>
<Grid>
<TextField
name="level"
@@ -232,6 +302,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>
)}
@@ -279,6 +351,7 @@ const SystemLog = () => {
>
<IconButton
disableRipple
aria-label={LL.CANCEL()}
onClick={() => {
setReadOpen(false);
setReadValue('');
@@ -304,7 +377,7 @@ const SystemLog = () => {
) : (
<>
{data.developer_mode && (
<IconButton onClick={sendReadCommand}>
<IconButton onClick={sendReadCommand} aria-label={LL.EXECUTE()}>
<PlayArrowIcon color="primary" />
</IconButton>
)}
@@ -326,27 +399,20 @@ const SystemLog = () => {
</Grid>
<Box
ref={logWindowRef}
sx={{
backgroundColor: 'black',
overflowY: 'scroll',
position: 'absolute',
right: 18,
bottom: 18,
left: () => leftOffset(),
top: () => topOffset(),
left: boxPosition.left,
top: boxPosition.top,
p: 1
}}
>
{logEntries.map((e) => (
<div key={e.i} style={{ font: '14px monospace', whiteSpace: 'nowrap' }}>
<span>{e.t}</span>
<span>{paddedLevelLabel(e.l)}&nbsp;</span>
<span>{paddedIDLabel(e.i)} </span>
<span>{paddedNameLabel(e.n)} </span>
<LogEntryLine details={{ level: e.l }} key={e.i}>
{e.m}
</LogEntryLine>
</div>
<LogEntryItem key={e.i} entry={e} compact={data.compact} />
))}
<div ref={ref} />

View File

@@ -1,12 +1,11 @@
import { useState } from 'react';
import { useCallback, useMemo, useRef, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel';
import { Box, Button, Dialog, DialogContent, Typography } from '@mui/material';
import { Box, Button, Typography } from '@mui/material';
import { callAction } from 'api/app';
import { readSystemStatus } from 'api/system';
import { dialogStyle } from 'CustomTheme';
import { useRequest } from 'alova/client';
import MessageBox from 'components/MessageBox';
import { useI18nContext } from 'i18n/i18n-react';
@@ -17,11 +16,9 @@ import { LinearProgressWithLabel } from '../../components/upload/LinearProgressW
const SystemMonitor = () => {
const [errorMessage, setErrorMessage] = useState<string>();
const hasInitialized = useRef(false);
const { LL } = useI18nContext();
let count = 0;
const { send: setSystemStatus } = useRequest(
(status: string) => callAction({ action: 'systemStatus', param: status }),
{
@@ -32,10 +29,12 @@ const SystemMonitor = () => {
const { data, send } = useRequest(readSystemStatus, {
force: true,
async middleware(_, next) {
if (count++ >= 1) {
// skip first request (1 second) to allow AsyncWS to send its response
await next();
// Skip first request to allow AsyncWS to send its response
if (!hasInitialized.current) {
hasInitialized.current = true;
return; // Don't await next() on first call
}
await next();
}
})
.onSuccess((event) => {
@@ -58,40 +57,85 @@ const SystemMonitor = () => {
void send();
}, 1000); // check every 1 second
const onCancel = async () => {
const { statusMessage, isUploading, progressValue } = useMemo(() => {
const status = data?.status;
const message =
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;
return {
statusMessage: message,
isUploading: uploading,
progressValue: progress
};
}, [data?.status, LL]);
const onCancel = useCallback(async () => {
setErrorMessage(undefined);
await setSystemStatus(
SystemStatusCodes.SYSTEM_STATUS_NORMAL as unknown as string
);
await setSystemStatus(String(SystemStatusCodes.SYSTEM_STATUS_NORMAL));
document.location.href = '/';
};
}, [setSystemStatus]);
return (
<Dialog fullWidth={true} sx={dialogStyle} open={true}>
<DialogContent dividers>
<Box m={0} py={0} display="flex" alignItems="center" flexDirection="column">
<Box
sx={{
position: 'fixed',
top: 0,
left: 0,
width: '100vw',
height: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
// backdropFilter: 'blur(8px)'
}}
>
<Box
sx={{
width: '30%',
minWidth: '300px',
maxWidth: '500px',
backgroundColor: '#393939',
border: 2,
borderColor: '#565656',
borderRadius: '8px',
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.3)',
p: 3
}}
>
<Box 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"
>
{data?.status >= SystemStatusCodes.SYSTEM_STATUS_UPLOADING
? LL.WAIT_FIRMWARE()
: data?.status === SystemStatusCodes.SYSTEM_STATUS_PENDING_RESTART
? LL.APPLICATION_RESTARTING()
: data?.status === SystemStatusCodes.SYSTEM_STATUS_NORMAL
? LL.RESTARTING_PRE()
: data?.status === SystemStatusCodes.SYSTEM_STATUS_ERROR_UPLOAD
? 'Upload Failed'
: LL.RESTARTING_POST()}
{statusMessage}
</Typography>
{errorMessage ? (
<MessageBox my={2} level="error" message={errorMessage}>
<MessageBox level="error" message={errorMessage}>
<Button
size="small"
sx={{ ml: 2 }}
size="small"
startIcon={<CancelIcon />}
variant="contained"
color="error"
@@ -102,23 +146,22 @@ 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()}&hellip;
</Typography>
{data && data.status >= SystemStatusCodes.SYSTEM_STATUS_UPLOADING && (
<Box width="100%" pl={2} pr={2} py={2}>
<LinearProgressWithLabel
value={Math.round(
data?.status - SystemStatusCodes.SYSTEM_STATUS_UPLOADING
)}
/>
{isUploading && (
<Box sx={{ width: '100%', pl: 2, pr: 2, py: 2 }}>
<LinearProgressWithLabel value={progressValue} />
</Box>
)}
</>
)}
</Box>
</DialogContent>
</Dialog>
</Box>
</Box>
);
};

View File

@@ -1,4 +1,13 @@
import { memo, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import {
memo,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState
} from 'react';
import { Link } from 'react-router';
import { toast } from 'react-toastify';
import CancelIcon from '@mui/icons-material/Cancel';
@@ -10,15 +19,12 @@ import WarningIcon from '@mui/icons-material/Warning';
import {
Box,
Button,
Checkbox,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
FormControlLabel,
Grid,
IconButton,
Link,
Table,
TableBody,
TableCell,
@@ -54,6 +60,13 @@ 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;
@@ -61,6 +74,9 @@ interface VersionData {
flash_chip_size: number;
psram: boolean;
build_flags?: string;
partition: string;
partitions: PartitionData[];
developer_mode: boolean;
}
interface UpgradeCheckData {
@@ -80,6 +96,10 @@ const VersionInfoDialog = memo(
showVersionInfo,
latestVersion,
latestDevVersion,
partitionVersion,
partition,
currentPartition,
size,
locale,
LL,
onClose
@@ -87,6 +107,10 @@ const VersionInfoDialog = memo(
showVersionInfo: number;
latestVersion?: VersionInfo;
latestDevVersion?: VersionInfo;
partitionVersion?: VersionInfo | undefined;
partition: string;
currentPartition: string;
size: number;
locale: string;
LL: TranslationFunctions;
onClose: () => void;
@@ -94,8 +118,19 @@ const VersionInfoDialog = memo(
if (showVersionInfo === 0) return null;
const isStable = showVersionInfo === 1;
const version = isStable ? latestVersion : latestDevVersion;
const relNotesUrl = isStable ? STABLE_RELNOTES_URL : DEV_RELNOTES_URL;
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}>
@@ -112,14 +147,17 @@ const VersionInfoDialog = memo(
borderBottom: 'none',
pr: 1,
py: 0.5,
fontSize: 13,
width: 90
fontSize: 13
}}
>
{LL.TYPE(0)}
{LL.VERSION()}
</TableCell>
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
{isStable ? LL.STABLE() : LL.DEVELOPMENT()}
{isPartition
? typeof version === 'string'
? version
: version?.name
: version?.name}
</TableCell>
</TableRow>
<TableRow sx={{ height: 24, borderBottom: 'none' }}>
@@ -131,15 +169,61 @@ const VersionInfoDialog = memo(
borderBottom: 'none',
pr: 1,
py: 0.5,
fontSize: 13
fontSize: 13,
width: 140
}}
>
{LL.VERSION()}
{isPartition ? LL.TYPE(0) : LL.RELEASE_TYPE()}
</TableCell>
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
{version?.name}
{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
@@ -153,7 +237,7 @@ const VersionInfoDialog = memo(
fontSize: 13
}}
>
Build Date
{isPartition ? 'Install Date' : 'Build Date'}
</TableCell>
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
{prettyDateTime(locale, new Date(version.published_at))}
@@ -164,15 +248,17 @@ const VersionInfoDialog = memo(
</Table>
</DialogContent>
<DialogActions>
<Button
variant="outlined"
component="a"
href={relNotesUrl}
target="_blank"
color="primary"
>
Changelog
</Button>
{!isPartition && (
<Button
variant="outlined"
component="a"
href={relNotesUrl}
target="_blank"
color="primary"
>
Changelog
</Button>
)}
<Button variant="outlined" onClick={onClose} color="secondary">
{LL.CLOSE()}
</Button>
@@ -188,6 +274,7 @@ const InstallDialog = memo(
fetchDevVersion,
latestVersion,
latestDevVersion,
upgradeImportantMessageType,
downloadOnly,
platform,
LL,
@@ -198,6 +285,7 @@ const InstallDialog = memo(
fetchDevVersion: boolean;
latestVersion?: VersionInfo;
latestDevVersion?: VersionInfo;
upgradeImportantMessageType: number;
downloadOnly: boolean;
platform: string;
LL: TranslationFunctions;
@@ -218,15 +306,27 @@ const InstallDialog = memo(
return (
<Dialog sx={dialogStyle} open={openInstallDialog} onClose={onClose}>
<DialogTitle>
{`${LL.UPDATE()} ${fetchDevVersion ? LL.DEVELOPMENT() : LL.STABLE()} Firmware`}
{`${LL.INSTALL()} ${fetchDevVersion ? LL.DEVELOPMENT() : LL.STABLE()} Firmware`}
</DialogTitle>
<DialogContent dividers>
<Typography mb={2}>
<Typography sx={{ mb: 2 }}>
{LL.INSTALL_VERSION(
downloadOnly ? LL.DOWNLOAD(1) : LL.INSTALL(),
fetchDevVersion ? latestDevVersion?.name : latestVersion?.name
)}
</Typography>
{upgradeImportantMessageType === 1 && LL.UPGRADE_IMPORTANT_MESSAGES_1()}
{upgradeImportantMessageType === 2 && LL.UPGRADE_IMPORTANT_MESSAGES_2()}
<Typography sx={{ mt: 2 }}>
<Link
target="_blank"
to="https://docs.emsesp.org/FAQ#upgrading-the-firmware"
style={{ color: 'lightblue' }}
>
{LL.ONLINE_HELP()}
</Link>
</Typography>
</DialogContent>
<DialogActions>
<Button
@@ -243,7 +343,12 @@ const InstallDialog = memo(
onClick={onClose}
color="primary"
>
<Link underline="none" target="_blank" href={binURL} color="primary">
<Link
to={binURL}
target="_blank"
rel="noreferrer"
style={{ color: 'lightblue', textDecoration: 'none' }}
>
{LL.DOWNLOAD(0)}
</Link>
</Button>
@@ -263,6 +368,56 @@ const InstallDialog = memo(
}
);
const InstallPartitionDialog = memo(
({
openInstallPartitionDialog,
version,
partition,
LL,
onClose,
onInstall
}: {
openInstallPartitionDialog: boolean;
version: string;
partition: string;
LL: TranslationFunctions;
onClose: () => void;
onInstall: (partition: string) => void;
}) => {
return (
<Dialog sx={dialogStyle} open={openInstallPartitionDialog} onClose={onClose}>
<DialogTitle>
{LL.INSTALL()} {LL.STORED_VERSIONS()}
</DialogTitle>
<DialogContent dividers>
<Typography 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 ? '+' : ''}`;
@@ -275,6 +430,14 @@ const Version = () => {
// 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);
@@ -282,9 +445,9 @@ const Version = () => {
useState<boolean>(false);
const [internetLive, setInternetLive] = useState<boolean>(false);
const [downloadOnly, setDownloadOnly] = useState<boolean>(false);
const [showVersionInfo, setShowVersionInfo] = useState<number>(0);
const [showVersionInfo, setShowVersionInfo] = useState<number>(0); // 1 = stable, 2 = dev, 3 = partition
const [firmwareSize, setFirmwareSize] = useState<number>(0);
// API calls with optimized error handling
const { send: sendCheckUpgrade } = useRequest(
(versions: string) => callAction({ action: 'checkUpgrade', param: versions }),
{ immediate: false }
@@ -294,6 +457,13 @@ const Version = () => {
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,
@@ -318,20 +488,60 @@ const Version = () => {
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'));
});
// Memoized values
const platform = useMemo(() => (data ? getPlatform(data) : ''), [data]);
const isDev = useMemo(
() => data?.emsesp_version.includes('dev') ?? false,
[data?.emsesp_version]
// 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 () => {
setRestarting(true);
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
(error: Error) => {
toast.error(error.message);
}
);
setRestarting(true);
}, [sendAPI]);
const installFirmwareURL = useCallback(
@@ -339,27 +549,60 @@ const Version = () => {
await sendUploadURL(url).catch((error: Error) => {
toast.error(error.message);
});
setRestarting(true);
await doRestart();
},
[sendUploadURL]
[sendUploadURL, doRestart]
);
const showFirmwareDialog = useCallback((useDevVersion: boolean) => {
setFetchDevVersion(useDevVersion);
setOpenInstallDialog(true);
}, []);
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);
void checkUpgradeImportantMessages(
useDevVersion ? latestDevVersion?.name : latestVersion?.name
);
setOpenInstallDialog(true);
},
[latestDevVersion, latestVersion, fetchDevVersion]
);
const closeInstallDialog = useCallback(() => {
setOpenInstallDialog(false);
}, []);
const handleVersionInfoClose = useCallback(() => {
setShowVersionInfo(0);
const closeInstallPartitionDialog = useCallback(() => {
setOpenInstallPartitionDialog(false);
}, []);
// Effect for checking upgrades
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) {
if (latestVersion && latestDevVersion && !upgradeCheckedRef.current) {
upgradeCheckedRef.current = true;
const versions = `${latestDevVersion.name},${latestVersion.name}`;
sendCheckUpgrade(versions)
.catch((error: Error) => {
@@ -399,7 +642,7 @@ const Version = () => {
{LL.LATEST_VERSION(usingDevVersion ? LL.DEVELOPMENT() : LL.STABLE())}
</span>
<Button
sx={{ ml: 2 }}
sx={{ ml: 1 }}
variant="outlined"
size="small"
onClick={() => showFirmwareDialog(showingDev)}
@@ -414,7 +657,7 @@ const Version = () => {
return (
<Button
sx={{ ml: 2 }}
sx={{ ml: 1 }}
variant="outlined"
color={choice === LL.UPDATE_AVAILABLE() ? 'success' : 'warning'}
size="small"
@@ -441,15 +684,14 @@ const Version = () => {
return (
<>
<Box p={2} border="1px solid grey" borderRadius={2}>
<Typography mb={2} variant="h6" color="primary">
<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"
rowSpacing={1}
sx={{
justifyContent: 'flex-start',
alignItems: 'baseline'
@@ -466,6 +708,12 @@ const Version = () => {
&nbsp; &#40;{data.build_flags}&#41;
</Typography>
)}
<IconButton
onClick={() => setPartitionVersionInfo(data.partition)}
aria-label={LL.FIRMWARE_VERSION_INFO()}
>
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
</IconButton>
</Typography>
</Grid>
@@ -498,57 +746,11 @@ const Version = () => {
</Typography>
</Typography>
</Grid>
<Grid size={{ xs: 4, md: 2 }}>
<Typography color="secondary">{LL.RELEASE_TYPE()}</Typography>
</Grid>
<Grid size={{ xs: 8, md: 10 }}>
<FormControlLabel
disabled={!isDev}
control={
<Checkbox
sx={{
'&.Mui-checked': {
color: 'lightblue'
}
}}
/>
}
slotProps={{
typography: {
color: 'grey'
}
}}
checked={!isDev}
label={LL.STABLE()}
sx={{ '& .MuiSvgIcon-root': { fontSize: 16 } }}
/>
<FormControlLabel
disabled={isDev}
control={
<Checkbox
sx={{
'&.Mui-checked': {
color: 'lightblue'
}
}}
/>
}
slotProps={{
typography: {
color: 'grey'
}
}}
checked={isDev}
label={LL.DEVELOPMENT()}
sx={{ '& .MuiSvgIcon-root': { fontSize: 16 } }}
/>
</Grid>
</Grid>
{internetLive ? (
<>
<Typography mt={2} mb={2} variant="h6" color="primary">
<Typography sx={{ mt: 4, mb: 1 }} variant="h6" color="primary">
{LL.AVAILABLE_VERSION()}
</Typography>
@@ -561,13 +763,57 @@ const Version = () => {
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?.name}
<IconButton onClick={() => setShowVersionInfo(1)}>
<IconButton
onClick={() => setShowVersionInfo(1)}
aria-label={LL.FIRMWARE_VERSION_INFO()}
>
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
</IconButton>
{showButtons(false)}
@@ -580,7 +826,10 @@ const Version = () => {
<Grid size={{ xs: 8, md: 10 }}>
<Typography>
{latestDevVersion?.name}
<IconButton onClick={() => setShowVersionInfo(2)}>
<IconButton
onClick={() => setShowVersionInfo(2)}
aria-label={LL.FIRMWARE_VERSION_INFO()}
>
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
</IconButton>
{showButtons(true)}
@@ -589,7 +838,7 @@ const Version = () => {
</Grid>
</>
) : (
<Typography mt={2} color="warning">
<Typography sx={{ mt: 2 }} color="warning">
<WarningIcon color="warning" sx={{ verticalAlign: 'middle', mr: 2 }} />
{LL.INTERNET_CONNECTION_REQUIRED()}
</Typography>
@@ -600,7 +849,11 @@ const Version = () => {
showVersionInfo={showVersionInfo}
latestVersion={latestVersion}
latestDevVersion={latestDevVersion}
partitionVersion={partitionVersion}
locale={locale}
partition={partition}
currentPartition={data?.partition ?? ''}
size={firmwareSize}
LL={LL}
onClose={handleVersionInfoClose}
/>
@@ -609,16 +862,25 @@ const Version = () => {
fetchDevVersion={fetchDevVersion}
latestVersion={latestVersion}
latestDevVersion={latestDevVersion}
upgradeImportantMessageType={upgradeImportantMessageType}
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} />
<SingleUpload doRestart={doRestart} />
</>
)}
</Box>
@@ -630,7 +892,6 @@ const Version = () => {
loadData,
LL,
platform,
isDev,
internetLive,
latestVersion,
latestDevVersion,
@@ -644,10 +905,18 @@ const Version = () => {
handleVersionInfoClose,
closeInstallDialog,
installFirmwareURL,
doRestart
doRestart,
otherPartitions,
setPartitionVersionInfo,
showPartitionDialog,
partitionVersion,
partition,
firmwareSize,
closeInstallPartitionDialog,
installPartitionFirmware
]);
return <SectionContent>{restarting ? <SystemMonitor /> : content}</SectionContent>;
return restarting ? <SystemMonitor /> : <SectionContent>{content}</SectionContent>;
};
export default memo(Version);

View File

@@ -1,9 +1,9 @@
import { memo } from 'react';
import { type FC, type PropsWithChildren, memo } from 'react';
import { Box } from '@mui/material';
import type { BoxProps } from '@mui/material';
const ButtonRow = memo<BoxProps>(({ children, ...rest }) => (
const ButtonRow: FC<PropsWithChildren<BoxProps>> = memo(({ children, ...rest }) => (
<Box
sx={{
'& button, & a, & .MuiCard-root': {
@@ -19,6 +19,4 @@ const ButtonRow = memo<BoxProps>(({ children, ...rest }) => (
</Box>
));
ButtonRow.displayName = 'ButtonRow';
export default ButtonRow;

View File

@@ -1,38 +1,35 @@
import type { FC } from 'react';
import { type FC, type PropsWithChildren, memo, useMemo } from 'react';
import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined';
import ErrorIcon from '@mui/icons-material/Error';
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
import ReportProblemOutlinedIcon from '@mui/icons-material/ReportProblemOutlined';
import { Box, Typography, useTheme } from '@mui/material';
import type { BoxProps, SvgIconProps, Theme } from '@mui/material';
import type { BoxProps, SvgIconProps } from '@mui/material';
type MessageBoxLevel = 'warning' | 'success' | 'info' | 'error';
export interface MessageBoxProps extends BoxProps {
level: MessageBoxLevel;
message?: string;
children?: React.ReactNode;
}
const LEVEL_ICONS: {
[type in MessageBoxLevel]: React.ComponentType<SvgIconProps>;
} = {
const LEVEL_ICONS: Record<MessageBoxLevel, React.ComponentType<SvgIconProps>> = {
success: CheckCircleOutlineOutlinedIcon,
info: InfoOutlinedIcon,
warning: ReportProblemOutlinedIcon,
error: ErrorIcon
};
const LEVEL_BACKGROUNDS: {
[type in MessageBoxLevel]: (theme: Theme) => string;
} = {
success: (theme: Theme) => theme.palette.success.dark,
info: (theme: Theme) => theme.palette.info.main,
warning: (theme: Theme) => theme.palette.warning.dark,
error: (theme: Theme) => theme.palette.error.dark
const LEVEL_PALETTE_PATHS: Record<MessageBoxLevel, string> = {
success: 'success.dark',
info: 'info.main',
warning: 'warning.dark',
error: 'error.dark'
};
const MessageBox: FC<MessageBoxProps> = ({
const MessageBox: FC<PropsWithChildren<MessageBoxProps>> = ({
level,
message,
sx,
@@ -40,25 +37,42 @@ const MessageBox: FC<MessageBoxProps> = ({
...rest
}) => {
const theme = useTheme();
const Icon = LEVEL_ICONS[level];
const backgroundColor = LEVEL_BACKGROUNDS[level](theme);
const color = 'white';
const { Icon, backgroundColor } = useMemo(() => {
const Icon = LEVEL_ICONS[level];
const palettePath = LEVEL_PALETTE_PATHS[level];
const [key, shade] = palettePath.split('.') as [
keyof typeof theme.palette,
string
];
const paletteKey = theme.palette[key] as unknown as Record<string, string>;
const backgroundColor = paletteKey[shade];
return { Icon, backgroundColor };
}, [level, theme]);
return (
<Box
p={2}
display="flex"
alignItems="center"
borderRadius={1}
sx={{ backgroundColor, color, ...sx }}
{...rest}
sx={{
display: 'flex',
alignItems: 'center',
borderRadius: 1,
backgroundColor,
color: 'white',
p: 2,
...sx
}}
>
<Icon />
<Typography sx={{ ml: 2 }} variant="body1">
{message ?? ''}
</Typography>
{children}
{(message || children) && (
<Typography sx={{ ml: 2 }} variant="body1">
{message}
{children}
</Typography>
)}
</Box>
);
};
export default MessageBox;
export default memo(MessageBox);

View File

@@ -1,6 +1,8 @@
import { memo } from 'react';
import type { FC } from 'react';
import { Paper } from '@mui/material';
import type { SxProps, Theme } from '@mui/material/styles';
import type { RequiredChildrenProps } from 'utils';
@@ -8,16 +10,19 @@ interface SectionContentProps extends RequiredChildrenProps {
id?: string;
}
const SectionContent: FC<SectionContentProps> = (props) => {
const { children, id } = props;
return (
<Paper
id={id}
sx={{ p: 1.5, m: 1.5, borderRadius: 3, border: '1px solid rgb(65, 65, 65)' }}
>
{children}
</Paper>
);
// Extract styles to avoid recreation on every render
const paperStyles: SxProps<Theme> = {
p: 1.5,
m: 1.5,
borderRadius: 3,
border: '1px solid rgb(65, 65, 65)'
};
export default SectionContent;
const SectionContent: FC<SectionContentProps> = ({ children, id }) => (
<Paper id={id} sx={paperStyles}>
{children}
</Paper>
);
// Memoize to prevent unnecessary re-renders
export default memo(SectionContent);

View File

@@ -1,4 +1,4 @@
// Optimized exports - use direct exports to reduce bundle size
// use direct exports to reduce bundle size
export { default as SectionContent } from './SectionContent';
export { default as ButtonRow } from './ButtonRow';
export { default as MessageBox } from './MessageBox';

View File

@@ -1,3 +1,4 @@
import { memo } from 'react';
import type { FC } from 'react';
import { FormControlLabel } from '@mui/material';
@@ -9,4 +10,4 @@ const BlockFormControlLabel: FC<FormControlLabelProps> = (props) => (
</div>
);
export default BlockFormControlLabel;
export default memo(BlockFormControlLabel);

View File

@@ -1,4 +1,6 @@
import { type ChangeEventHandler, useContext } from 'react';
import { memo, useCallback, useContext, useMemo } from 'react';
import type { ChangeEventHandler } from 'react';
import type { CSSProperties } from 'react';
import { MenuItem, TextField } from '@mui/material';
@@ -17,73 +19,66 @@ import { I18nContext } from 'i18n/i18n-react';
import type { Locales } from 'i18n/i18n-types';
import { loadLocaleAsync } from 'i18n/i18n-util.async';
const LanguageSelector = () => {
const { setLocale, locale } = useContext(I18nContext);
const flagStyle: CSSProperties = { width: 16, verticalAlign: 'middle' };
const onLocaleSelected: ChangeEventHandler<HTMLInputElement> = async ({
target
}) => {
const loc = target.value as Locales;
localStorage.setItem('lang', loc);
await loadLocaleAsync(loc);
setLocale(loc);
};
interface LanguageOption {
key: Locales;
flag: string;
label: string;
}
const LANGUAGE_OPTIONS: LanguageOption[] = [
{ key: 'cz', flag: CZflag, label: 'CZ' },
{ key: 'de', flag: DEflag, label: 'DE' },
{ key: 'en', flag: GBflag, label: 'EN' },
{ key: 'fr', flag: FRflag, label: 'FR' },
{ key: 'it', flag: ITflag, label: 'IT' },
{ key: 'nl', flag: NLflag, label: 'NL' },
{ key: 'no', flag: NOflag, label: 'NO' },
{ key: 'pl', flag: PLflag, label: 'PL' },
{ key: 'sk', flag: SKflag, label: 'SK' },
{ key: 'sv', flag: SVflag, label: 'SV' },
{ key: 'tr', flag: TRflag, label: 'TR' }
];
const LanguageSelector = () => {
const { setLocale, locale, LL } = useContext(I18nContext);
const onLocaleSelected: ChangeEventHandler<HTMLInputElement> = useCallback(
async ({ target }) => {
const loc = target.value as Locales;
localStorage.setItem('lang', loc);
await loadLocaleAsync(loc);
setLocale(loc);
},
[setLocale]
);
// Memoize menu items to prevent recreation on every render
const menuItems = useMemo(
() =>
LANGUAGE_OPTIONS.map(({ key, flag, label }) => (
<MenuItem key={key} value={key}>
<img src={flag} style={flagStyle} alt={label} />
&nbsp;{label}
</MenuItem>
)),
[]
);
return (
<TextField
name="locale"
variant="outlined"
aria-label={LL.LANGUAGE()}
value={locale}
onChange={onLocaleSelected}
size="small"
select
>
<MenuItem key="cz" value="cz">
<img src={CZflag} style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;CZ
</MenuItem>
<MenuItem key="de" value="de">
<img src={DEflag} style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;DE
</MenuItem>
<MenuItem key="en" value="en">
<img src={GBflag} style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;EN
</MenuItem>
<MenuItem key="fr" value="fr">
<img src={FRflag} style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;FR
</MenuItem>
<MenuItem key="it" value="it">
<img src={ITflag} style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;IT
</MenuItem>
<MenuItem key="nl" value="nl">
<img src={NLflag} style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;NL
</MenuItem>
<MenuItem key="no" value="no">
<img src={NOflag} style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;NO
</MenuItem>
<MenuItem key="pl" value="pl">
<img src={PLflag} style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;PL
</MenuItem>
<MenuItem key="sk" value="sk">
<img src={SKflag} style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;SK
</MenuItem>
<MenuItem key="sv" value="sv">
<img src={SVflag} style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;SV
</MenuItem>
<MenuItem key="tr" value="tr">
<img src={TRflag} style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;TR
</MenuItem>
{menuItems}
</TextField>
);
};
export default LanguageSelector;
export default memo(LanguageSelector);

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { memo, useCallback, useState } from 'react';
import type { FC } from 'react';
import VisibilityIcon from '@mui/icons-material/Visibility';
@@ -13,6 +13,10 @@ type ValidatedPasswordFieldProps = Omit<ValidatedTextFieldProps, 'type'>;
const ValidatedPasswordField: FC<ValidatedPasswordFieldProps> = ({ ...props }) => {
const [showPassword, setShowPassword] = useState<boolean>(false);
const togglePasswordVisibility = useCallback(() => {
setShowPassword((prev) => !prev);
}, []);
return (
<ValidatedTextField
{...props}
@@ -21,7 +25,11 @@ const ValidatedPasswordField: FC<ValidatedPasswordFieldProps> = ({ ...props }) =
input: {
endAdornment: (
<InputAdornment position="end">
<IconButton onClick={() => setShowPassword(!showPassword)} edge="end">
<IconButton
onClick={togglePasswordVisibility}
edge="end"
aria-label="Password visibility"
>
{showPassword ? <VisibilityIcon /> : <VisibilityOffIcon />}
</IconButton>
</InputAdornment>
@@ -32,4 +40,4 @@ const ValidatedPasswordField: FC<ValidatedPasswordFieldProps> = ({ ...props }) =
);
};
export default ValidatedPasswordField;
export default memo(ValidatedPasswordField);

View File

@@ -1,3 +1,4 @@
import { memo } from 'react';
import type { FC } from 'react';
import { FormHelperText, TextField } from '@mui/material';
@@ -14,18 +15,42 @@ export type ValidatedTextFieldProps = ValidatedFieldProps & TextFieldProps;
const ValidatedTextField: FC<ValidatedTextFieldProps> = ({
fieldErrors,
sx,
...rest
}) => {
const errors = fieldErrors?.[rest.name];
return (
<>
<TextField error={!!errors} {...rest} />
<TextField
error={!!errors}
{...rest}
aria-label="Error"
sx={{
'& .MuiInputBase-input.Mui-disabled': {
WebkitTextFillColor: 'grey'
},
...(sx || {})
}}
{...(rest.disabled && {
slotProps: {
select: {
IconComponent: () => null
},
inputLabel: {
style: { color: 'grey' }
}
}
})}
color={rest.disabled ? 'secondary' : 'primary'}
/>
{errors?.map((e) => (
<FormHelperText key={e.message}>{e.message}</FormHelperText>
<FormHelperText key={e.message} sx={{ color: 'rgb(250, 95, 84)' }}>
{e.message}
</FormHelperText>
))}
</>
);
};
export default ValidatedTextField;
export default memo(ValidatedTextField);

View File

@@ -13,7 +13,7 @@ import { LayoutContext } from './context';
export const DRAWER_WIDTH = 210;
const Layout: FC<RequiredChildrenProps> = memo(({ children }) => {
const LayoutComponent: FC<RequiredChildrenProps> = ({ children }) => {
const [mobileOpen, setMobileOpen] = useState(false);
const [title, setTitle] = useState(PROJECT_NAME);
const { pathname } = useLocation();
@@ -41,6 +41,8 @@ const Layout: FC<RequiredChildrenProps> = memo(({ children }) => {
</Box>
</LayoutContext.Provider>
);
});
};
const Layout = memo(LayoutComponent);
export default Layout;

Some files were not shown because too many files have changed in this diff Show More