Compare commits

...
679 Commits
Author SHA1 Message Date
Proddy ef11879dc0 Merge pull request #3226 from proddy/dev
don't use Eth if not on ESP32
2026-09-08 22:09:02 +02:00
proddy e3d9f03097 don't use Eth if not on ESP32 2026-09-08 22:08:22 +02:00
Proddy f90b7e9d91 Merge pull request #3217 from proddy/dev
Cleaner MD5 and Firmware upload messages in WebUI
2026-09-08 21:15:35 +02:00
Proddy e489c0f77d Merge branch 'dev' into dev 2026-09-08 21:14:49 +02:00
proddy 99dd04cbf5 remove writing efuse 2026-09-08 21:08:58 +02:00
proddy 4dac338e2c remove comments 2026-09-08 21:08:44 +02:00
proddy ff47722693 package update 2026-09-08 21:08:33 +02:00
Proddy 6aed13a9b2 Merge pull request #3225 from MichaelDvP/dev
fix solar enable #3223
2026-09-05 12:04:21 +02:00
proddy dbf54f09f5 auto-formatting 2026-09-05 11:02:33 +02:00
proddy 204ad1f8c9 add psram so standalone compiles 2026-09-05 11:02:20 +02:00
proddy 928e1e7a69 update 2026-09-05 11:02:07 +02:00
proddy a6ea91439b pkg update 2026-09-05 10:58:14 +02:00
MichaelDvP 7735c9e8f2 Merge remote-tracking branch 'mdvp/dev' into dev 2026-09-05 10:24:57 +02:00
MichaelDvP a22cab95fa Merge remote-tracking branch 'emsesp/dev' into dev 2026-09-05 10:22:39 +02:00
MichaelDvP 629c20ee11 chore: update generated files for v3.9.0-dev.8 2026-09-05 08:13:19 +00:00
Proddy 5d75583dba Merge branch 'emsesp:dev' into dev 2026-09-05 10:00:05 +02:00
Proddy 2ec8450b78 Merge pull request #3220 from pepe82sh/fix/olimexpoe-wrover-psram-eth-clock
Add OLIMEXPOEW board profile; guard against PSRAM/Ethernet clock GPIO conflict
2026-09-05 09:59:53 +02:00
MichaelDvP f911950aee solar enable #3223 2026-09-05 09:56:44 +02:00
MichaelDvP d1b97daff0 formatting 2026-09-05 09:54:34 +02:00
MichaelDvP 7caa5a1ba0 check for nvs1 partition 2026-09-05 09:53:51 +02:00
MichaelDvP 3ba7099987 show mqtt stop 2026-09-05 09:53:31 +02:00
proddy 0a50a9b1fa update 2026-09-04 16:30:58 +02:00
proddy 6291bdfa5d pkg update 2026-09-04 16:27:14 +02:00
proddy 81fa7c592e update for heating block rename 2026-09-04 16:27:02 +02:00
proddy 1b345f788d added heatblock temp translations 2026-09-04 13:48:25 +02:00
Proddy 7a709ba497 Merge branch 'emsesp:dev' into dev 2026-09-04 13:37:19 +02:00
proddy b4548ed257 formatting 2026-09-04 13:35:51 +02:00
Proddy 5f548c3287 Merge pull request #3219 from Jah-yee/fix/typo-german-translation
fix: correct German translation typo "Energiepreis Eletrizität" → "Energiepreis Elektrizität"
2026-09-04 13:33:23 +02:00
proddy 9d3a49a0de remove lambda check 2026-09-04 13:32:11 +02:00
proddy 355029ca9f update 2026-09-04 13:30:34 +02:00
proddy 889ddee86e 3.9.0-dev.8 2026-09-04 13:30:28 +02:00
proddy e43226af7e add missing translations 2026-09-04 13:28:31 +02:00
proddy 09e1ee9914 rename EXECUTE to RUN 2026-09-04 13:28:21 +02:00
proddy dc623cb32a pkg update 2026-09-04 13:27:59 +02:00
Michael PapeandClaude Sonnet 5 c1e514d8bf add OLIMEXPOEW board profile, guard against PSRAM/eth clock GPIO conflict
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GnozuURE2WF6AWJzxSu6fP
2026-09-04 13:20:49 +02:00
Jah-yee 936cbb5427 fix: correct heatblock translation per issue #3197
- English description: 'heating block' → 'Heatblok temperature'
- German translation: 'Wärmezelle' → 'Wärmeblocktemperatur'

The German 'Wärmezelle' (heat cell) was inconsistent with the English
'heating block' concept. The correct Bosch/HVAC term is 'Wärmeblock'
(heating block), and since the value represents a temperature reading,
'Wärmeblocktemperatur' is appropriate (per issue #3197).
2026-09-04 15:44:41 +08:00
Jah-yee 566167c7d9 fix: correct German translation "Energiepreis Eletrizität" → "Energiepreis Elektrizität" 2026-09-04 06:57:22 +08:00
proddy cc2865ebdd #3216 2026-09-03 21:24:40 +02:00
Proddy 6a1f4c9941 Merge pull request #3215 from proddy/dev
Network optimizations
2026-09-03 21:08:11 +02:00
proddy 13c6ed9e06 move eth_10mbit to Network Settings 2026-09-03 12:22:52 +02:00
proddy c711d1c6de updated 2026-09-02 17:48:02 +02:00
proddy 71824d1fc7 comments 2026-09-02 17:47:55 +02:00
proddy aaea00f943 pkg update 2026-09-02 17:47:47 +02:00
proddy f01b0fc3a7 added changelog notes 2026-09-01 22:44:07 +02:00
proddy ad62c79a40 power save - Reduce EMS-ESP energy consumption #3213 2026-09-01 21:56:36 +02:00
proddy e3b36a4084 pkg update 2026-09-01 21:54:11 +02:00
Proddy ce2b28651d Merge pull request #3202 from proddy/dev
http and sendmail optimizations and fixes
2026-08-31 08:10:33 +02:00
proddy 518ac0b4b4 check TLS, add more error messages 2026-08-30 15:48:58 +02:00
proddy c5e2ba024c rename "'Command sent" 2026-08-30 15:48:40 +02:00
proddy c765b5c9b6 update 2026-08-30 14:46:49 +02:00
proddy 84d67c0946 dont run the shunting-yard twice on sendmail values. Sending literal text through system/sendmail logged a warning and dropped the computed body 2026-08-30 14:42:42 +02:00
proddy 04f99688eb pkg update 2026-08-30 14:41:34 +02:00
proddy 0991ed467e dont use ReadyMail/ESP_SSL if security is off 2026-08-30 10:17:07 +02:00
proddy 4d8d3f0d46 fix email security as number 2026-08-29 20:06:32 +02:00
proddy db5ccb018f improve http fallouts, move to its own class 2026-08-29 16:26:26 +02:00
proddy 4d4d716e5f pkg update 2026-08-29 16:26:08 +02:00
proddy 94926b0ca7 S3 wifi support 2026-08-29 11:19:01 +02:00
proddy 98a6199a69 fixes for email service 2026-08-29 11:18:53 +02:00
proddy 416fecfaed 3.9.0-dev.6 2026-08-29 11:18:29 +02:00
proddy 319dfe6944 update words 2026-08-29 11:18:19 +02:00
proddy 3d0399b3a8 package update 2026-08-29 11:18:12 +02:00
Proddy 8eb1a47b5a Merge pull request #3195 from proddy/dev
minor changes
2026-08-25 21:28:43 +02:00
proddy fe972db8c5 pkg update 2026-08-25 21:28:06 +02:00
proddy be224265f9 show mqtt disconnect once 2026-08-25 18:59:25 +02:00
proddy 5fcd9bfd2d 3.9.0-dev.5 2026-08-25 18:26:33 +02:00
proddy 69b6af4069 pkg update 2026-08-25 18:26:06 +02:00
proddy cdade5f999 show 'Starting MQTT Service' only once, and connection failure errors after 2026-08-23 22:19:58 +02:00
proddy 97af64c53b text change 2026-08-23 22:19:16 +02:00
proddy bcb47912c0 refactor mqtt disconnect reason 2026-08-23 22:19:09 +02:00
proddy badd8f2241 pkg update 2026-08-23 22:18:51 +02:00
proddy 5aed596140 RESTART button is green 2026-08-23 22:18:42 +02:00
proddy 015da730bf move factory reset button to the left 2026-08-23 13:01:49 +02:00
proddy 0b47e5ec05 remove unused translations 2026-08-23 12:54:49 +02:00
proddy b0255d726b check text for online version check 2026-08-23 12:41:23 +02:00
proddy fce46f7613 auto-formatting 2026-08-23 12:31:27 +02:00
proddy c3cd4ac6b6 if there is no internet, keep trying every few seconds 2026-08-23 12:23:25 +02:00
proddy 91955689a2 include system name 2026-08-23 12:23:07 +02:00
proddy 89e88135bc comment changes 2026-08-23 12:22:55 +02:00
proddy 42f86cd12b get and send system name in AuthenticaionContext to prevent /rest/settings on each render 2026-08-23 12:22:45 +02:00
proddy 6efc7113b3 suppress rollout timings 2026-08-23 12:21:32 +02:00
proddy 48d425e0b6 remove useMemo 2026-08-22 15:45:48 +02:00
proddy b18b27d2f6 remove useMemo 2026-08-22 15:45:34 +02:00
proddy 5d992e5a70 remove useMemo 2026-08-22 15:45:24 +02:00
proddy 80e48430f5 pkg update - eslint 2026-08-22 15:44:41 +02:00
proddy cedafbd462 move reconnect count to onConnect. always show MQTT disconnect error 2026-08-22 13:25:31 +02:00
proddy 9d3eaa5ff7 rename connecting_ 2026-08-22 13:24:51 +02:00
proddy bc6351cc22 move MQTT and NTP to main loop 2026-08-22 13:24:40 +02:00
proddy 9b0ee07ee1 remove comments 2026-08-22 13:24:18 +02:00
proddy 4fe64fbff8 set system status to SYSTEM_STATUS_UPLOADING to pause MQTT/NTP etc during upload 2026-08-22 13:24:05 +02:00
proddy 51b689d91b text changes 2026-08-22 13:23:36 +02:00
proddy f5189b4d09 mention how to update unit tests 2026-08-22 13:23:21 +02:00
proddy 55b882cec2 update unit tests 2026-08-22 13:23:10 +02:00
proddy c16f12990a package update 2026-08-22 13:23:00 +02:00
proddy ed4b247d28 chore: update generated files for v3.9.0-dev.4 2026-08-20 11:48:53 +00:00
Proddy afb8efec53 Merge pull request #3194 from MichaelDvP/dev
small changes and fixes from #3181
2026-08-20 13:32:06 +02:00
MichaelDvP 84b23f19e5 dev.4 2026-08-20 13:23:05 +02:00
MichaelDvP 668d59a2cd update modbus 2026-08-20 12:44:32 +02:00
MichaelDvP 88f258d7e9 use http_request for version cache 2026-08-20 12:44:21 +02:00
MichaelDvP c45441e3be cleanup sample_value 2026-08-20 12:37:10 +02:00
MichaelDvP 2994535158 add back burnminpower for 0xe6, #3193 2026-08-20 12:36:35 +02:00
MichaelDvP fdfb560cf9 min value for coolstart, #3192 2026-08-20 12:35:55 +02:00
MichaelDvP d56c5897fa fix standalone 2026-08-19 16:40:51 +02:00
MichaelDvP cec0fbc95d revert dev7 mqtt changes 2026-08-19 16:37:10 +02:00
MichaelDvP 8f118e07a3 fix standalone 2026-08-19 16:30:08 +02:00
MichaelDvP b05ae6dbbb fix http timeout setting, dev.8 2026-08-19 15:46:11 +02:00
MichaelDvP 469ca8bf05 revert mqtt dev7 changes 2026-08-19 11:49:29 +02:00
MichaelDvP edd1677526 http timeouts 2026-08-19 11:49:04 +02:00
MichaelDvP c363e0e2c8 do not rebuild all HA configs on a mqtt reconnect, limit by heap 2026-08-17 14:11:42 +02:00
MichaelDvP d397a14b41 prevent unsuccessful mqtt connections, dev7 2026-08-17 11:37:19 +02:00
MichaelDvP 822096b635 detect network disconnect faster 2026-08-17 11:36:18 +02:00
MichaelDvP 0defebfdb6 update pkg 2026-08-17 11:35:40 +02:00
MichaelDvP 574c4e45d5 val_tpl for undefined: 'None' 2026-08-16 10:16:34 +02:00
MichaelDvP 48c118fed7 typo 2026-08-14 15:35:34 +02:00
MichaelDvP ef4335156c remote emulation for UI800 is RT800 2026-08-13 09:49:24 +02:00
MichaelDvP 4d4269cdf7 hc/control setting for UI800 thermostat #3181 2026-08-12 11:44:11 +02:00
MichaelDvP 95a8b78d1d revert commit #9c992d8 2026-08-12 08:54:05 +02:00
MichaelDvP 982b0f304e test with 'None' for undefined HA values 2026-08-11 09:36:30 +02:00
MichaelDvP 1d9a0b6257 set boottime for RTC (if rtc is set) 2026-08-10 10:11:43 +02:00
MichaelDvP 52486ed5b4 HA val_tpl for undefined boottime None 2026-08-10 10:11:01 +02:00
Proddy 73f2634acc Merge pull request #3186 from proddy/dev
more minor fixes, Eth fixes and other improvements
2026-08-09 20:36:58 +02:00
Proddy 2a604008ff Merge branch 'dev' into dev 2026-08-09 20:36:41 +02:00
proddy cd0a51f843 text changes 2026-08-09 20:21:41 +02:00
proddy 34cd93b7be fix bug where changing provision mode inflight would crash 2026-08-09 20:21:32 +02:00
proddy d47915d815 text changes 2026-08-09 20:20:53 +02:00
proddy cb97de7706 no AP if its sets to Never, stop loading ETH.begin() causing memory crash 2026-08-09 20:20:44 +02:00
proddy dbc5a73583 changed path 2026-08-09 20:19:48 +02:00
proddy 4d25c69c84 matching AP provision modes 2026-08-09 20:19:24 +02:00
proddy b430866bbb update tests 2026-08-09 20:19:08 +02:00
proddy 553752a7ca package update 2026-08-09 18:15:10 +02:00
proddy 9c992d8fda show MQTT connection error only once on the first connect failure 2026-08-09 12:28:17 +02:00
proddy e4d127db67 mqtt disconnect on non-psram boards 2026-08-09 09:58:57 +02:00
Proddy 5b41217a67 Merge pull request #3187 from MichaelDvP/dev
workaround for memory leak, #3184
2026-08-09 09:04:39 +02:00
proddy d913824b35 fix compare string length 2026-08-08 11:44:04 +02:00
proddy ae3443c637 various lint optimizations (add const, pass by reference) 2026-08-08 11:43:44 +02:00
proddy 50c34c13f0 replace define with constexpr 2026-08-08 11:42:32 +02:00
proddy d8fab910a8 package update 2026-08-08 11:42:10 +02:00
proddy 4231192991 ui changes to dialog boxes 2026-08-07 13:44:47 +02:00
proddy b47e7d1dd6 fix HA warning on Uptime 2026-08-07 13:44:35 +02:00
proddy fb55327373 fix possible dhcp bounce 2026-08-07 13:44:20 +02:00
proddy 4aab9e095a update 2026-08-07 13:43:09 +02:00
proddy 12ae358434 3.9.0-dev.3 2026-08-07 13:43:00 +02:00
proddy e22b82e913 refactoring based on Michael's comments 2026-08-07 13:42:45 +02:00
proddy 285361c09f package update 2026-08-07 13:42:26 +02:00
MichaelDvP 16caff789d Merge remote-tracking branch 'emsesp/dev' into dev 2026-08-07 12:42:02 +02:00
Proddy 835a8762f6 Merge pull request #3185 from proddy/dev
minor fixes
2026-08-07 12:23:27 +02:00
MichaelDvP 90f0c9c01b workaround for memory leak in setenv, #3184 2026-08-07 11:25:49 +02:00
proddy bde9ab53a1 fix formatting 2026-08-06 16:17:17 +02:00
proddy 901721fb99 pkg update 2026-08-06 12:10:45 +02:00
proddy dac1e1a69c prevent STA warning on boot 2026-08-06 12:10:39 +02:00
proddy 1b2371c67a scan all dns slots, prevent STA warning on boot 2026-08-06 12:10:21 +02:00
proddy 0f396200a2 update with dev.2 changes 2026-08-05 15:43:51 +02:00
proddy 1956ff8386 fix LED flashing 2026-08-05 15:43:40 +02:00
proddy 3a81c70018 add DHCP workaround (disabled for now) 2026-08-05 15:43:30 +02:00
Proddy 7212439f1b Merge pull request #3180 from MichaelDvP/dev
control entity only for master thermostat, #3173
2026-08-03 08:38:30 +02:00
MichaelDvP 6f7f343abd Merge remote-tracking branch 'mdvp/dev' into dev 2026-08-02 16:56:13 +02:00
MichaelDvP 42e6e31476 typo 2026-08-02 16:55:37 +02:00
MichaelDvP 96cf019f0a chore: update generated files for v3.9.0-dev.1 2026-08-02 14:49:34 +00:00
MichaelDvP 59b0417297 Merge branch 'core3' into dev 2026-08-02 16:32:05 +02:00
Proddy 2369277a9e Merge pull request #3179 from proddy/dev
fix lint warnings from SonarQube
2026-08-02 14:42:17 +02:00
proddy e166db8b70 fix lint warnings from SonarQube 2026-08-02 14:41:46 +02:00
proddy 36884867f7 fix for formidable loading, and svg being compressed 2026-08-02 14:40:32 +02:00
proddyandCursor 7fb8eb3592 Merge branch 'core3' into dev
Brings the ESP32 Arduino core 3 work (515 commits) into dev, moving the
version to 3.9.0-dev.0.

The 10 commits dev had since the merge base were auto-formatting, docs
regeneration and a version bump, all of which core3 had already applied
or superseded, so every conflict resolved to the core3 side and the
resulting tree matches core3 exactly.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 13:04:07 +02:00
proddy 2e988df817 preparing 3.9.0-dev.0 2026-08-02 12:53:04 +02:00
Proddy 483e124484 Merge pull request #3177 from proddy/core3
Core3 - pkg update and formatting
2026-08-01 17:05:51 +02:00
proddy 34db81ab4b package update 2026-08-01 17:03:31 +02:00
proddy e9a422bd27 add exit clause 2026-08-01 17:03:26 +02:00
proddy 4a8ad71b63 auto-formatting 2026-08-01 17:03:15 +02:00
MichaelDvP 6487a3be70 remove device without entities and not registered by busmaster 2026-07-31 18:40:18 +02:00
MichaelDvP 2eb804de0b show control only for master thermostat #3173 2026-07-31 18:33:25 +02:00
Proddy 6801f7b8cf Merge pull request #3175 from proddy/core3
Core3 pkg updates
2026-07-31 09:51:00 +02:00
proddy c9b90e282f pkg updates 2026-07-31 09:49:15 +02:00
proddy 11eeb521d5 fix state class for uptime 2026-07-28 21:28:30 +02:00
Proddy 67c8b8429b Merge pull request #3172 from MichaelDvP/core3
Merged dev with water fix
2026-07-28 18:58:34 +02:00
MichaelDvP c624e82ada update modbus 2026-07-28 10:11:39 +02:00
MichaelDvP 722e4665af Merge branch 'dev' into core3 2026-07-28 10:03:35 +02:00
MichaelDvP ba7c9cdc1b Merge remote-tracking branch 'emsesp/core3' into core3 2026-07-28 09:57:11 +02:00
Proddy 8c647331f2 Merge pull request #3170 from proddy/core3
Core3 - CZflag
2026-07-27 18:17:40 +02:00
proddy 9a98f6b2ff revert CZflag 2026-07-27 18:14:45 +02:00
proddy 1631b607c5 pkg auto-update 2026-07-27 18:12:33 +02:00
MichaelDvP 4646572c80 dump 2026-07-27 09:11:17 +02:00
MichaelDvP 9b5f9e7a28 Merge remote-tracking branch 'emsesp/core3' into core3 2026-07-27 08:56:17 +02:00
Proddy 4575f62e36 Merge pull request #3169 from proddy/core3
port two espMqttClient fixes from upstream 1.7.3
2026-07-26 17:41:41 +02:00
proddy aa8481c42e migrate espMqttClient upsteam patches 2026-07-26 17:39:58 +02:00
Proddy 454b45ee65 Merge pull request #3168 from proddy/core3
Core3 - implement setting to disable factory reset
2026-07-26 13:45:55 +02:00
Proddy 500f9ef5c5 Merge branch 'emsesp:core3' into core3 2026-07-26 13:45:12 +02:00
proddy 96f2e6d5a7 update 2026-07-26 13:44:30 +02:00
proddy a469507fb6 add disable_reset 2026-07-26 13:42:50 +02:00
proddy 2bef33f81a added application setting 'disable factory' Feature request->Button anti tampering mode
Fixes #3150
2026-07-26 13:34:15 +02:00
proddy e584adaa07 ESP32Async/ESPAsyncWebServer @ 3.12.0 2026-07-26 13:32:13 +02:00
Proddy faa327cd08 Merge pull request #3167 from proddy/core3
Core3 updates
2026-07-26 11:26:33 +02:00
proddy ec4572c9fc update tests with API attribute changes 2026-07-26 11:23:21 +02:00
proddy 1398aac828 auto-build modbus and run tests 2026-07-26 11:22:48 +02:00
proddy a25f73f0ec cz -> cs 2026-07-26 11:22:30 +02:00
proddy 3d402b0c94 update auto-generated docs 2026-07-26 11:22:11 +02:00
proddy c91488d2fe cz -> cs 2026-07-26 11:16:10 +02:00
proddy ac36b072e8 3.9.0-dev.18 2026-07-26 11:15:56 +02:00
proddy 77fac47584 show gateway/connect but greyed out in webUI 2026-07-26 10:50:10 +02:00
proddy c349c2ad0b fix typo 2026-07-26 10:49:52 +02:00
proddy 2c268bfc06 package update 2026-07-26 10:49:34 +02:00
proddy 0a66991902 add connect to device list 2026-07-26 10:49:20 +02:00
proddy d6fcf16257 ESP32Async/AsyncTCP @ 3.5.0 2026-07-26 10:48:56 +02:00
Proddy 3ab402c56d Merge pull request #3162 from MichaelDvP/core3
api attributs
2026-07-25 08:38:45 +02:00
MichaelDvP 92f36c6849 update pkg 2026-07-22 07:53:25 +02:00
MichaelDvP 0ae46e9382 Merge remote-tracking branch 'emsesp/core3' into core3 2026-07-17 10:02:13 +02:00
MichaelDvP 7989ab5b3f every readable entity has a value 2026-07-17 10:01:59 +02:00
Proddy b7aa153f84 Merge pull request #3160 from proddy/core3
Core3 updates
2026-07-16 21:21:42 +02:00
proddy c760218024 add UPTIME for HA UOM - #3146 2026-07-16 21:20:13 +02:00
proddy 3ee5b1ea1d package updates 2026-07-16 21:04:23 +02:00
Proddy 9d5edd8e08 Merge pull request #3158 from MichaelDvP/core3
updates from dev
2026-07-15 18:02:36 +02:00
MichaelDvP 5397d5d33a attributes for all entities 2026-07-15 14:00:03 +02:00
MichaelDvP a80ec9cf7e fix langth for json in shuntingyard, do not remove emty strings 2026-07-13 15:04:57 +02:00
MichaelDvP bc960980eb Merge remote-tracking branch 'emsesp/core3' into core3 2026-07-13 14:55:37 +02:00
Proddy 5199375088 Merge pull request #3156 from proddy/core3
Core3 - web changes
2026-07-12 22:05:06 +02:00
proddy 0ff0ff7d6c don't show gateways 2026-07-12 22:02:14 +02:00
proddy 7721993937 fix device types 2026-07-12 22:01:56 +02:00
proddy ddd0c39e4a package update 2026-07-12 21:26:35 +02:00
proddy 6512eac9af fix compile on osx 2026-07-12 21:26:29 +02:00
proddy 2bb88a6d3a clean builds 2026-07-12 21:26:21 +02:00
MichaelDvP e9b300f20b Merge branch 'dev' into core3 2026-07-12 16:40:45 +02:00
MichaelDvP c4c046f15f default sync NTP thermostat 2026-07-11 15:58:59 +02:00
MichaelDvP 2350a1e1a4 update version dev.17 2026-07-11 15:49:14 +02:00
MichaelDvP 99346c1db4 pkg 2026-07-11 15:28:56 +02:00
MichaelDvP 5ac1b486a7 block too much GET requests with semaphore 2026-07-11 15:27:59 +02:00
MichaelDvP 1cd3cb854e device value info for command attributes and GET requests 2026-07-11 15:27:33 +02:00
MichaelDvP eef1367ed2 default to sync NTP with thermostat clock 2026-07-11 15:26:27 +02:00
MichaelDvP bcad8f0819 add missing translations 2026-07-11 15:25:59 +02:00
MichaelDvP df1065eb8e pkg 2026-07-11 15:25:30 +02:00
MichaelDvP f36c02b9ed Merge remote-tracking branch 'emsesp/core3' into core3 2026-07-11 15:07:45 +02:00
Proddy 2e3ab7be8e Merge pull request #3154 from proddy/core3
core3 updates
2026-07-11 13:24:17 +02:00
proddy 2f63172cbf package updates 2026-07-11 13:22:48 +02:00
proddy 03326c1b47 https://github.com/emsesp/EMS-ESP32/pull/3149 2026-07-11 12:30:42 +02:00
MichaelDvP 036722f825 ntp setting 2026-07-10 16:11:48 +02:00
MichaelDvP 8bb464f2a9 update mock pkg 2026-07-10 13:50:28 +02:00
MichaelDvP 534989e4bb Merge branch 'dev' into core3 2026-07-10 13:19:39 +02:00
MichaelDvP eca8641c64 update pkg 2026-07-10 12:42:10 +02:00
MichaelDvP 0a5d0fbf88 Merge remote-tracking branch 'emsesp/core3' into core3 2026-07-10 12:37:31 +02:00
MichaelDvP aa76e94b43 update asyncwebserver 2026-07-10 12:34:31 +02:00
MichaelDvP fd284ce57d fix memoryleak 2026-07-10 12:34:18 +02:00
proddy e5e3c0bd40 package update 2026-07-04 16:00:26 +02:00
Proddy 7c4ff6e3e3 Merge pull request #3142 from proddy/core3
fix set datetime for junkers
2026-06-28 16:13:23 +02:00
proddy 04e787fd80 update 2026-06-28 16:09:22 +02:00
proddy 1040450cb2 fix set datetime for junkers 2026-06-28 16:03:31 +02:00
Proddy 06f5ee5c4c Merge pull request #3141 from proddy/core3
Core3 updates
2026-06-28 14:52:55 +02:00
proddy 58bf0b39c2 fix img compression 2026-06-28 14:39:43 +02:00
proddy 5a9ac0fd25 package update 2026-06-28 14:39:29 +02:00
proddy 7f308f4036 auto-formatting 2026-06-28 14:39:02 +02:00
proddy 61d3ad023a updates from 3.8.3-dev.7 2026-06-28 14:38:29 +02:00
proddy 1cfae73137 use dev version 2026-06-25 22:10:17 +02:00
proddy 2d4583ff01 package update 2026-06-25 22:02:45 +02:00
proddy b46dfd7f54 update 2026-06-25 22:02:40 +02:00
proddy 388b741d94 add more example commands 2026-06-25 22:02:31 +02:00
proddy 1a603e70ec AP crash and infinite loop during WiFi scan #3128 2026-06-25 21:29:02 +02:00
proddy 2a6b436b2e fix: negative dew point calculation (emulated RC100H) #3134 2026-06-25 21:23:49 +02:00
Proddy d21f5f9ab6 Merge pull request #3130 from proddy/core3
Core3 - fix divide-by-zero in shuntingyard
2026-06-22 22:57:53 +02:00
proddy 745d87c6d2 fixes https://github.com/emsesp/EMS-ESP32/discussions/3127 2026-06-22 22:54:25 +02:00
proddy 17f76e594d add comment 2026-06-22 22:53:56 +02:00
proddy 722a41fb43 fix typo 2026-06-22 22:53:46 +02:00
proddy f861924435 update 2026-06-22 22:53:39 +02:00
MichaelDvP ec8c94f733 wifi scanner, mock example schedule 2026-06-22 08:12:54 +02:00
Proddy 8fe8d87924 Merge pull request #3129 from proddy/core3
Core3 WebUI optimizations
2026-06-21 14:21:47 +02:00
proddy 74e260b4be 3.9.0-dev.14 2026-06-21 10:48:02 +02:00
proddy 3e501ade4f use new validation 2026-06-21 00:12:38 +02:00
proddy 679b152dd0 fix inline warnings 2026-06-21 00:12:17 +02:00
proddy 00201b1202 make pretty 2026-06-21 00:12:00 +02:00
proddy f2eb1b2e7f build web with optional ui languages 2026-06-20 13:10:56 +02:00
proddy 39b02591d9 react-router optimizations 2026-06-20 10:26:00 +02:00
proddy 92d63d6f5d replace async-validator 2026-06-19 12:06:33 +02:00
proddy 392812d489 own version of toast 2026-06-19 11:52:33 +02:00
proddy 88a777ca5f update react router to v8 2026-06-19 11:31:16 +02:00
Proddy 87a77586db Merge pull request #3124 from proddy/core3
remove old binaries #3123
2026-06-15 17:26:48 +02:00
proddy ca26f93ae7 remove old binaries #3123 2026-06-15 17:25:50 +02:00
proddy 2551c255c4 update package manager 2026-06-15 17:25:27 +02:00
Proddy 437b8447a2 Merge pull request #3122 from proddy/commands
Commands - update web
2026-06-14 17:11:13 +02:00
proddy 4042d47736 rename CommandsPage to Commands 2026-06-14 17:10:23 +02:00
proddy 0bc5cc0fe8 update 2026-06-14 17:10:00 +02:00
proddy fa265e8ea4 3.9.0-dev.13 2026-06-14 17:09:52 +02:00
proddy 30cc720dcf package update 2026-06-14 17:05:32 +02:00
proddy cdc5011eb6 prevent icons flashing white on page reload 2026-06-14 17:04:07 +02:00
proddy fe8cf2620d show execute button in table 2026-06-14 17:03:52 +02:00
proddy e818a5a943 update example backup file 2026-06-14 17:03:31 +02:00
proddy 6dde0ffc79 hide execute button on save 2026-06-14 17:03:19 +02:00
Proddy a790cf594e Merge pull request #3121 from MichaelDvP/core3
fix update warning message
2026-06-14 13:27:06 +02:00
MichaelDvP c260914dac fix update warning message 2026-06-14 08:59:50 +02:00
Proddy 9558e1d5ab Merge pull request #3120 from proddy/commands
Commands - fixes
2026-06-13 17:31:22 +02:00
proddy 7f98139deb add missing commands to restore 2026-06-13 17:24:29 +02:00
proddy ea8e0c1819 Merge branch 'commands' of github.com:proddy/EMS-ESP32 into commands 2026-06-13 17:16:40 +02:00
Proddy be0737f783 Merge pull request #8 from proddy/cursor/critical-bug-investigation-ac3f
Fix main-loop deadlock that breaks system/message and sendmail commands
2026-06-13 17:16:19 +02:00
proddy 6cedc0adc1 package update 2026-06-13 17:14:44 +02:00
proddy 06a81db88a minor heap optimizations 2026-06-13 16:47:14 +02:00
Cursor AgentandProddy 5ee08443f9 refactor: drop dead scheduler raw_value deferral, route url messages to worker
The raw_value/computed_value handoff on WebSchedulerService is no longer
used now that command_message()/command_sendmail() compute synchronously,
so remove the members and the dead block in the scheduler loop.

Also drop the system/message special-case in dispatchCommand(): a
system/message whose value embeds a {url} now blocks at execution time
like any other internal command, so it should be offloaded to the
command worker task (large stack, off the main loop) instead of running
inline. Update the now-stale comments.

Co-authored-by: Proddy <proddy@users.noreply.github.com>
2026-06-13 07:02:28 +00:00
Cursor AgentandProddy 515eb2a16b fix: run message/sendmail shunting-yard synchronously to avoid main-loop deadlock
command_message() and command_sendmail() handed their value to
WebSchedulerService via raw_value and busy-waited up to 2s for the
scheduler loop (running in a separate task) to compute it. After the
scheduler was moved to run synchronously in the main loop, any caller
running in the main loop (MQTT-triggered commands, scheduler-triggered
commands) deadlocks: the loop that would compute raw_value cannot run
while the caller is blocking inside it. The 2s wait then times out and
system/message fails entirely (sendmail sends the un-computed body).

Compute the value directly with compute() instead, which restores
correct behaviour for all callers.

Co-authored-by: Proddy <proddy@users.noreply.github.com>
2026-06-13 06:59:43 +00:00
Proddy 497d7cf7f3 Merge branch 'emsesp:dev' into commands 2026-06-12 16:41:38 +02:00
Proddy ea5be9508e Merge pull request #3114 from proddy/commands
Commands
2026-06-12 16:41:25 +02:00
proddy f5c15f83b4 fix bug when comparing dev releases 2026-06-12 16:29:35 +02:00
proddy e3bec544be version test 2026-06-12 16:29:26 +02:00
proddy 27eadcb875 add tests for version upgrades 2026-06-12 16:29:17 +02:00
proddy 31de3ffc82 package update 2026-06-12 16:27:39 +02:00
proddy 7664d50d69 one command function, to save on heap 2026-06-10 22:06:46 +02:00
proddy 5357f633d3 package update 2026-06-10 22:06:30 +02:00
proddy 0406ee0529 Scheduler to sync, Commands to async 2026-06-09 23:04:41 +02:00
proddy 423515ec37 remove EMSESP_SCHEDULER_* 2026-06-09 23:00:49 +02:00
proddy 9426ae9df6 fix mDNS restart after a reset 2026-06-09 23:00:32 +02:00
proddy b1944601ef scheduler loop is no longer async 2026-06-09 23:00:13 +02:00
proddy c4cc6a9386 fix missing translations, and order 2026-06-09 22:50:39 +02:00
proddy ad48f0010f package update 2026-06-09 22:50:25 +02:00
proddy d5e3571e17 Scheduler show 'at startup' 2026-06-09 20:30:32 +02:00
proddy cb6e452864 revert WiFi.disconnect 2026-06-09 20:30:12 +02:00
proddy c3ff4422c8 ESPAsyncWebServer @ 3.11.1 2026-06-09 20:29:35 +02:00
proddy 1ce691500f package update 2026-06-09 20:29:23 +02:00
proddy 05b1eaf175 add test 2026-06-09 20:29:07 +02:00
proddy 4f2215b126 button formatting 2026-06-08 22:48:46 +02:00
proddy 2cb7677098 implement migration to new Commands/Scheduler 2026-06-08 22:35:07 +02:00
proddy dc096a9856 load Commands first before Scheduler 2026-06-08 22:34:50 +02:00
proddy 8496a911da remove colors 2026-06-08 22:34:27 +02:00
proddy 4bff05a1c6 have to save before executing 2026-06-08 21:22:59 +02:00
proddy ff90662be1 green execute button 2026-06-08 21:04:19 +02:00
proddy 8fb69826f9 comments 2026-06-08 20:54:43 +02:00
proddy 7d94dc7cf0 update tests 2026-06-08 20:48:22 +02:00
proddy f93b5290ff add comment 2026-06-08 20:48:09 +02:00
proddy 88d7124874 update 2026-06-08 20:47:54 +02:00
proddy 7ee6dcf0e0 call commands can override value 2026-06-08 20:44:21 +02:00
proddy 9370ae69ec cleanup 2026-06-08 20:44:09 +02:00
proddy afee7246c6 prevent STA warning 2026-06-08 20:44:00 +02:00
proddy 2102180c46 add commands to system backup 2026-06-08 20:43:42 +02:00
proddy 064548316e dont show unused json elements 2026-06-08 20:43:27 +02:00
proddy e53aaffd4d add comment 2026-06-08 20:43:11 +02:00
proddy 0d14601385 optimize 2026-06-08 20:43:03 +02:00
proddy 96e3fdc323 use EMSdevice::DeviceType::SYSTEM 2026-06-08 20:42:42 +02:00
proddy fd359ab9ed optional value when calling commands 2026-06-08 20:42:08 +02:00
proddy 192357f4f3 3.9.0-dev.12 2026-06-08 20:41:47 +02:00
proddy 223700a1a8 update 2026-06-08 20:41:39 +02:00
proddy 643744d8d9 formatting 2026-06-08 20:41:31 +02:00
proddy 4085a96145 formatting 2026-06-08 20:41:21 +02:00
proddy ba7ea60af5 typo 2026-06-08 20:41:11 +02:00
proddy ecf416dc0a formatting 2026-06-08 20:41:05 +02:00
proddy 8a45e79071 updates 2026-06-08 10:03:32 +02:00
proddy 5c4dfcb9ae first try 2026-06-07 18:26:35 +02:00
Proddy f4cee54042 Merge pull request #3109 from proddy/core3
Core3 updates #3
2026-06-07 17:14:05 +02:00
Proddy 6e9049c82b Merge branch 'emsesp:core3' into core3 2026-06-07 17:09:02 +02:00
Proddy 4498d853b0 Merge pull request #3113 from MichaelDvP/core3
merge dev changes into Core3
2026-06-07 17:08:35 +02:00
MichaelDvP f6308d61eb fix looping of immediate schedule, fix errormessage nvs 2026-06-07 16:29:08 +02:00
proddy 776ffdc931 update tests 2026-06-07 15:14:35 +02:00
proddy 14df556e36 close dialog on fail 2026-06-07 14:40:27 +02:00
proddy f3e4040d72 add comment 2026-06-07 14:40:18 +02:00
proddy 511f5c1681 camelCase system name 2026-06-07 14:39:56 +02:00
proddy 844b48accb show custom system name as model in HA 2026-06-07 14:39:18 +02:00
proddy 4bba059ed5 formatting 2026-06-07 14:39:03 +02:00
MichaelDvP cc1808289a Merge branch 'dev' into core3 2026-06-07 14:12:36 +02:00
proddy cfcc84d0c4 call scheduler immediate from dashboard 2026-06-07 12:37:34 +02:00
proddy cbfebabfa3 Feature Request: Make the "EMS-ESP" title text in the web interface configurable.
Fixes #3110
2026-06-07 09:56:07 +02:00
proddy c55b8c5e2e fix showing immediate in dashboard 2026-06-07 09:55:48 +02:00
proddy a2afc4342e fix lint warning for C++20 volatile 2026-06-06 16:42:32 +02:00
proddy b85085790a add volatile, as they are ISR-written outside the main loop 2026-06-06 16:40:23 +02:00
proddy 2d0bb34d17 update 2026-06-06 16:39:58 +02:00
proddy 8f3adc800f use corepack v11 2026-06-06 16:39:17 +02:00
proddy 791285e7f4 enable cancel mid-flight in an HTTPS firmware upload 2026-06-06 16:35:40 +02:00
proddy b1bc8110cc remove debug 2026-06-06 15:07:45 +02:00
proddy 0b5d643863 don't show Scheduler items of type immediate in Dashboard 2026-06-06 15:07:17 +02:00
proddy 1acc71a6b9 3.9.0-dev.11" 2026-06-06 15:06:50 +02:00
proddy 41ff124d10 package update 2026-06-06 15:06:43 +02:00
proddy 9a225569fd formatting 2026-06-06 15:06:32 +02:00
Proddy c572155d8d Merge pull request #3103 from proddy/core3
add missing "\r\n" to GET header
2026-05-29 16:18:45 +01:00
proddy d79f27422f package update 2026-05-29 16:17:38 +01:00
proddy 844ee751b7 add esp32_exception_decoder 2026-05-29 16:16:05 +01:00
proddy 7b6d60691c add missing "\r\n" to GET header 2026-05-29 16:15:54 +01:00
proddy c7816a644f prevent message command parsing URLs twice 2026-05-29 16:15:29 +01:00
proddy a0196ff9b2 add comment 2026-05-29 16:14:59 +01:00
Proddy 24ae72a7d1 Merge pull request #3102 from MichaelDvP/core3
merge dev changes to core3
2026-05-29 08:46:16 +01:00
MichaelDvP 0099c4faae Merge branch 'dev' into core3 2026-05-29 07:12:21 +02:00
MichaelDvP 0a7e0dc1a3 Merge remote-tracking branch 'emsesp/core3' into core3 2026-05-29 07:08:12 +02:00
MichaelDvP 62e32d5ddd Merge remote-tracking branch 'emsesp/dev' into dev 2026-05-29 07:07:07 +02:00
Proddy f97f4d83f1 Merge pull request #3098 from proddy/core3
Core3 updates
2026-05-28 20:55:31 +01:00
proddy aaff759227 add comments 2026-05-28 20:40:15 +01:00
proddy 1314f643f7 load schduler and other services before checking upgrade 2026-05-28 20:39:59 +01:00
proddy 55a5ffc6df handle mandatory scheduler name change in update function 2026-05-28 20:39:43 +01:00
proddy e6a04b2c4f package update 2026-05-28 20:39:03 +01:00
MichaelDvP 06293ebd2f update pkg 2026-05-28 15:05:07 +02:00
MichaelDvP 36f63da36f fix typos 2026-05-27 12:00:30 +02:00
MichaelDvP e989891652 Merge branch 'dev' into core3 2026-05-27 11:16:19 +02:00
proddy c2f0cdab15 typo 2026-05-26 22:39:59 +02:00
proddy dc1a9ad993 -DEMSESP_EN_ONLY to c6 2026-05-26 22:38:24 +02:00
Proddy 2522218385 Merge branch 'core3' into core3 2026-05-26 22:31:25 +02:00
proddy 258daef1cf dont show immediate scheduler items 2026-05-26 22:28:16 +02:00
proddy df7e95b644 remove preceeding bar 2026-05-26 22:00:18 +02:00
proddy e67ce25eb9 package update 2026-05-26 21:58:59 +02:00
proddy 88768aaf75 remove comments 2026-05-26 21:58:49 +02:00
proddy 2543d2f484 remove HTTPClient 2026-05-26 09:15:05 +02:00
proddy 96f5510153 fix stat_t bug 2026-05-26 09:14:58 +02:00
proddy 1d6813fe30 package update 2026-05-26 09:14:47 +02:00
Proddy 0867e166e1 Merge pull request #3093 from MichaelDvP/core3
network fallback, fixes #3090
2026-05-25 19:08:19 +02:00
proddy 48b3ab9085 auto-set a scheduler name if its empty 2026-05-25 12:51:51 +02:00
proddy 7f78e27649 formatting 2026-05-25 12:51:36 +02:00
proddy 46c5560222 add TLS support for all boards 2026-05-25 12:32:53 +02:00
proddy cef5e69aa1 remove downloadonly 2026-05-25 12:30:01 +02:00
proddy 601395079e 3.9.0-dev.10 2026-05-25 12:29:10 +02:00
proddy bbb05594e4 3.9.0-dev.10 2026-05-25 12:29:04 +02:00
proddy 2f5400b49a package update 2026-05-25 12:28:52 +02:00
proddy ace071e974 Scheduler with type "Immediate", click Execute does not run the command
Fixes #3092
2026-05-25 12:28:42 +02:00
proddy a406d6d911 package update 2026-05-25 12:27:42 +02:00
MichaelDvP ba7d9b33d8 allow E32 gateway to connect eth 2026-05-25 08:59:37 +02:00
MichaelDvP 04934c6bca remove trailing comma 2026-05-25 08:59:09 +02:00
proddy 7409c7286f https://github.com/emsesp/EMS-ESP32/issues/3089 2026-05-24 21:34:49 +02:00
proddy 51c0157d1d typo 2026-05-24 21:34:45 +02:00
MichaelDvP 0b9eec0849 Merge remote-tracking branch 'emsesp/core3' into core3 2026-05-24 18:01:07 +02:00
Proddy 8785bde7b6 Merge pull request #3083 from proddy/core3
various optimizations
2026-05-24 17:15:49 +02:00
MichaelDvP 31166794dd network fallback, fixes #3090 2026-05-24 16:54:04 +02:00
proddy 2b90b59386 update comment 2026-05-20 21:49:57 +02:00
proddy 280341a11c remove debug log 2026-05-20 21:49:24 +02:00
proddy e6274174b1 sync with dev 2026-05-20 18:32:12 +02:00
proddy 984d587613 Merge branch 'core3' of github.com:proddy/EMS-ESP32 into core3 2026-05-20 18:31:53 +02:00
proddy 2b4def1737 package update 2026-05-20 18:12:14 +02:00
proddy 6ecb45cc86 fix MacOSX standalone 2026-05-19 22:59:08 +02:00
proddy 3fd0573339 merge all URI handlers into a single dispatch function 2026-05-19 21:28:30 +02:00
proddy d670bc2b07 optimize enum to avoid per-call adding enums to heap 2026-05-19 17:45:48 +02:00
proddy 5292f7ed17 use reference to avoid copy 2026-05-19 17:45:02 +02:00
proddy 9bd987abb0 order device values for packing, reducing stack 2026-05-19 17:44:39 +02:00
proddy 4bb24da89d use PSRAM 2026-05-19 17:44:11 +02:00
proddy 7491e0d9b1 package update 2026-05-19 17:43:56 +02:00
MichaelDvP d85c1dc8e8 telegram buffers 2026-05-18 07:40:02 +02:00
MichaelDvP ecb8b5df0c Merge remote branch “emsesp/core3” 2026-05-17 16:45:22 +02:00
proddy 972fb9cce5 formatting 2026-05-17 16:20:03 +02:00
proddy 90894898c2 use PSRAM in AsyncJsonResponse 2026-05-17 16:19:54 +02:00
proddy 144d6589ef fix brand_to_cstr 2026-05-17 16:15:17 +02:00
proddy a7588779a3 package update 2026-05-17 16:14:55 +02:00
proddy 4bc7ed3db2 min_free_mem_ 2026-05-16 16:26:11 +02:00
proddy 5f9eb8d6d2 large jsondocs in PSRAM 2026-05-16 16:26:04 +02:00
proddy 2f85b367b0 replace std:function with lambda 2026-05-16 16:17:10 +02:00
proddy 824c036f9a brand_to_cstr() 2026-05-16 15:46:17 +02:00
proddy 0bb27f03f3 add const 2026-05-16 15:46:08 +02:00
proddy 1602f9bf02 add condition to LOGging 2026-05-16 15:45:51 +02:00
proddy 800cd5bec8 add const, preserve some memory 2026-05-16 15:45:38 +02:00
proddy 85a12bf581 add const 2026-05-16 15:45:14 +02:00
proddy a1f24b38fa do the toLower outside the loop 2026-05-16 15:39:51 +02:00
Proddy 44168216fe Merge pull request #3081 from proddy/feature-LED_api
Feature led api #3063
2026-05-16 15:19:31 +02:00
proddy dee4f3531d remove unneeded include 2026-05-16 15:19:08 +02:00
proddy bf07f904fc some small optimizations, more colors 2026-05-16 12:27:00 +02:00
proddy 26c2b1b14e package update 2026-05-16 12:26:51 +02:00
proddy 7153bfefba remove unused variables 2026-05-15 20:41:48 +02:00
proddy 807b77d418 logic improvements 2026-05-15 13:06:14 +02:00
proddy c69a7b03ef remove DEBUG for bus connection testing 2026-05-15 13:06:00 +02:00
proddy a39626bb0e add comments 2026-05-15 13:05:43 +02:00
proddy 68e14875b3 EMS BUS ID -> EMS Bus ID 2026-05-15 10:49:23 +02:00
proddy 2d6d664109 typo 2026-05-15 10:49:12 +02:00
proddy dd58973118 comment update 2026-05-15 10:48:40 +02:00
proddy 17d3f182a0 formatting 2026-05-15 10:48:15 +02:00
proddy 1961090c4c show EMS bus ID 2026-05-15 10:48:05 +02:00
proddy 33fda705c0 Feature: Make RGB LED (preset colors) accessible via API/etc #3039
Fixes #3063
2026-05-15 10:07:17 +02:00
proddy 208717a896 rgbLedWrite 2026-05-14 21:25:05 +02:00
proddy 6afc6b1baa replace rgbLedWrite 2026-05-14 21:24:55 +02:00
proddy a4ddd73fc8 FACTORY_NETWORK_HOSTNAME 2026-05-14 21:24:20 +02:00
proddy 4459b1e333 fix for -DEMSESP_TEST 2026-05-14 21:24:01 +02:00
proddy b2a66a0789 fix typo in hostname 2026-05-14 15:50:30 +02:00
proddy 33d1315395 package update 2026-05-14 15:50:23 +02:00
proddy a0cae13f53 bump version 2026-05-14 15:50:16 +02:00
proddy d1e2634594 rename FACTORY_NETWORK_HOSTNAME 2026-05-14 15:50:08 +02:00
proddy b2247f5f58 tidy up 2026-05-14 12:10:42 +02:00
proddy ae46ca2b8a upgrade bus id from x0B to x49 2026-05-14 12:10:33 +02:00
proddy 82ab22518e add cursor settings 2026-05-14 12:10:14 +02:00
Proddy 224bf63ded Merge pull request #3075 from proddy/core3
version update
2026-05-12 19:52:22 +02:00
proddy a7fbde48ef bump version 2026-05-12 19:51:48 +02:00
proddy 3a3d0fb05e package update 2026-05-12 19:51:43 +02:00
proddy 8ed71e0192 formatting 2026-05-12 19:51:36 +02:00
Proddy 434b6a81c4 Merge pull request #3073 from MichaelDvP/core3
merge latest dev changes, sendmail security as list
2026-05-12 19:48:08 +02:00
Proddy ec20f0743c Merge branch 'core3' into core3 2026-05-12 19:47:59 +02:00
Proddy 09a72068e6 Merge pull request #3069 from proddy/core3
enable OTA uploads of filesystem (pre_load.json)
2026-05-12 19:45:27 +02:00
proddy fcc56a9bf6 package update 2026-05-12 19:45:04 +02:00
proddy 84d321f1b4 fix json uploads 2026-05-12 19:44:59 +02:00
MichaelDvP ea7c2a1548 Merge branch 'dev' into core3 2026-05-11 15:06:11 +02:00
MichaelDvP 34d12bfb49 Merge branch 'core3' of https://github.com/MichaelDvP/EMS-ESP32 into core3 2026-05-10 11:28:04 +02:00
MichaelDvP ff0990ec0e update core, pnpm, and pkg 2026-05-10 11:11:17 +02:00
MichaelDvP 459270e035 rename sendmail and add security list instead of checkbox 2026-05-10 11:10:53 +02:00
MichaelDvP 2af709b3d5 update core, pnpm, and pkg 2026-05-10 10:41:51 +02:00
MichaelDvP b84d53dc39 rename sendmail and add security list instead of checkbox 2026-05-10 10:41:24 +02:00
proddy 57997d0acb enable OTA uploads of filesystem (pre_load.json) 2026-05-09 15:25:06 +02:00
proddy 76a23870d5 force littlefs to v2.0 2026-05-09 15:24:45 +02:00
proddy 193e43ddc5 package update 2026-05-09 15:24:14 +02:00
MichaelDvP fc451863c0 Merge branch 'dev' into core3 2026-05-09 08:13:31 +02:00
Proddy db69c43d35 Merge pull request #3067 from MichaelDvP/core3
core3 fix for #3064
2026-05-07 18:04:04 +02:00
MichaelDvP 97d727a19b update pkg 2026-05-07 12:25:31 +02:00
MichaelDvP ff952935a2 Merge branch 'dev' into core3 2026-05-07 12:10:53 +02:00
Proddy a9cdab1ce9 Merge pull request #3062 from MichaelDvP/core3
sync dev changes to core3
2026-05-06 09:54:18 +02:00
MichaelDvP 015c100a1d Merge branch 'dev' into core3 2026-05-06 09:28:29 +02:00
Proddy e7a14183d2 Merge pull request #3060 from proddy/core3
rename mail -> email
2026-05-05 22:00:42 +02:00
proddy 5e1b47707f rename mail -> email 2026-05-05 21:56:55 +02:00
proddy 56fc303816 package update 2026-05-05 21:55:26 +02:00
proddy d7a6a6f803 formatting 2026-05-05 21:55:13 +02:00
Proddy 03d969cb39 Merge pull request #3059 from proddy/core3
fix warning "esp32-hal-gpio.c:181] __digitalWrite(): IO 2 is not set …
2026-05-05 08:04:43 +02:00
proddy c55df4eb6e fix standalone build lint warning 2026-05-04 20:57:20 +02:00
proddy cc631ff07f remove digitalWrite at init 2026-05-04 20:48:12 +02:00
proddy b34a1b57d6 fix warning "esp32-hal-gpio.c:181] __digitalWrite(): IO 2 is not set as GPIO. Execute digitalMode(2, OUTPUT) first" 2026-05-04 08:38:41 +02:00
Proddy 9cad606a55 Merge pull request #3058 from proddy/core3
small adjustments
2026-05-04 08:23:28 +02:00
proddy 6587ddcead increase timeout to allow slower networks to connect 2026-05-04 08:22:39 +02:00
proddy ad05eec952 fix standalone and tests 2026-05-04 08:15:27 +02:00
Proddy 847fa4f46c Merge pull request #3056 from MichaelDvP/core3
"Send Testmail" button (not translated) #3050
2026-05-03 23:04:43 +02:00
Proddy c5897b7ee1 Merge pull request #3057 from proddy/core3
updates to Network code
2026-05-03 23:04:19 +02:00
proddy 99ef4c0c18 updates 2026-05-03 23:03:22 +02:00
proddy cc118adec6 text changes 2026-05-03 22:55:25 +02:00
proddy ca94e37495 use a state machine for cycling between Eth-Wifi-AP 2026-05-03 21:59:28 +02:00
proddy e2bd721c3e remove empty SSID check 2026-05-03 17:16:38 +02:00
proddy 033ce24fb7 udpate 2026-05-03 15:21:26 +02:00
proddy eab7cdd7b5 updates 2026-05-03 15:19:49 +02:00
proddy 666ba41f67 package update 2026-05-03 14:24:36 +02:00
MichaelDvP 2579450eae "Send Testmail" button (not translated) #3050 2026-05-03 14:24:03 +02:00
MichaelDvP 87a3ca8393 nosleep default on after network selector 2026-05-03 14:22:08 +02:00
proddy 9ff4be41f7 getWifiReconnects -> getNetworkReconnects 2026-05-03 08:43:28 +02:00
Proddy da3ed6cd3a Merge pull request #3054 from proddy/core3
add back version check
2026-05-02 11:04:15 +02:00
proddy 23519a8a90 add back version check 2026-05-02 11:03:33 +02:00
Proddy 242708358e Merge pull request #3052 from proddy/core3
refactor networking into a single class
2026-05-02 10:51:37 +02:00
proddy 3cc3c74e5a update versions 2026-05-02 10:44:06 +02:00
proddy cb4cb39396 update 2026-05-02 10:43:36 +02:00
proddy 363799c9c6 fix connect spelling 2026-05-02 09:49:34 +02:00
proddy 132f83aa79 update dictionary 2026-05-02 09:49:21 +02:00
proddy f998714225 add missing #endif 2026-05-02 09:49:09 +02:00
proddy 323fc1bb99 remove comments 2026-05-02 09:48:55 +02:00
proddy 3062d3f0e3 remove Divider 2026-05-02 09:48:44 +02:00
proddy 8f37bb7623 package update 2026-05-02 09:48:31 +02:00
proddy a57ed90756 use new network code 2026-05-02 09:48:19 +02:00
proddy eaf8332d16 Merge remote-tracking branch 'origin/dev' into core3 2026-05-02 09:35:31 +02:00
proddy 522286ff74 remove double wifi lost message 2026-05-02 09:11:19 +02:00
proddy 747047556e fix lint warnings on osx 2026-05-01 17:58:22 +02:00
proddyandCursor df3d75c702 ignore .vscode/settings.json
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-01 17:44:41 +02:00
proddy e40beeadd4 performance updates 2026-05-01 17:04:46 +02:00
proddy 751f10603d upgrade if AP provision mode is AP_MODE_ALWAYS 2026-05-01 17:03:56 +02:00
proddy 751c540cb3 refactor network code 2026-05-01 08:07:05 +02:00
proddy d7bbc329bb use 3.9.0 as dummy latest dev version 2026-04-29 08:58:04 +02:00
proddy 41cd49a61c remove comment 2026-04-29 08:57:53 +02:00
proddy fd5a39702b update 2026-04-29 08:57:46 +02:00
proddy 4d3408254e chore: stop tracking .vscode/settings.json
Already listed in .gitignore but was tracked, so local edits kept
showing up as pending changes. Untrack it so the ignore rule applies.

Made-with: Cursor
2026-04-29 08:57:12 +02:00
proddy 2cbb5ec5f2 move restart button from Settings to Version page. only show Factory Reset when in developer mode 2026-04-28 20:09:22 +02:00
proddy 3b765b308e remove unused useMemo 2026-04-28 17:31:50 +02:00
proddy 53ac82520e DeserializationError is enum 2026-04-28 16:28:11 +02:00
proddy 381fcf4080 ESP32Async/ESPAsyncWebServer @ 3.11.0 2026-04-28 16:27:59 +02:00
proddy a3f0faf022 package update 2026-04-28 16:27:46 +02:00
proddy b3a8737a71 move Version from status to settings 2026-04-28 16:27:39 +02:00
proddy 6e76bcc9af show badge if there is an update available, which is cached 2026-04-27 18:12:05 +02:00
proddy 6473c55317 don't force an update on each request 2026-04-27 18:11:48 +02:00
proddy 1a880f14a0 Remove useMemo/useCallback across the web UI 2026-04-27 13:24:07 +02:00
proddy e39af36589 fix lint errors 2026-04-27 13:23:39 +02:00
proddy c5b262af8a dont update cloudflare KV for forks 2026-04-27 11:34:03 +02:00
proddy 43ec5c1925 move mockserver to standalone section only 2026-04-27 11:30:40 +02:00
proddy 5e260f0239 refactoring 2026-04-27 11:09:51 +02:00
proddy ab67f97b40 3.8.2-dev.20 2026-04-27 11:09:34 +02:00
proddy 9ac35e2e14 fetch emsesp firmware versions after IP connected 2026-04-27 11:09:24 +02:00
proddy 7c6259dddd tidy up comments 2026-04-27 11:08:52 +02:00
proddy 1cff1abc33 package update 2026-04-27 11:08:22 +02:00
proddy d834d46586 rename EMSESP_Version to firmwareVersion 2026-04-27 11:08:13 +02:00
proddy 1107e1bdf3 package update 2026-04-26 16:10:35 +02:00
proddy 3a11327e7e https://github.com/emsesp/EMS-ESP32/discussions/3044 2026-04-26 16:10:30 +02:00
proddy 74062bab57 update tests 2026-04-26 16:07:45 +02:00
proddy 6802336b6b remove old code 2026-04-26 16:07:29 +02:00
proddy a9db134d3a version updates 2026-04-26 13:24:40 +02:00
proddy ee7be1d907 add 2026-04-26 12:20:48 +02:00
proddy 5ecda88457 inlclude full date/time 2026-04-25 21:14:16 +02:00
proddy 7056c446fa use emsesp.org/versions.json 2026-04-25 20:55:10 +02:00
proddy 147c09ae64 automatically update versions in Cloudflare KV store 2026-04-25 11:42:26 +02:00
proddy 112adf9eb0 add vscode 2026-04-25 11:19:39 +02:00
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 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 051c332426 package update 2026-04-22 15:43:46 +02:00
proddy a09258325e remove YIELD 2026-04-22 15:43:36 +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
MichaelDvP 1329b13db3 Merge branch 'dev' into core3 2026-04-22 15:05:39 +02:00
MichaelDvP 9dd894f0fe Merge branch 'dev' of https://github.com/emsesp/EMS-ESP32 into core3 2026-04-22 10:14:34 +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 08eb294213 update readymail 2026-04-22 07:26:17 +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
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 c40d828749 Merge pull request #3031 from proddy/core3
build_webUI -> build-webUI
2026-04-19 19:15:28 +02:00
proddy 84ad08887a build_webUI -> build-webUI 2026-04-19 19:13:15 +02:00
MichaelDvP ed0a678020 Merge branch 'dev' of https://github.com/emsesp/EMS-ESP32 into core3 2026-04-19 15:49:36 +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 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
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 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 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 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
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 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
MichaelDvP 51d90095aa https client for scheduler/shuntingYard 2026-04-04 11:39:43 +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
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
proddy 22312812bb system backup 2026-03-20 17:14:58 +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
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
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
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 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 aa30ca99bf update core 2026-03-15 21:41:18 +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 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 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 d627404dc2 skip onEvent for AP, MQTT, NTP 2026-02-16 07:47:13 +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
289 changed files with 19859 additions and 16926 deletions
+20
View File
@@ -77,3 +77,23 @@ jobs:
files: |
CHANGELOG_LATEST.md
./build/firmware/*.*
- name: Update version in Cloudflare KV store
if: github.repository == 'emsesp/EMS-ESP32'
env:
CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}
CF_NAMESPACE_ID: ${{ secrets.CF_NAMESPACE_ID }}
CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
VERSION: ${{ steps.build_info.outputs.VERSION }}
run: |
JSON_DATA=$(jq -n \
--arg version "$VERSION" \
--arg date "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
'{version: $version, date: $date}')
echo "JSON_DATA: $JSON_DATA"
curl -sS --fail-with-body \
-X PUT "https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/storage/kv/namespaces/${CF_NAMESPACE_ID}/values/dev" \
-H "Authorization: Bearer ${CF_API_TOKEN}" \
-H "Content-Type: application/json" \
-d "$JSON_DATA"
echo
+28 -1
View File
@@ -27,10 +27,17 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v6
- name: Enable Corepack
run: corepack enable pnpm
- name: Get the EMS-ESP version
id: build_info
run: |
version=`grep -E '^#define EMSESP_APP_VERSION' ./src/emsesp_version.h | awk -F'"' '{print $2}'`
echo "VERSION=$version" >> $GITHUB_OUTPUT
- name: Install PlatformIO
run: |
python -m pip install --upgrade pip
@@ -61,3 +68,23 @@ jobs:
files: |
CHANGELOG.md
./build/firmware/*.*
- name: Update version in Cloudflare KV store
if: github.repository == 'emsesp/EMS-ESP32'
env:
CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}
CF_NAMESPACE_ID: ${{ secrets.CF_NAMESPACE_ID }}
CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
VERSION: ${{ steps.build_info.outputs.VERSION }}
run: |
JSON_DATA=$(jq -n \
--arg version "$VERSION" \
--arg date "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
'{version: $version, date: $date}')
echo "JSON_DATA: $JSON_DATA"
curl -sS --fail-with-body \
-X PUT "https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/storage/kv/namespaces/${CF_NAMESPACE_ID}/values/stable" \
-H "Authorization: Bearer ${CF_API_TOKEN}" \
-H "Content-Type: application/json" \
-d "$JSON_DATA"
echo
+40
View File
@@ -0,0 +1,40 @@
name: 'Update versions'
on:
workflow_dispatch:
permissions:
contents: write
jobs:
update-version:
name: 'Update versions in Cloudflare KV store'
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Get and Send EMS-ESP version to Cloudflare
env:
CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}
CF_NAMESPACE_ID: ${{ secrets.CF_NAMESPACE_ID }}
CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
run: |
version=$(grep -E '^#define EMSESP_APP_VERSION' ./src/emsesp_version.h | awk -F'"' '{print $2}')
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
KV_ENV="stable"
else
KV_ENV="dev"
fi
JSON_DATA=$(jq -n \
--arg version "$version" \
--arg date "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
'{version: $version, date: $date}')
echo "KV_ENV: $KV_ENV"
echo "JSON_DATA: $JSON_DATA"
curl -sS --fail-with-body \
-X PUT "https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/storage/kv/namespaces/${CF_NAMESPACE_ID}/values/${KV_ENV}" \
-H "Authorization: Bearer ${CF_API_TOKEN}" \
-H "Content-Type: application/json" \
-d "$JSON_DATA"
echo
+5 -1
View File
@@ -2,6 +2,7 @@
.vscode/c_cpp_properties.json
.vscode/extensions.json
.vscode/launch.json
.vscode/settings.json
# c++ compiling
.clang_complete
@@ -63,7 +64,7 @@ words-found-verbose.txt
# sonarlint
compile_commands.json
# pioarduino + hybrid
# other files
managed_components
dependencies.lock
CMakeLists.txt
@@ -75,3 +76,6 @@ pnpm-lock.yaml
.cache/
interface/.tsbuildinfo
test/test_api/package-lock.json
.clangd
mklittlefs
.cursor
-101
View File
@@ -1,101 +0,0 @@
{
"search.exclude": {
"**/.yarn": true,
"**/.pnp.*": true
},
"editor.codeActionsOnSave": {
"source.fixAll": "explicit"
},
"eslint.validate": [
"typescript"
],
"eslint.codeActionsOnSave.rules": null,
"eslint.nodePath": "interface/.yarn/sdks",
"eslint.workingDirectories": ["interface"],
"prettier.prettierPath": "",
"typescript.enablePromptUseWorkspaceTsdk": true,
"files.associations": {
"*.tsx": "typescriptreact",
"*.tcc": "cpp",
"optional": "cpp",
"istream": "cpp",
"ostream": "cpp",
"ratio": "cpp",
"system_error": "cpp",
"array": "cpp",
"functional": "cpp",
"regex": "cpp",
"tuple": "cpp",
"type_traits": "cpp",
"utility": "cpp",
"string": "cpp",
"string_view": "cpp",
"atomic": "cpp",
"bitset": "cpp",
"cctype": "cpp",
"chrono": "cpp",
"clocale": "cpp",
"cmath": "cpp",
"condition_variable": "cpp",
"cstdarg": "cpp",
"cstddef": "cpp",
"cstdint": "cpp",
"cstdio": "cpp",
"cstdlib": "cpp",
"cstring": "cpp",
"ctime": "cpp",
"cwchar": "cpp",
"cwctype": "cpp",
"deque": "cpp",
"list": "cpp",
"unordered_map": "cpp",
"unordered_set": "cpp",
"vector": "cpp",
"exception": "cpp",
"algorithm": "cpp",
"iterator": "cpp",
"map": "cpp",
"memory": "cpp",
"memory_resource": "cpp",
"numeric": "cpp",
"random": "cpp",
"set": "cpp",
"fstream": "cpp",
"initializer_list": "cpp",
"iomanip": "cpp",
"iosfwd": "cpp",
"iostream": "cpp",
"limits": "cpp",
"mutex": "cpp",
"new": "cpp",
"sstream": "cpp",
"stdexcept": "cpp",
"streambuf": "cpp",
"thread": "cpp",
"cinttypes": "cpp",
"typeinfo": "cpp"
},
"todo-tree.filtering.excludeGlobs": [
"**/vendor/**",
"**/node_modules/**",
"**/dist/**",
"**/bower_components/**",
"**/build/**",
"**/.vscode/**",
"**/.github/**",
"**/_output/**",
"**/*.min.*",
"**/*.map",
"**/ArduinoJson/**"
],
"cSpell.enableFiletypes": [
"ini",
"makefile"
],
"typescript.preferences.preferTypeOnlyAutoImports": true,
"sonarlint.pathToCompileCommands": "${workspaceFolder}/compile_commands.json",
"sonarlint.connectedMode.project": {
"connectionId": "emsesp",
"projectKey": "emsesp_EMS-ESP32"
}
}
+31 -1
View File
@@ -5,6 +5,36 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [3.8.3] 1 August 2026
## Added
- KM300 at address 0x4A [#3084](https://github.com/emsesp/EMS-ESP32/issues/3084)
## Fixed
- signed value for solarInfuence [#3077](https://github.com/emsesp/EMS-ESP32/issues/3077)
- set bin file upload limit to 1M again [#3086](https://github.com/emsesp/EMS-ESP32/issues/3086)
- check arithmetric operations on strings [#3127](https://github.com/emsesp/EMS-ESP32/discussions/3127)
- prevent system crash during WiFi network discovery on ESP32-S3 hardware [#3128](https://github.com/emsesp/EMS-ESP32/pull/3128)
- fix setting date/time on Junkers thermostats
- offset of hpminflowtemp [#3144](https://github.com/emsesp/EMS-ESP32/issues/3144)
- water circulation command [#3149](https://github.com/emsesp/EMS-ESP32/pull/3149)
- start scheduler after customEntities, delay scheduler loop after startup, handling of `system/restart` command [#3146](https://github.com/emsesp/EMS-ESP32/issues/3146)
- possible memory leaks fixed.
- repeated startup schedules compute value
- call `system/message` called from scheduler processes compute twice
- water module SM100/200 as dhw1/dhw2 [#3164](https://github.com/emsesp/EMS-ESP32/issues/3164)
## Changed
- call compute value direct, enlarge TCP stack [#3127](https://github.com/emsesp/EMS-ESP32/discussions/3127)
- Dewtemperature for Easycontrol calculated by ems-esp [#3135](https://github.com/emsesp/EMS-ESP32/issues/3135)
- add option for sync thermostat to ntp, allow different time zones [#3148](https://github.com/emsesp/EMS-ESP32/discussions/3148), **defaults to sync**.
- block too many GET requests [mentioned in #3104](https://github.com/emsesp/EMS-ESP32/issues/3104)
- set 10 sec timeout for http requests [#3104](https://github.com/emsesp/EMS-ESP32/issues/3104)
- language code "cs" [#3161](https://github.com/emsesp/EMS-ESP32/issues/3161)
## [3.8.2] 12 May 2026
## Added
@@ -945,4 +975,4 @@ There are breaking changes between 3.5.x and earlier versions of 3.6.0. Please r
- some names of mqtt-tags like in v2.2.1
- new ESP32 partition side to allow for smoother OTA and fallback
- Network Gateway IP is optional (#682)emsesp/EMS-ESP
- moved to a new GitHub repo <https://github.com/emsesp/EMS-ESP32>
- moved to a new GitHub repo <https://github.com/emsesp/EMS-ESP32>
+30 -20
View File
@@ -2,32 +2,42 @@
For more details go to [emsesp.org](https://emsesp.org/).
## [3.8.3]
## [3.9.0]
This release is based on the latest Espressif/Arduino core version 3. It brings in many memory and performance optimizations. Note it does require the user to manually migrate settings from 3.8.x to 3.9.0.
## Added
- KM300 at address 0x4A [#3084](https://github.com/emsesp/EMS-ESP32/issues/3084)
- user-requested LED blink [#3063](https://github.com/emsesp/EMS-ESP32/issues/3063)
- Commands Service that can be called via MQTT or API or used in the Scheduler Service
- option to disable factory reset [#3150](https://github.com/emsesp/EMS-ESP32/issues/3150)
- TLS support with 4MB boards without PSRAM
- added eth_10mbit to Network Settings, which forces 10BASE-T half-duplex & autonegotiation off, halving the PHYs power draw. Its off by default as it will cause issues on managed switches with a port forced to 100Mb [#3213](https://github.com/emsesp/EMS-ESP32/pull/3213)
- cleaner MD5 and firmware uploading in WebUI [#3222](https://github.com/emsesp/EMS-ESP32/issues/3222)
## Fixed
- signed value for solarInfuence [#3077](https://github.com/emsesp/EMS-ESP32/issues/3077)
- set bin file upload limit to 1M again [#3086](https://github.com/emsesp/EMS-ESP32/issues/3086)
- check arithmetric operations on strings [#3127](https://github.com/emsesp/EMS-ESP32/discussions/3127)
- prevent system crash during WiFi network discovery on ESP32-S3 hardware [#3128](https://github.com/emsesp/EMS-ESP32/pull/3128)
- fix setting date/time on Junkers thermostats
- offset of hpminflowtemp [#3144](https://github.com/emsesp/EMS-ESP32/issues/3144)
- water circulation command [#3149](https://github.com/emsesp/EMS-ESP32/pull/3149)
- start scheduler after customEntities, delay scheduler loop after startup, handling of `system/restart` command [#3146](https://github.com/emsesp/EMS-ESP32/issues/3146)
- possible memory leaks fixed.
- repeated startup schedules compute value
- call `system/message` called from scheduler processes compute twice
- water module SM100/200 as dhw1/dhw2 [#3164](https://github.com/emsesp/EMS-ESP32/issues/3164)
- shunting yard show json
- `system/sendmail` called from a Command or the Scheduler computed its value twice, which stripped the quotes from literal text and then failed on characters like `!`. Also shunting yard treated a `?` inside a quoted string as a ternary, so text like `"too hot?"` returned nothing
- memory leak when using different timezones for ems-esp and thermostat[#3184](https://github.com/emsesp/EMS-ESP32/issues/3184)
- LED stayed off on a healthy system when "Disable LED" was unchecked
- Ethernet MAC address changed with the new SDK, breaking DHCP reservations (currently disabled)
- "IPv4 nameserver" showed an IPv6 address when IPv6 was in use
- HA Discovery warning on Uptime after EMS-ESP boot due to NTP not ready
- hc/control setting for UI800 thermostats [#3181](https://github.com/emsesp/EMS-ESP32/discussions/3181)
- solar module switches [#3223](https://github.com/emsesp/EMS-ESP32/issues/3223)
## Changed
- call compute value direct, enlarge TCP stack [#3127](https://github.com/emsesp/EMS-ESP32/discussions/3127)
- Dewtemperature for Easycontrol calculated by ems-esp [#3135](https://github.com/emsesp/EMS-ESP32/issues/3135)
- add option for sync thermostat to ntp, allow different time zones [#3148](https://github.com/emsesp/EMS-ESP32/discussions/3148), **defaults to sync**.
- block too many GET requests [mentioned in #3104](https://github.com/emsesp/EMS-ESP32/issues/3104)
- set 10 sec timeout for http requests [#3104](https://github.com/emsesp/EMS-ESP32/issues/3104)
- language code "cs" [#3161](https://github.com/emsesp/EMS-ESP32/issues/3161)
- various memory optimizations [#3083](https://github.com/emsesp/EMS-ESP32/issues/3083)
- Scheduler name is now mandatory
- network fallback to AP only after start [#3090](https://github.com/emsesp/EMS-ESP32/issues/3090)
- replaced Web async-validator with custom validator and toast with native snackbar to reduce bundle size
- Gateway and Connect devices are shown in the Devices page, but disabled [3126](https://github.com/emsesp/EMS-ESP32/discussions/3126)
- show control setting only for master thermostats (0x10) [#3173](https://github.com/emsesp/EMS-ESP32/issues/3173)
- remove devices without entities not listed in 0x07 telegram
- changes to the Dialogs in Dashboard page, removed the run icon in the Commands page. Renamed Execute to Run.
- set `None` for undefined values in HA `val_tpl`
- changes in WebUI - remove multiple calls systemInfo endpoint, Vewrsion pages checks for internet connection [#3195](https://github.com/emsesp/EMS-ESP32/pull/3195)
- multiple optimization in network, power down WiFi when Ethernet and WiFi nosleep is instant when selected [#3213](https://github.com/emsesp/EMS-ESP32/pull/3213)
+3 -4
View File
@@ -47,7 +47,7 @@ MAKEFLAGS += -j$(JOBS) -l$(shell echo $$(($(JOBS) * 2)))
#----------------------------------------------------------------------
TARGET := emsesp
BUILD := build
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
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 :=
@@ -65,7 +65,6 @@ CXX_STANDARD := -std=gnu++20
#----------------------------------------------------------------------
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_BOARD_PROFILE=\"S32S3\"
@@ -113,8 +112,8 @@ CXX := /usr/bin/g++
CPPFLAGS += $(DEFINES) $(DEFAULTS) $(INCLUDE)
CPPFLAGS += -ggdb -g3 -MMD
CPPFLAGS += -flto=auto
CPPFLAGS += -Wall -Wextra -Werror -Wswitch-enum
CPPFLAGS += -Wno-unused-parameter -Wno-missing-braces -Wno-vla-cxx-extension
CPPFLAGS += -Wall -Wextra -Werror
CPPFLAGS += -Wno-unused-parameter -Wno-missing-braces -Wno-vla-cxx-extension -Wno-switch-enum
CPPFLAGS += -ffunction-sections -fdata-sections -fno-exceptions -fno-rtti -fno-threadsafe-statics
CPPFLAGS += -Os -DNDEBUG
-1
View File
@@ -5,7 +5,6 @@
},
"core": "esp32",
"extra_flags": [
"-DTASMOTA_SDK",
"-DARDUINO_LOLIN_C3_MINI",
"-DARDUINO_USB_MODE=1",
"-DARDUINO_USB_CDC_ON_BOOT=1"
-1
View File
@@ -6,7 +6,6 @@
"core": "esp32",
"extra_flags": [
"-DBOARD_HAS_PSRAM",
"-DTASMOTA_SDK",
"-DARDUINO_USB_CDC_ON_BOOT=1",
"-DARDUINO_USB_MODE=0"
],
+1 -1
View File
@@ -21,7 +21,7 @@
"arduino",
"espidf"
],
"name": "Espressif ESP32-S3 32M Flash OPI PSRAM, 4608KB Code/OTA, 2MB FS",
"name": "Tasmota ESP32-S3 32M Flash OPI PSRAM, 4608KB Code/OTA, 2MB FS",
"upload": {
"flash_size": "32MB",
"maximum_ram_size": 327680,
+1 -2
View File
@@ -1,7 +1,6 @@
{
"build": {
"core": "esp32",
"extra_flags": "-DTASMOTA_SDK",
"f_cpu": "240000000L",
"f_flash": "40000000L",
"flash_mode": "dio",
@@ -19,7 +18,7 @@
"arduino",
"espidf"
],
"name": "Espressif ESP32 16M Flash, 4608KB Code/OTA, 2MB FS",
"name": "Tasmota ESP32 16M Flash, 4608KB Code/OTA, 2MB FS",
"upload": {
"flash_size": "16MB",
"maximum_ram_size": 327680,
+1 -1
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,
-1
View File
@@ -1,7 +1,6 @@
{
"build": {
"core": "esp32",
"extra_flags": "-DTASMOTA_SDK",
"f_cpu": "240000000L",
"f_flash": "40000000L",
"flash_mode": "dio",
+161 -26
View File
@@ -1,6 +1,7 @@
{
"type": "systembackup",
"version": "3.8.2",
"version": "3.9.0",
"date": "2026-06-14T10:59:16",
"systembackup": [
{
"type": "settings",
@@ -15,10 +16,11 @@
"enableMDNS": true,
"enableCORS": false,
"CORSOrigin": "*",
"tx_power": 0
"tx_power": 0,
"eth_10mbit": false
},
"AP": {
"provision_mode": 1,
"provision_mode": 2,
"ssid": "ems-esp",
"password": "ems-esp-neo",
"channel": 1,
@@ -31,13 +33,13 @@
"MQTT": {
"enableTLS": false,
"rootCA": "",
"enabled": false,
"host": "",
"enabled": true,
"host": "192.168.1.2",
"port": 1883,
"base": "ems-esp",
"username": "",
"password": "",
"client_id": "esp32-b8ffc9ec",
"username": "username",
"password": "password",
"client_id": "esp32",
"keep_alive": 60,
"clean_session": false,
"entity_format": 1,
@@ -51,17 +53,17 @@
"publish_time_heartbeat": 60,
"mqtt_qos": 0,
"mqtt_retain": false,
"ha_enabled": false,
"ha_enabled": true,
"nested_format": 1,
"discovery_prefix": "homeassistant",
"discovery_type": 0,
"ha_number_mode": 0,
"ha_number_mode": 1,
"publish_single": false,
"publish_single2cmd": false,
"send_response": false
},
"NTP": {
"enabled": false,
"enabled": true,
"server": "time.google.com",
"tz_label": "Europe/Amsterdam",
"tz_format": "CET-1CEST,M3.5.0,M10.5.0/3"
@@ -82,11 +84,12 @@
]
},
"Settings": {
"version": "3.8.2-dev.22",
"version": "3.9.0",
"board_profile": "E32V2_2",
"platform": "ESP32",
"system_name": "prod",
"locale": "en",
"tx_mode": 5,
"tx_mode": 1,
"ems_bus_id": 73,
"syslog_enabled": false,
"syslog_level": 3,
@@ -97,7 +100,7 @@
"boiler_heatingoff": false,
"remote_timeout": 24,
"remote_timeout_en": false,
"shower_timer": false,
"shower_timer": true,
"shower_alert": false,
"shower_alert_coldshot": 10,
"shower_alert_trigger": 7,
@@ -107,7 +110,7 @@
"dallas_gpio": 14,
"dallas_parasite": false,
"led_gpio": 32,
"hide_led": false,
"hide_led": true,
"led_type": 1,
"low_clock": false,
"telnet_enabled": true,
@@ -131,26 +134,74 @@
"modbus_port": 502,
"modbus_max_clients": 10,
"modbus_timeout": 300,
"developer_mode": false
"developer_mode": true,
"disable_reset": false,
"email_enabled": true,
"email_security": 2,
"email_server": "smtp.gmail.com",
"email_port": 587,
"email_login": "test@emsesp.org",
"email_pass": "password",
"email_sender": "test@emsesp.org",
"email_recp": "test@emsesp.org",
"email_subject": "ems-esp notification"
}
},
{
"type": "commands",
"Commands": {
"commands": [
{
"id": 1,
"cmd": "{\"url\":\"https://api.pushover.net/1/messages.json\"}",
"value": "{\"user\":\"userkey\", \"token\":\"tokenkey\", \"message\":\"custom/message2\"}",
"name": "push_over"
},
{
"id": 2,
"cmd": "system/message",
"value": "hello",
"name": "sendmessage"
},
{
"id": 3,
"cmd": "system/message",
"value": "{\"url\":\"http://emsesp.org/versions.json\"}",
"name": "sendversions"
},
{
"id": 4,
"cmd": "custom/message",
"value": "{\"url\":\"http://emsesp.org/versions.json\"}",
"name": "getversions"
},
{
"id": 5,
"cmd": "system/sendmail",
"value": "\"test email with \" + custom/message2",
"name": "sendmail"
}
]
}
},
{
"type": "schedule",
"Schedule": {
"schedule": []
"schedule": [
{
"id": 1,
"flags": 128,
"active": false,
"time": "",
"cmd_name": "sendmail",
"name": "sendmailtest"
}
]
}
},
{
"type": "customizations",
"Customizations": {
"ts": [
{
"id": "28_1767_7B13_2502",
"name": "gateway_temperature",
"offset": 0,
"is_system": true
}
],
"as": [
{
"gpio": 39,
@@ -186,7 +237,36 @@
{
"type": "entities",
"Entities": {
"entities": []
"entities": [
{
"id": 0,
"ram": 1,
"device_id": 0,
"type_id": 0,
"offset": 0,
"factor": 1,
"name": "message",
"uom": 0,
"value_type": 9,
"writeable": true,
"hide": false,
"value": "{\"stable\":{\"version\":\"3.8.3\",\"date\":\"2026-08-01\"},\"dev\":{\"version\":\"3.9.0-dev.0\",\"date\":\"2026-08-01T09:55:29Z\"}}"
},
{
"id": 1,
"ram": 1,
"device_id": 0,
"type_id": 0,
"offset": 0,
"factor": 1,
"name": "message2",
"uom": 0,
"value_type": 9,
"writeable": true,
"hide": false,
"value": "testing"
}
]
}
},
{
@@ -198,10 +278,65 @@
{
"type": "nvs",
"nvs": [
{
"type": 1,
"key": "nompower",
"value": 30
},
{
"type": 33,
"key": "boot",
"value": "3.9.0"
},
{
"type": 17,
"key": "led",
"value": 0
},
{
"type": 33,
"key": "intTemp",
"value": "28_1767_7B13_2502"
},
{
"type": 4,
"key": "d_boot",
"value": 1
},
{
"type": 33,
"key": "app1",
"value": "3.9.0"
},
{
"type": 33,
"key": "app0",
"value": "3.9.0"
},
{
"type": 66,
"key": "nrgheat",
"value": 49355.29793
},
{
"type": 4,
"key": "d_app1",
"value": 1781336521
},
{
"type": 1,
"key": "fresh_firmware",
"value": 0
},
{
"type": 4,
"key": "d_app0",
"value": 1781363944
},
{
"type": 66,
"key": "nrgww",
"value": 41852.79107
}
]
}
File diff suppressed because it is too large Load Diff
+6011 -5998
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -3,7 +3,7 @@ build_flags =
; WiFi settings
-D FACTORY_WIFI_SSID=\"\"
-D FACTORY_WIFI_PASSWORD=\"\"
-D FACTORY_WIFI_HOSTNAME=\"ems-esp\"
-D FACTORY_NETWORK_HOSTNAME=\"ems-esp\"
; Access point settings
-D FACTORY_AP_PROVISION_MODE=AP_MODE_DISCONNECTED
@@ -24,7 +24,7 @@ build_flags =
-D FACTORY_NTP_TIME_ZONE_LABEL=\"Europe/Amsterdam\"
-D FACTORY_NTP_TIME_ZONE_FORMAT=\"CET-1CEST,M3.5.0,M10.5.0/3\"
-D FACTORY_NTP_SERVER=\"time.google.com\"
-D FACTORY_NTP_THERMOSTAT_SYNC=0
-D FACTORY_NTP_THERMOSTAT_SYNC=1
; MQTT settings
-D FACTORY_MQTT_ENABLED=false
+27 -36
View File
@@ -1,9 +1,9 @@
{
"name": "EMS-ESP",
"version": "3.8.2",
"version": "3.9.0",
"description": "EMS-ESP WebUI",
"homepage": "https://emsesp.org",
"author": "proddy, emsesp.org",
"author": "emsesp.org",
"license": "MIT",
"private": true,
"type": "module",
@@ -23,49 +23,40 @@
"standalone-devcontainer": "concurrently -c \"auto\" \"typesafe-i18n\" \"pnpm:mock-rest\" \"vite --host\""
},
"dependencies": {
"@alova/adapter-xhr": "2.3.1",
"@alova/adapter-xhr": "2.3.3",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^9.2.0",
"@mui/material": "^9.2.0",
"@preact/compat": "^18.3.2",
"@mui/icons-material": "^9.4.0",
"@mui/material": "^9.4.0",
"@table-library/react-table-library": "4.1.15",
"alova": "3.5.1",
"async-validator": "^4.2.5",
"etag": "^1.8.1",
"formidable": "^3.5.4",
"alova": "^3.5.5",
"jwt-decode": "^4.0.0",
"magic-string": "^0.30.21",
"mime-types": "^3.0.2",
"preact": "^10.29.7",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"preact": "^10.29.8",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-icons": "^5.7.0",
"react-router": "^7.18.1",
"react-toastify": "^11.1.0",
"typesafe-i18n": "^5.27.1",
"typescript": "^6.0.3"
"react-router": "^8.3.1",
"typesafe-i18n": "^5.27.1"
},
"devDependencies": {
"@babel/core": "^7.29.7",
"@eslint/js": "^10.0.1",
"@preact/compat": "^18.3.2",
"@preact/preset-vite": "^2.10.5",
"@preact/preset-vite": "^2.10.6",
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
"@types/node": "^25.9.5",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"axe-core": "^4.12.1",
"concurrently": "^9.2.4",
"eslint": "^10.7.0",
"@types/node": "^26.5.0",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.7",
"concurrently": "^10.0.5",
"eslint": "^10.10.0",
"eslint-config-prettier": "^10.1.8",
"prettier": "^3.9.5",
"rollup-plugin-visualizer": "^7.0.1",
"terser": "^5.49.0",
"typescript-eslint": "^8.63.0",
"vite": "^8.1.4",
"vite-plugin-imagemin": "^0.6.1",
"vite-tsconfig-paths": "^6.1.1"
"etag": "^1.8.1",
"mime-types": "^3.0.2",
"prettier": "^3.9.6",
"rollup-plugin-visualizer": "^7.1.1",
"terser": "^5.51.2",
"typescript": "^6.0.3",
"typescript-eslint": "^8.70.0",
"vite": "^8.2.2",
"vite-plugin-imagemin": "^0.6.1"
},
"packageManager": "pnpm@10.33.4+sha512.1c67b3b359b2d408119ba1ed289f34b8fc3c6873412bec6fd264fbdc82489e510fcbecb9ce9d22dae7f3b76269d8441046014bdca53b9979cd7a561ad631b800"
"packageManager": "pnpm@12.3.4+sha512.961aa41fb077da3a04a441d9f8e15ebc0c96da8ef710b2eb67bf9ee7cb0610eabd48f1fd85f51cffe73846785fa0f87c56a3a872a1d893f8446741b5cce45457"
}
+888 -1474
View File
File diff suppressed because it is too large Load Diff
+7 -32
View File
@@ -1,33 +1,8 @@
allowBuilds:
cwebp-bin: set this to true or false
esbuild: set this to true or false
gifsicle: set this to true or false
jpegtran-bin: set this to true or false
mozjpeg: set this to true or false
optipng-bin: set this to true or false
pngquant-bin: set this to true or false
minimumReleaseAgeExclude:
- '@babel/code-frame@7.29.7'
- '@babel/compat-data@7.29.7'
- '@babel/core@7.29.7'
- '@babel/generator@7.29.7'
- '@babel/helper-compilation-targets@7.29.7'
- '@babel/helper-globals@7.29.7'
- '@babel/helper-module-imports@7.29.7'
- '@babel/helper-module-transforms@7.29.7'
- '@babel/helper-string-parser@7.29.7'
- '@babel/helper-validator-identifier@7.29.7'
- '@babel/helper-validator-option@7.29.7'
- '@babel/helpers@7.29.7'
- '@babel/parser@7.29.7'
- '@babel/template@7.29.7'
- '@babel/traverse@7.29.7'
- '@babel/types@7.29.7'
onlyBuiltDependencies:
- cwebp-bin
- esbuild
- gifsicle
- jpegtran-bin
- mozjpeg
- optipng-bin
- pngquant-bin
cwebp-bin: false
esbuild: false
gifsicle: false
jpegtran-bin: false
mozjpeg: false
optipng-bin: false
pngquant-bin: false
+38 -1
View File
@@ -46,6 +46,38 @@ ${fileInfo.map((f) => `${INDENT}{"${f.uri}", "${f.mimeType}", ${f.variable}, ${f
static constexpr size_t WWW_ASSETS_COUNT = sizeof(WWW_ASSETS) / sizeof(WWW_ASSETS[0]);
`;
// Optional locale allow-list, shared with the Vite build via VITE_APP_LOCALES
// (e.g. "en,de,nl"). When set, locale chunks outside the list are NOT embedded
// into firmware flash. `en` is always kept as the fallback. Unset => embed all.
const ALL_LOCALES = [
'cs',
'de',
'en',
'fr',
'it',
'nl',
'no',
'pl',
'sk',
'sv',
'tr'
];
const localeAllowList = (process.env.VITE_APP_LOCALES || '')
.split(',')
.map((locale) => locale.trim())
.filter(Boolean);
const isExcludedLocaleChunk = (relativeFilePath) => {
if (localeAllowList.length === 0) return false;
const base = relativeFilePath.split(sep).pop();
const match = /^([a-z]{2})-[A-Za-z0-9_-]+\.js$/.exec(base);
if (!match) return false;
const code = match[1];
// Only treat known locale codes as locale chunks; never drop the en fallback.
if (!ALL_LOCALES.includes(code) || code === 'en') return false;
return !localeAllowList.includes(code);
};
const getFilesSync = (dir, files = []) => {
readdirSync(dir, { withFileTypes: true }).forEach((entry) => {
const entryPath = resolve(dir, entry.name);
@@ -116,7 +148,12 @@ writeStream.write(ARDUINO_INCLUDES);
const buildPath = resolve(sourcePath);
for (const filePath of getFilesSync(buildPath)) {
writeFile(relative(buildPath, filePath), readFileSync(filePath));
const relativeFilePath = relative(buildPath, filePath);
if (isExcludedLocaleChunk(relativeFilePath)) {
console.log(`Skipping locale (not in VITE_APP_LOCALES): ${relativeFilePath}`);
continue;
}
writeFile(relativeFilePath, readFileSync(filePath));
}
writeStream.write(generateWWWClass());
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

+36 -36
View File
@@ -1,14 +1,15 @@
import { memo, useCallback, useEffect, useState } from 'react';
import { ToastContainer, Zoom } from 'react-toastify';
import { memo, useEffect, useState } from 'react';
import { Outlet } from 'react-router';
import AppRouting from 'AppRouting';
import CustomTheme from 'CustomTheme';
import { Toaster } from 'components/toast';
import { Authentication } from 'contexts/authentication';
import TypesafeI18n from 'i18n/i18n-react';
import type { Locales } from 'i18n/i18n-types';
import { loadLocaleAsync } from 'i18n/i18n-util.async';
import { detectLocale, navigatorDetector } from 'typesafe-i18n/detectors';
const AVAILABLE_LOCALES = [
const ALL_LOCALES = [
'de',
'en',
'it',
@@ -22,51 +23,50 @@ const AVAILABLE_LOCALES = [
'cs'
] as Locales[];
// Static toast configuration - no need to recreate on every render
const TOAST_CONTAINER_PROPS = {
position: 'bottom-left' as const,
autoClose: 3000,
hideProgressBar: false,
newestOnTop: false,
closeOnClick: true,
rtl: false,
pauseOnFocusLoss: true,
draggable: false,
pauseOnHover: false,
transition: Zoom,
closeButton: false,
theme: 'dark' as const,
toastStyle: {
border: '1px solid #177ac9',
width: 'fit-content'
}
};
// Optional build-time allow-list (e.g. VITE_APP_LOCALES="en,de,nl"). When unset,
// every locale is available. `en` is always kept as the fallback locale, and the
// progmem generator embeds the matching subset into firmware flash.
const localeAllowList = (import.meta.env.VITE_APP_LOCALES ?? '')
.split(',')
.map((locale) => locale.trim())
.filter(Boolean);
const AVAILABLE_LOCALES: Locales[] = localeAllowList.length
? ALL_LOCALES.filter(
(locale) => locale === 'en' || localeAllowList.includes(locale)
)
: ALL_LOCALES;
const App = memo(() => {
const [wasLoaded, setWasLoaded] = useState(false);
const [locale, setLocale] = useState<Locales>('en');
// Memoize locale initialization to prevent unnecessary re-runs
const initializeLocale = useCallback(async () => {
const browserLocale = detectLocale('en', AVAILABLE_LOCALES, navigatorDetector);
const newLocale = (localStorage.getItem('lang') || browserLocale) as Locales;
localStorage.setItem('lang', newLocale);
setLocale(newLocale);
await loadLocaleAsync(newLocale);
setWasLoaded(true);
}, []);
useEffect(() => {
const initializeLocale = async () => {
const browserLocale = detectLocale('en', AVAILABLE_LOCALES, navigatorDetector);
const stored = localStorage.getItem('lang');
// Ignore a stored locale that isn't available (e.g. trimmed from this build).
const newLocale =
stored && AVAILABLE_LOCALES.includes(stored as Locales)
? (stored as Locales)
: browserLocale;
localStorage.setItem('lang', newLocale);
setLocale(newLocale);
await loadLocaleAsync(newLocale);
setWasLoaded(true);
};
void initializeLocale();
}, [initializeLocale]);
}, []);
if (!wasLoaded) return null;
return (
<TypesafeI18n locale={locale}>
<CustomTheme>
<AppRouting />
<ToastContainer {...TOAST_CONTAINER_PROPS} />
<Authentication>
<Outlet />
</Authentication>
<Toaster />
</CustomTheme>
</TypesafeI18n>
);
-72
View File
@@ -1,72 +0,0 @@
import { type FC, memo, useContext, useEffect, useRef } from 'react';
import { Navigate, Route, Routes } from 'react-router';
import { toast } from 'react-toastify';
import AuthenticatedRouting from 'AuthenticatedRouting';
import SignIn from 'SignIn';
import { RequireAuthenticated, RequireUnauthenticated } from 'components';
import { Authentication, AuthenticationContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react';
interface SecurityRedirectProps {
readonly message: string;
readonly signOut?: boolean;
}
const RootRedirect: FC<SecurityRedirectProps> = memo(
({ message, signOut = false }) => {
const { signOut: contextSignOut } = useContext(AuthenticationContext);
const hasShownToast = useRef(false);
useEffect(() => {
// Prevent duplicate toasts on strict mode or re-renders
if (!hasShownToast.current) {
hasShownToast.current = true;
if (signOut) {
contextSignOut(false);
}
toast.success(message);
}
// Only run once on mount - using ref to track execution
}, []);
return <Navigate to="/" replace />;
}
);
const AppRouting: FC = memo(() => {
const { LL } = useI18nContext();
return (
<Authentication>
<Routes>
<Route
path="/unauthorized"
element={<RootRedirect message={LL.PLEASE_SIGNIN()} signOut />}
/>
<Route
path="/fileUpdated"
element={<RootRedirect message={LL.UPLOAD_SUCCESSFUL()} />}
/>
<Route
path="/"
element={
<RequireUnauthenticated>
<SignIn />
</RequireUnauthenticated>
}
/>
<Route
path="/*"
element={
<RequireAuthenticated>
<AuthenticatedRouting />
</RequireAuthenticated>
}
/>
</Routes>
</Authentication>
);
});
export default AppRouting;
-79
View File
@@ -1,79 +0,0 @@
import { memo, useContext } from 'react';
import { Navigate, Route, Routes } from 'react-router';
import CustomEntities from 'app/main/CustomEntities';
import Customizations from 'app/main/Customizations';
import Dashboard from 'app/main/Dashboard';
import Devices from 'app/main/Devices';
import Help from 'app/main/Help';
import Modules from 'app/main/Modules';
import Scheduler from 'app/main/Scheduler';
import Sensors from 'app/main/Sensors';
import UserProfile from 'app/main/UserProfile';
import APSettings from 'app/settings/APSettings';
import ApplicationSettings from 'app/settings/ApplicationSettings';
import DownloadUpload from 'app/settings/DownloadUpload';
import MqttSettings from 'app/settings/MqttSettings';
import NTPSettings from 'app/settings/NTPSettings';
import Settings from 'app/settings/Settings';
import Network from 'app/settings/network/Network';
import Security from 'app/settings/security/Security';
import APStatus from 'app/status/APStatus';
import Activity from 'app/status/Activity';
import HardwareStatus from 'app/status/HardwareStatus';
import MqttStatus from 'app/status/MqttStatus';
import NTPStatus from 'app/status/NTPStatus';
import NetworkStatus from 'app/status/NetworkStatus';
import Status from 'app/status/Status';
import SystemLog from 'app/status/SystemLog';
import Version from 'app/status/Version';
import { Layout } from 'components';
import { AuthenticatedContext } from 'contexts/authentication';
const AuthenticatedRouting = memo(() => {
const { me } = useContext(AuthenticatedContext);
return (
<Layout>
<Routes>
<Route path="/dashboard/*" element={<Dashboard />} />
<Route path="/devices/*" element={<Devices />} />
<Route path="/sensors/*" element={<Sensors />} />
<Route path="/help/*" element={<Help />} />
<Route path="/user/*" element={<UserProfile />} />
<Route path="/status/*" element={<Status />} />
<Route path="/status/hardwarestatus/*" element={<HardwareStatus />} />
<Route path="/status/activity" element={<Activity />} />
<Route path="/status/log" element={<SystemLog />} />
<Route path="/status/mqtt" element={<MqttStatus />} />
<Route path="/status/ntp" element={<NTPStatus />} />
<Route path="/status/ap" element={<APStatus />} />
<Route path="/status/network" element={<NetworkStatus />} />
<Route path="/status/version" element={<Version />} />
{me.admin && (
<>
<Route path="/settings" element={<Settings />} />
<Route path="/settings/application" element={<ApplicationSettings />} />
<Route path="/settings/mqtt" element={<MqttSettings />} />
<Route path="/settings/ntp" element={<NTPSettings />} />
<Route path="/settings/ap" element={<APSettings />} />
<Route path="/settings/modules" element={<Modules />} />
<Route path="/settings/downloadUpload" element={<DownloadUpload />} />
<Route path="/settings/network/*" element={<Network />} />
<Route path="/settings/security/*" element={<Security />} />
<Route path="/customizations" element={<Customizations />} />
<Route path="/scheduler" element={<Scheduler />} />
<Route path="/customentities" element={<CustomEntities />} />
</>
)}
<Route path="/*" element={<Navigate to="/" />} />
</Routes>
</Layout>
);
});
export default AuthenticatedRouting;
+12 -17
View File
@@ -1,5 +1,4 @@
import { memo, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { toast } from 'react-toastify';
import { memo, useContext, useEffect, useState } from 'react';
import ForwardIcon from '@mui/icons-material/Forward';
import { Box, Button, Paper, Typography } from '@mui/material';
@@ -7,18 +6,19 @@ import type { Theme } from '@mui/material/styles';
import * as AuthenticationApi from 'components/routing/authentication';
import { useRequest } from 'alova/client';
import type { ValidateFieldsError } from 'async-validator';
import {
LanguageSelector,
ValidatedPasswordField,
ValidatedTextField
} from 'components';
import { toast } from 'components/toast';
import { AuthenticationContext } from 'contexts/authentication';
import { PROJECT_NAME } from 'env';
import { useI18nContext } from 'i18n/i18n-react';
import type { SignInRequest } from 'types';
import { onEnterCallback, updateValue } from 'utils';
import { SIGN_IN_REQUEST_VALIDATOR, ValidationError, validate } from 'validators';
import type { ValidateFieldsError } from 'validators/schema';
const SignIn = memo(() => {
const authenticationContext = useContext(AuthenticationContext);
@@ -43,18 +43,13 @@ const SignIn = memo(() => {
}
});
// Memoize callback to prevent recreation on every render
const updateLoginRequestValue = useMemo(
() =>
updateValue((updater) =>
setSignInRequest(
updater as unknown as (prevState: SignInRequest) => SignInRequest
)
),
[]
const updateLoginRequestValue = updateValue((updater) =>
setSignInRequest(
updater as unknown as (prevState: SignInRequest) => SignInRequest
)
);
const signIn = useCallback(async () => {
const signIn = async () => {
await callSignIn(signInRequest).catch((event: Error) => {
if (event.message === 'Unauthorized') {
toast.warning(LL.INVALID_LOGIN());
@@ -63,9 +58,9 @@ const SignIn = memo(() => {
}
setProcessing(false);
});
}, [callSignIn, signInRequest, LL]);
};
const validateAndSignIn = useCallback(async () => {
const validateAndSignIn = async () => {
setProcessing(true);
SIGN_IN_REQUEST_VALIDATOR.messages({
required: LL.IS_REQUIRED('%s')
@@ -77,9 +72,9 @@ const SignIn = memo(() => {
setFieldErrors((error as ValidationError).fieldErrors);
setProcessing(false);
}
}, [signInRequest, signIn, LL]);
};
const submitOnEnter = useMemo(() => onEnterCallback(signIn), [signIn]);
const submitOnEnter = onEnterCallback(signIn);
// get rid of scrollbar
useEffect(() => {
+21 -2
View File
@@ -4,6 +4,8 @@ import type {
APIcall,
Action,
Activity,
CommandItem,
Commands,
CoreData,
DashboardData,
DeviceData,
@@ -102,8 +104,7 @@ export const readSchedule = () =>
o_deleted: si.deleted,
o_flags: si.flags,
o_time: si.time,
o_cmd: si.cmd,
o_value: si.value,
o_cmd_name: si.cmd_name,
o_name: si.name
}));
}
@@ -111,6 +112,24 @@ export const readSchedule = () =>
export const writeSchedule = (data: Schedule) =>
alovaInstance.Post('/rest/schedule', data);
// Commands
export const readCommands = () =>
alovaInstance.Get<CommandItem[]>('/rest/commands', {
// @ts-expect-error - exactOptionalPropertyTypes compatibility issue
transform(data) {
const commands = (data as Commands).commands;
return commands.map((ci) => ({
...ci,
o_id: ci.id,
o_cmd: ci.cmd,
o_value: ci.value,
o_name: ci.name
}));
}
});
export const writeCommands = (data: Commands) =>
alovaInstance.Post('/rest/commands', data);
// Modules
export const readModules = () =>
alovaInstance.Get<ModuleItem[]>('/rest/modules', {
-9
View File
@@ -57,12 +57,3 @@ export const alovaInstance = createAlova({
onSuccess: handleResponse
}
});
export const alovaInstanceGH = createAlova({
baseURL:
process.env.NODE_ENV === 'development'
? '/gh'
: 'https://api.github.com/repos/emsesp/EMS-ESP32/releases',
statesHook: ReactHook,
requestAdapter: xhrRequestAdapter()
});
+4 -26
View File
@@ -1,8 +1,8 @@
import type { LogSettings, SystemStatus } from 'types';
import { alovaInstance, alovaInstanceGH } from './endpoints';
import { alovaInstance } from './endpoints';
// systemStatus - also used to ping in System Monitor for pinging
// systemStatus - also used in System Monitor to ping the ESP
export const readSystemStatus = () =>
alovaInstance.Get<SystemStatus>('/rest/systemStatus');
@@ -13,35 +13,13 @@ export const updateLogSettings = (data: LogSettings) =>
alovaInstance.Post('/rest/logSettings', data);
export const fetchLogES = () => alovaInstance.Get('/es/log');
// Get versions from GitHub
// cache for 10 minutes to stop getting the IP blocked by GitHub
export const getStableVersion = () =>
alovaInstanceGH.Get('latest', {
cacheFor: 60 * 10 * 1000,
transform(response: { data: { name: string; published_at: string } }) {
return {
name: response.data.name.substring(1),
published_at: response.data.published_at
};
}
});
export const getDevVersion = () =>
alovaInstanceGH.Get('tags/latest', {
cacheFor: 60 * 10 * 1000,
transform(response: { data: { name: string; published_at: string } }) {
return {
name: response.data.name.split(/\s+/).splice(-1)[0]?.substring(1) || '',
published_at: response.data.published_at
};
}
});
const UPLOAD_TIMEOUT = 60000; // 1 minute
export const uploadFile = (file: File) => {
const formData = new FormData();
formData.append('file', file);
return alovaInstance.Post('/rest/uploadFile', formData, {
timeout: UPLOAD_TIMEOUT
timeout: UPLOAD_TIMEOUT,
enableUpload: true
});
};
+284
View File
@@ -0,0 +1,284 @@
import { useState } from 'react';
import { useBlocker } from 'react-router';
import AddIcon from '@mui/icons-material/Add';
import CancelIcon from '@mui/icons-material/Cancel';
import WarningIcon from '@mui/icons-material/Warning';
import { Box, Button, Typography } from '@mui/material';
import {
Body,
Cell,
Header,
HeaderCell,
HeaderRow,
Row,
Table
} from '@table-library/react-table-library/table';
import { useTheme } from '@table-library/react-table-library/theme';
import { updateState, useRequest } from 'alova/client';
import {
BlockNavigation,
ButtonRow,
FormLoader,
SectionContent,
useLayoutTitle
} from 'components';
import { toast } from 'components/toast';
import { useI18nContext } from 'i18n/i18n-react';
import { useInterval } from 'utils';
import { readCommands, writeCommands } from '../../api/app';
import CommandsDialog from './CommandsDialog';
import type { CommandItem, Commands as CommandsType } from './types';
import { commandItemValidation } from './validators';
const INTERVAL_DELAY = 30000;
const MIN_ID = -100;
const MAX_ID = 100;
const DEFAULT_COMMAND_ITEM: Omit<CommandItem, 'id'> = {
cmd: '',
value: '',
name: '',
deleted: false
};
const commandsTheme = {
Table: `
--data-table-library_grid-template-columns: repeat(1, minmax(100px, 1fr)) repeat(1, minmax(100px, 1fr)) 160px;
`,
BaseRow: `
font-size: 14px;
.td {
height: 32px;
}
`,
BaseCell: `
&:nth-of-type(1) {
padding: 8px;
}
`,
HeaderRow: `
text-transform: uppercase;
background-color: black;
color: #90CAF9;
.th {
border-bottom: 1px solid #565656;
height: 36px;
}
`,
Row: `
background-color: #1e1e1e;
position: relative;
cursor: pointer;
.td {
border-bottom: 1px solid #565656;
}
&:hover .td {
background-color: #177ac9;
}
`
};
const Commands = () => {
const { LL } = useI18nContext();
const [numChanges, setNumChanges] = useState<number>(0);
const blocker = useBlocker(numChanges !== 0);
const [selectedItem, setSelectedItem] = useState<CommandItem>();
const [creating, setCreating] = useState<boolean>(false);
const [dialogOpen, setDialogOpen] = useState<boolean>(false);
useLayoutTitle(LL.COMMANDS());
const {
data: commands,
send: fetchCommands,
error
} = useRequest(readCommands, {
initialData: []
});
const { send: updateCommands } = useRequest(
(data: CommandsType) => writeCommands(data),
{ immediate: false }
);
const hasChanged = (ci: CommandItem) =>
ci.id !== ci.o_id ||
(ci.name || '') !== (ci.o_name || '') ||
ci.cmd !== ci.o_cmd ||
ci.value !== ci.o_value ||
ci.deleted !== ci.o_deleted;
useInterval(() => {
if (numChanges === 0) {
void fetchCommands();
}
}, INTERVAL_DELAY);
const theme = useTheme(commandsTheme);
const saveCommands = async () => {
try {
await updateCommands({
commands: commands
.filter((ci: CommandItem) => !ci.deleted)
.map((ci: CommandItem) => ({
id: ci.id,
cmd: ci.cmd,
value: ci.value,
name: ci.name
}))
});
toast.success(LL.UPDATED_OF(LL.COMMANDS()));
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
toast.error(message);
} finally {
await fetchCommands();
setNumChanges(0);
}
};
const editItem = (ci: CommandItem) => {
setCreating(false);
setSelectedItem(ci);
setDialogOpen(true);
if (ci.o_name === undefined) {
ci.o_name = ci.name;
}
};
const onDialogClose = () => {
setDialogOpen(false);
};
const onDialogCancel = async () => {
await fetchCommands().then(() => {
setNumChanges(0);
});
};
const onDialogSave = (updatedItem: CommandItem) => {
setDialogOpen(false);
void updateState(readCommands(), (data: CommandItem[]) => {
const new_data = creating
? [...data, updatedItem]
: data.map((ci) =>
ci.id === updatedItem.id ? { ...ci, ...updatedItem } : ci
);
setNumChanges(new_data.filter((ci) => hasChanged(ci)).length);
return new_data;
});
};
const addItem = () => {
setCreating(true);
const newItem: CommandItem = {
id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID),
...DEFAULT_COMMAND_ITEM
};
setSelectedItem(newItem);
setDialogOpen(true);
};
const filteredCommands = commands.filter((ci: CommandItem) => !ci.deleted);
const renderCommands = () => {
if (!commands) {
return (
<FormLoader onRetry={fetchCommands} errorMessage={error?.message || ''} />
);
}
return (
<Table
data={{ nodes: filteredCommands }}
theme={theme}
layout={{ custom: true }}
>
{(tableList: CommandItem[]) => (
<>
<Header>
<HeaderRow>
<HeaderCell stiff>{LL.COMMAND(0)}</HeaderCell>
<HeaderCell stiff>{LL.VALUE(0)}</HeaderCell>
<HeaderCell stiff>{LL.NAME(0)}</HeaderCell>
</HeaderRow>
</Header>
<Body>
{tableList.map((ci: CommandItem) => (
<Row key={ci.id} item={ci} onClick={() => editItem(ci)}>
<Cell>{ci.cmd}</Cell>
<Cell>{ci.value}</Cell>
<Cell>{ci.name}</Cell>
</Row>
))}
</Body>
</>
)}
</Table>
);
};
return (
<SectionContent>
{blocker ? <BlockNavigation blocker={blocker} /> : null}
<Typography sx={{ mb: 2 }} color="warning" variant="body1">
{LL.COMMANDS_HELP_1()}.
</Typography>
{renderCommands()}
{selectedItem && (
<CommandsDialog
open={dialogOpen}
creating={creating}
onClose={onDialogClose}
onSave={onDialogSave}
selectedItem={selectedItem}
validator={commandItemValidation(commands, selectedItem)}
/>
)}
<Box sx={{ display: 'flex', flexWrap: 'wrap' }}>
<Box sx={{ flexGrow: 1 }}>
{numChanges !== 0 && (
<ButtonRow>
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={onDialogCancel}
color="secondary"
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<WarningIcon color="warning" />}
variant="contained"
color="info"
onClick={saveCommands}
>
{LL.APPLY_CHANGES(numChanges)}
</Button>
</ButtonRow>
)}
</Box>
<Box sx={{ flexWrap: 'nowrap', whiteSpace: 'nowrap' }}>
<ButtonRow>
<Button
startIcon={<AddIcon />}
variant="outlined"
color="primary"
onClick={addItem}
>
{LL.ADD(0)}
</Button>
</ButtonRow>
</Box>
</Box>
</SectionContent>
);
};
export default Commands;
+206
View File
@@ -0,0 +1,206 @@
import { useEffect, useState } from 'react';
import AddIcon from '@mui/icons-material/Add';
import CancelIcon from '@mui/icons-material/Cancel';
import DoneIcon from '@mui/icons-material/Done';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import RemoveIcon from '@mui/icons-material/RemoveCircleOutlined';
import {
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
TextField
} from '@mui/material';
import { callAction } from '@/api/app';
import { dialogStyle } from 'CustomTheme';
import { useRequest } from 'alova/client';
import { ValidatedTextField } from 'components';
import { toast } from 'components/toast';
import { useI18nContext } from 'i18n/i18n-react';
import { updateValue } from 'utils';
import { ValidationError, validate } from 'validators';
import type Schema from 'validators/schema';
import type { ValidateFieldsError } from 'validators/schema';
import type { CommandItem } from './types';
interface CommandsDialogProps {
open: boolean;
creating: boolean;
onClose: () => void;
onSave: (ci: CommandItem) => void;
selectedItem: CommandItem;
validator: Schema;
}
const CommandsDialog = ({
open,
creating,
onClose,
onSave,
selectedItem,
validator
}: CommandsDialogProps) => {
const { LL } = useI18nContext();
const [hasChanges, setHasChanges] = useState<boolean>(false);
const [editItem, setEditItem] = useState<CommandItem>(selectedItem);
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const updateFormValue = updateValue(
setEditItem as unknown as React.Dispatch<
React.SetStateAction<Record<string, unknown>>
>
);
useEffect(() => {
if (open) {
setFieldErrors(undefined);
setEditItem(selectedItem);
}
}, [open, selectedItem]);
const hasChanged = (ci: CommandItem) =>
ci.id !== ci.o_id ||
(ci.name || '') !== (ci.o_name || '') ||
ci.cmd !== ci.o_cmd ||
ci.value !== ci.o_value ||
ci.deleted !== ci.o_deleted;
useEffect(() => {
setHasChanges(hasChanged(editItem));
}, [editItem]);
const handleSave = async (itemToSave: CommandItem) => {
try {
setFieldErrors(undefined);
await validate(validator, itemToSave);
onSave(itemToSave);
} catch (error) {
setFieldErrors((error as ValidationError).fieldErrors);
} finally {
// setHasChanges(false);
}
};
const save = async () => {
await handleSave(editItem);
};
const { send: executeCommand } = useRequest(
(id: string) => callAction({ action: 'executeCommand', param: id }),
{ immediate: false }
)
.onSuccess(() => {
toast.success(LL.COMMAND_EXECUTED());
})
.onError((error) => {
toast.error(String(error.error?.message || 'An error occurred'));
});
const execute = async () => {
await executeCommand(editItem.name);
};
const remove = () => {
onSave({ ...editItem, deleted: true });
};
const handleClose = (
_event: React.SyntheticEvent,
reason: 'backdropClick' | 'escapeKeyDown'
) => {
if (reason !== 'backdropClick') {
onClose();
}
};
return (
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
<DialogTitle>
{creating ? `${LL.ADD(1)} ` : LL.UPDATE() + '/' + LL.RUN()}&nbsp;
{LL.COMMAND(0)}
</DialogTitle>
<DialogContent dividers>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
name="cmd"
label={LL.COMMAND(0)}
multiline
fullWidth
value={editItem.cmd}
margin="normal"
onChange={updateFormValue}
/>
<TextField
name="value"
label={LL.VALUE(0)}
multiline
margin="normal"
fullWidth
value={editItem.value}
onChange={updateFormValue}
/>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
name="name"
label={LL.NAME(0)}
value={editItem.name}
fullWidth
margin="normal"
onChange={updateFormValue}
/>
</DialogContent>
<DialogActions>
{!creating && (
<Box sx={{ flexGrow: 1 }}>
<Button
startIcon={<RemoveIcon />}
variant="outlined"
color="warning"
onClick={remove}
>
{LL.REMOVE()}
</Button>
</Box>
)}
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={onClose}
color="secondary"
>
{LL.CANCEL()}
</Button>
{hasChanges && (
<Button
startIcon={creating ? <AddIcon /> : <DoneIcon />}
variant="outlined"
onClick={save}
color="primary"
>
{creating ? LL.ADD(0) : LL.UPDATE()}
</Button>
)}
{!creating && !hasChanges && editItem.cmd !== '' && (
<Button
startIcon={<PlayArrowIcon />}
variant="outlined"
onClick={execute}
color="success"
>
{LL.RUN()}
</Button>
)}
</DialogActions>
</Dialog>
);
};
export default CommandsDialog;
+50 -72
View File
@@ -1,6 +1,5 @@
import { useCallback, useMemo, useState } from 'react';
import { useState } from 'react';
import { useBlocker } from 'react-router';
import { toast } from 'react-toastify';
import AddIcon from '@mui/icons-material/Add';
import CancelIcon from '@mui/icons-material/Cancel';
@@ -26,6 +25,7 @@ import {
SectionContent,
useLayoutTitle
} from 'components';
import { toast } from 'components/toast';
import { useI18nContext } from 'i18n/i18n-react';
import { useInterval } from 'utils';
@@ -57,20 +57,18 @@ const CustomEntities = () => {
initialData: []
});
const intervalCallback = useCallback(() => {
useInterval(() => {
if (!dialogOpen && !numChanges) {
void fetchEntities();
}
}, [dialogOpen, numChanges, fetchEntities]);
useInterval(intervalCallback);
});
const { send: writeEntities } = useRequest(
(data: Entities) => writeCustomEntities(data),
{ immediate: false }
);
const hasEntityChanged = useCallback((ei: EntityItem) => {
const hasEntityChanged = (ei: EntityItem) => {
return (
ei.id !== ei.o_id ||
ei.ram !== ei.o_ram ||
@@ -86,21 +84,19 @@ const CustomEntities = () => {
ei.deleted !== ei.o_deleted ||
(ei.value || '') !== (ei.o_value || '')
);
}, []);
};
const entity_theme = useMemo(
() =>
useTheme({
Table: `
const entity_theme = useTheme({
Table: `
--data-table-library_grid-template-columns: repeat(1, minmax(60px, 1fr)) minmax(80px, auto) 80px 80px 80px 120px;
`,
BaseRow: `
BaseRow: `
font-size: 14px;
.td {
height: 32px;
}
`,
BaseCell: `
BaseCell: `
&:nth-of-type(1) {
padding: 8px;
}
@@ -120,7 +116,7 @@ const CustomEntities = () => {
text-align: center;
}
`,
HeaderRow: `
HeaderRow: `
text-transform: uppercase;
background-color: black;
color: #90CAF9;
@@ -129,7 +125,7 @@ const CustomEntities = () => {
height: 36px;
}
`,
Row: `
Row: `
background-color: #1e1e1e;
position: relative;
cursor: pointer;
@@ -140,11 +136,9 @@ const CustomEntities = () => {
background-color: #177ac9;
}
`
}),
[]
);
});
const saveEntities = useCallback(async () => {
const saveEntities = async () => {
await writeEntities({
entities: entities
.filter((ei: EntityItem) => !ei.deleted)
@@ -173,44 +167,41 @@ const CustomEntities = () => {
await fetchEntities();
setNumChanges(0);
});
}, [entities, writeEntities, LL, fetchEntities]);
};
const editEntityItem = useCallback((ei: EntityItem) => {
const editEntityItem = (ei: EntityItem) => {
setCreating(false);
setSelectedEntityItem(ei);
setDialogOpen(true);
}, []);
};
const onDialogClose = useCallback(() => {
const onDialogClose = () => {
setDialogOpen(false);
}, []);
};
const onDialogCancel = useCallback(async () => {
const onDialogCancel = async () => {
await fetchEntities().then(() => {
setNumChanges(0);
});
}, [fetchEntities]);
};
const onDialogSave = useCallback(
(updatedItem: EntityItem) => {
setDialogOpen(false);
void updateState(readCustomEntities(), (data: EntityItem[]) => {
const new_data = creating
? [
...data.filter((ei) => creating || ei.o_id !== updatedItem.o_id),
updatedItem
]
: data.map((ei) =>
ei.id === updatedItem.id ? { ...ei, ...updatedItem } : ei
);
setNumChanges(new_data.filter((ei) => hasEntityChanged(ei)).length);
return new_data;
});
},
[creating, hasEntityChanged]
);
const onDialogSave = (updatedItem: EntityItem) => {
setDialogOpen(false);
void updateState(readCustomEntities(), (data: EntityItem[]) => {
const new_data = creating
? [
...data.filter((ei) => creating || ei.o_id !== updatedItem.o_id),
updatedItem
]
: data.map((ei) =>
ei.id === updatedItem.id ? { ...ei, ...updatedItem } : ei
);
setNumChanges(new_data.filter((ei) => hasEntityChanged(ei)).length);
return new_data;
});
};
const onDialogDup = useCallback((item: EntityItem) => {
const onDialogDup = (item: EntityItem) => {
setCreating(true);
setSelectedEntityItem({
id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID),
@@ -228,9 +219,9 @@ const CustomEntities = () => {
value: item.value
});
setDialogOpen(true);
}, []);
};
const addEntityItem = useCallback(() => {
const addEntityItem = () => {
setCreating(true);
setSelectedEntityItem({
id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID),
@@ -248,30 +239,27 @@ const CustomEntities = () => {
value: ''
});
setDialogOpen(true);
}, []);
};
const formatValue = useCallback((value: unknown, uom: number) => {
const formatValue = (value: unknown, uom: number) => {
return value === undefined
? ''
: typeof value === 'number'
? new Intl.NumberFormat().format(value) +
(uom === 0 ? '' : ` ${DeviceValueUOM_s[uom]}`)
: `${value as string}${uom === 0 ? '' : ` ${DeviceValueUOM_s[uom]}`}`;
}, []);
};
const showHex = useCallback((value: number, digit: number) => {
const showHex = (value: number, digit: number) => {
return `0x${value.toString(16).toUpperCase().padStart(digit, '0')}`;
}, []);
};
const filteredAndSortedEntities = useMemo(
() =>
entities
?.filter((ei: EntityItem) => !ei.deleted)
.sort((a: EntityItem, b: EntityItem) => a.name.localeCompare(b.name)) ?? [],
[entities]
);
const filteredAndSortedEntities =
entities
?.filter((ei: EntityItem) => !ei.deleted)
.sort((a: EntityItem, b: EntityItem) => a.name.localeCompare(b.name)) ?? [];
const renderEntity = useCallback(() => {
const renderEntity = () => {
if (!entities) {
return (
<FormLoader onRetry={fetchEntities} errorMessage={error?.message || ''} />
@@ -328,17 +316,7 @@ const CustomEntities = () => {
)}
</Table>
);
}, [
entities,
error,
fetchEntities,
entity_theme,
editEntityItem,
LL,
filteredAndSortedEntities,
showHex,
formatValue
]);
};
return (
<SectionContent>
+27 -37
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useEffect, useState } from 'react';
import AddIcon from '@mui/icons-material/Add';
import CancelIcon from '@mui/icons-material/Cancel';
@@ -23,12 +23,12 @@ import {
} from '@mui/material';
import { dialogStyle } from 'CustomTheme';
import type Schema from 'async-validator';
import type { ValidateFieldsError } from 'async-validator';
import { BlockFormControlLabel, ValidatedTextField } from 'components';
import { useI18nContext } from 'i18n/i18n-react';
import { numberValue, updateValue } from 'utils';
import { ValidationError, validate } from 'validators';
import type Schema from 'validators/schema';
import type { ValidateFieldsError } from 'validators/schema';
import { DeviceValueType, DeviceValueTypeNames, DeviceValueUOM_s } from './types';
import type { EntityItem } from './types';
@@ -68,14 +68,10 @@ const CustomEntitiesDialog = ({
const { LL } = useI18nContext();
const [editItem, setEditItem] = useState<EntityItem>(selectedItem);
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const updateFormValue = useMemo(
() =>
updateValue(
setEditItem as unknown as React.Dispatch<
React.SetStateAction<Record<string, unknown>>
>
),
[]
const updateFormValue = updateValue(
setEditItem as unknown as React.Dispatch<
React.SetStateAction<Record<string, unknown>>
>
);
useEffect(() => {
@@ -105,16 +101,16 @@ const CustomEntitiesDialog = ({
}
}, [open, selectedItem]);
const handleClose = useCallback(
(_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => {
if (reason !== 'backdropClick') {
onClose();
}
},
[onClose]
);
const handleClose = (
_event: React.SyntheticEvent,
reason: 'backdropClick' | 'escapeKeyDown'
) => {
if (reason !== 'backdropClick') {
onClose();
}
};
const save = useCallback(async () => {
const save = async () => {
try {
setFieldErrors(undefined);
await validate(validator, editItem);
@@ -138,27 +134,21 @@ const CustomEntitiesDialog = ({
} catch (error) {
setFieldErrors((error as ValidationError).fieldErrors);
}
}, [validator, editItem, onSave]);
};
const remove = useCallback(() => {
const itemWithDeleted = { ...editItem, deleted: true };
onSave(itemWithDeleted);
}, [editItem, onSave]);
const remove = () => {
onSave({ ...editItem, deleted: true });
};
const dup = useCallback(() => {
const dup = () => {
onDup(editItem);
}, [editItem, onDup]);
};
// Memoize UOM menu items to avoid recreating on every render
const uomMenuItems = useMemo(
() =>
DeviceValueUOM_s.map((val, i) => (
<MenuItem key={val} value={i}>
{val}
</MenuItem>
)),
[]
);
const uomMenuItems = DeviceValueUOM_s.map((val, i) => (
<MenuItem key={val} value={i}>
{val}
</MenuItem>
));
return (
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
+60 -85
View File
@@ -1,6 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useEffect, useState } from 'react';
import { useBlocker, useLocation } from 'react-router';
import { toast } from 'react-toastify';
import CancelIcon from '@mui/icons-material/Cancel';
import EditIcon from '@mui/icons-material/Edit';
@@ -46,6 +45,7 @@ import {
SectionContent,
useLayoutTitle
} from 'components';
import { toast } from 'components/toast';
import { useI18nContext } from 'i18n/i18n-react';
import {
@@ -171,19 +171,17 @@ const Customizations = () => {
);
};
const entities_theme = useMemo(
() =>
useTheme({
Table: `
const entities_theme = useTheme({
Table: `
--data-table-library_grid-template-columns: 156px repeat(1, minmax(80px, 1fr)) 45px minmax(45px, auto) minmax(120px, auto);
`,
BaseRow: `
BaseRow: `
font-size: 14px;
.td {
height: 32px;
}
`,
BaseCell: `
BaseCell: `
&:nth-of-type(3) {
text-align: right;
}
@@ -194,7 +192,7 @@ const Customizations = () => {
text-align: right;
}
`,
HeaderRow: `
HeaderRow: `
text-transform: uppercase;
background-color: black;
color: #90CAF9;
@@ -206,7 +204,7 @@ const Customizations = () => {
text-align: center;
}
`,
Row: `
Row: `
background-color: #1e1e1e;
position: relative;
cursor: pointer;
@@ -222,7 +220,7 @@ const Customizations = () => {
background-color: #177ac9;
}
`,
Cell: `
Cell: `
&:nth-of-type(2) {
padding: 8px;
}
@@ -236,9 +234,7 @@ const Customizations = () => {
padding-right: 8px;
}
`
}),
[]
);
});
function hasEntityChanged(de: DeviceEntity) {
return (
@@ -287,26 +283,23 @@ const Customizations = () => {
return value as string;
}
const isCommand = useCallback((de: DeviceEntity) => {
const isCommand = (de: DeviceEntity) => {
return de.n && de.n[0] === '!';
}, []);
};
const formatName = useCallback(
(de: DeviceEntity, withShortname: boolean) => {
let name: string;
if (isCommand(de)) {
name = de.t
? `${LL.COMMAND(1)}: ${de.t} ${de.n?.slice(1)}`
: `${LL.COMMAND(1)}: ${de.n?.slice(1)}`;
} else if (de.cn && de.cn !== '') {
name = de.t ? `${de.t} ${de.cn}` : de.cn;
} else {
name = de.t ? `${de.t} ${de.n}` : de.n || '';
}
return withShortname ? `${name} ${de.id}` : name;
},
[LL]
);
const formatName = (de: DeviceEntity, withShortname: boolean) => {
let name: string;
if (isCommand(de)) {
name = de.t
? `${LL.COMMAND(1)}: ${de.t} ${de.n?.slice(1)}`
: `${LL.COMMAND(1)}: ${de.n?.slice(1)}`;
} else if (de.cn && de.cn !== '') {
name = de.t ? `${de.t} ${de.cn}` : de.cn;
} else {
name = de.t ? `${de.t} ${de.n}` : de.n || '';
}
return withShortname ? `${name} ${de.id}` : name;
};
const getMaskNumber = (newMask: string[]) => {
let new_mask = 0;
@@ -336,33 +329,27 @@ const Customizations = () => {
return new_masks;
};
const filter_entity = useCallback(
(de: DeviceEntity) =>
(de.m & selectedFilters || !selectedFilters) &&
formatName(de, true).toLowerCase().includes(search.toLowerCase()),
[selectedFilters, search, formatName]
);
const filter_entity = (de: DeviceEntity) =>
(de.m & selectedFilters || !selectedFilters) &&
formatName(de, true).toLowerCase().includes(search.toLowerCase());
const maskDisabled = useCallback(
(set: boolean) => {
setDeviceEntities((prev) =>
prev.map((de) => {
if (filter_entity(de)) {
const excludeMask =
DeviceEntityMask.DV_API_MQTT_EXCLUDE | DeviceEntityMask.DV_WEB_EXCLUDE;
return {
...de,
m: set ? de.m | excludeMask : de.m & ~excludeMask
};
}
return de;
})
);
},
[filter_entity]
);
const maskDisabled = (set: boolean) => {
setDeviceEntities((prev) =>
prev.map((de) => {
if (filter_entity(de)) {
const excludeMask =
DeviceEntityMask.DV_API_MQTT_EXCLUDE | DeviceEntityMask.DV_WEB_EXCLUDE;
return {
...de,
m: set ? de.m | excludeMask : de.m & ~excludeMask
};
}
return de;
})
);
};
const resetCustomization = useCallback(async () => {
const resetCustomization = async () => {
try {
await sendResetCustomizations();
toast.info(LL.CUSTOMIZATIONS_RESTART());
@@ -372,30 +359,27 @@ const Customizations = () => {
setConfirmReset(false);
setRestarting(true);
}
}, [sendResetCustomizations, LL]);
};
const onDialogClose = () => {
setDialogOpen(false);
};
const updateDeviceEntity = useCallback((updatedItem: DeviceEntity) => {
const updateDeviceEntity = (updatedItem: DeviceEntity) => {
setDeviceEntities(
(prev) =>
prev?.map((de) =>
de.id === updatedItem.id ? { ...de, ...updatedItem } : de
) ?? []
);
}, []);
};
const onDialogSave = useCallback(
(updatedItem: DeviceEntity) => {
setDialogOpen(false);
updateDeviceEntity(updatedItem);
},
[updateDeviceEntity]
);
const onDialogSave = (updatedItem: DeviceEntity) => {
setDialogOpen(false);
updateDeviceEntity(updatedItem);
};
const editDeviceEntity = useCallback((de: DeviceEntity) => {
const editDeviceEntity = (de: DeviceEntity) => {
if (de.n === undefined || (de.n && de.n[0] === '!')) {
return;
}
@@ -406,9 +390,9 @@ const Customizations = () => {
setSelectedDeviceEntity(de);
setDialogOpen(true);
}, []);
};
const saveCustomization = useCallback(async () => {
const saveCustomization = async () => {
if (!devices || !deviceEntities || selectedDevice === -1) {
return;
}
@@ -441,9 +425,9 @@ const Customizations = () => {
.finally(() => {
setOriginalSettings(deviceEntities);
});
}, [devices, deviceEntities, selectedDevice, sendCustomizationEntities, LL]);
};
const renameDevice = useCallback(async () => {
const renameDevice = async () => {
await sendDeviceName({
id: selectedDevice,
name: selectedDeviceName,
@@ -459,14 +443,7 @@ const Customizations = () => {
setRename(false);
await fetchCoreData();
});
}, [
selectedDevice,
selectedDeviceName,
selectedDeviceBrand,
sendDeviceName,
LL,
fetchCoreData
]);
};
const renderDeviceList = () => (
<>
@@ -512,7 +489,8 @@ const Customizations = () => {
</MenuItem>
{devices.devices.map(
(device: Device) =>
device.id < 90 && (
device.id < 90 &&
device.e !== 0 && (
<MenuItem key={device.id} value={device.id}>
{device.n}&nbsp;({device.tn})
</MenuItem>
@@ -562,10 +540,7 @@ const Customizations = () => {
</>
);
const filteredEntities = useMemo(
() => deviceEntities.filter((de) => filter_entity(de)),
[deviceEntities, filter_entity]
);
const filteredEntities = deviceEntities.filter((de) => filter_entity(de));
const renderDeviceData = () => {
return (
+32 -42
View File
@@ -1,4 +1,4 @@
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
import { memo, useEffect, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel';
import CloseIcon from '@mui/icons-material/Close';
@@ -57,23 +57,16 @@ const CustomizationsDialog = ({
const [editItem, setEditItem] = useState<DeviceEntity>(selectedItem);
const [error, setError] = useState<boolean>(false);
const updateFormValue = useMemo(
() =>
updateValue(
setEditItem as unknown as React.Dispatch<
React.SetStateAction<Record<string, unknown>>
>
),
[]
const updateFormValue = updateValue(
setEditItem as unknown as React.Dispatch<
React.SetStateAction<Record<string, unknown>>
>
);
const isWriteableNumber = useMemo(
() =>
typeof editItem.v === 'number' &&
editItem.w &&
!(editItem.m & DeviceEntityMask.DV_READONLY),
[editItem.v, editItem.w, editItem.m]
);
const isWriteableNumber =
typeof editItem.v === 'number' &&
editItem.w &&
!(editItem.m & DeviceEntityMask.DV_READONLY);
useEffect(() => {
if (open) {
@@ -82,16 +75,16 @@ const CustomizationsDialog = ({
}
}, [open, selectedItem]);
const handleClose = useCallback(
(_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => {
if (reason !== 'backdropClick') {
onClose();
}
},
[onClose]
);
const handleClose = (
_event: React.SyntheticEvent,
reason: 'backdropClick' | 'escapeKeyDown'
) => {
if (reason !== 'backdropClick') {
onClose();
}
};
const save = useCallback(() => {
const save = () => {
if (
isWriteableNumber &&
editItem.mi &&
@@ -102,34 +95,31 @@ const CustomizationsDialog = ({
} else {
onSave(editItem);
}
}, [isWriteableNumber, editItem, onSave]);
};
const updateDeviceEntity = useCallback((updatedItem: DeviceEntity) => {
const updateDeviceEntity = (updatedItem: DeviceEntity) => {
setEditItem((prev) => ({ ...prev, m: updatedItem.m }));
}, []);
const dialogTitle = useMemo(() => `${LL.EDIT()} ${LL.ENTITY()}`, [LL]);
const writeableIcon = useMemo(
() =>
editItem.w ? (
<DoneIcon color="success" sx={{ fontSize: ICON_SIZE }} />
) : (
<CloseIcon color="error" sx={{ fontSize: ICON_SIZE }} />
),
[editItem.w]
);
};
return (
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
<DialogTitle>{dialogTitle}</DialogTitle>
<DialogTitle>{`${LL.EDIT()} ${LL.ENTITY()}`}</DialogTitle>
<DialogContent dividers>
<LabelValue label={LL.ID_OF(LL.ENTITY())} value={editItem.id} />
<LabelValue
label={`${LL.DEFAULT(1)} ${LL.ENTITY_NAME(1)}`}
value={editItem.n}
/>
<LabelValue label={LL.WRITEABLE()} value={writeableIcon} />
<LabelValue
label={LL.WRITEABLE()}
value={
editItem.w ? (
<DoneIcon color="success" sx={{ fontSize: ICON_SIZE }} />
) : (
<CloseIcon color="error" sx={{ fontSize: ICON_SIZE }} />
)
}
/>
<Box sx={{ mt: 1, mb: 2 }}>
<EntityMaskToggle onUpdate={updateDeviceEntity} de={editItem} />
+104 -101
View File
@@ -1,12 +1,12 @@
import { memo, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { memo, useContext, useEffect, useState } from 'react';
import { IconContext } from 'react-icons/lib';
import { Link } from 'react-router';
import { toast } from 'react-toastify';
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import EditIcon from '@mui/icons-material/Edit';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import HelpOutlineIcon from '@mui/icons-material/HelpOutlined';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import UnfoldLessIcon from '@mui/icons-material/UnfoldLess';
import UnfoldMoreIcon from '@mui/icons-material/UnfoldMore';
import {
@@ -29,6 +29,7 @@ import {
SectionContent,
useLayoutTitle
} from 'components';
import { toast } from 'components/toast';
import { AuthenticatedContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react';
import { useInterval, usePersistState } from 'utils';
@@ -75,42 +76,38 @@ const Dashboard = memo(() => {
{
immediate: false
}
);
)
.onSuccess(() => {
toast.success(LL.WRITE_CMD_SENT());
})
.onError((error) => {
toast.error(String(error.error?.message || 'An error occurred'));
});
const deviceValueDialogSave = useCallback(
async (devicevalue: DeviceValue) => {
if (!selectedDashboardItem) {
return;
}
const deviceValueDialogSave = async (devicevalue: DeviceValue) => {
if (!selectedDashboardItem) {
return;
}
// skip if we're executing an immediate schedule as this is handled in DevicesDialog::doAction()
if (devicevalue.v !== undefined) {
const id = selectedDashboardItem.parentNode.id; // this is the parent ID
await sendDeviceValue({ id, c: devicevalue.c ?? '', v: devicevalue.v })
.then(() => {
toast.success(LL.WRITE_CMD_SENT());
})
.catch((error: Error) => {
toast.error(error.message);
})
.finally(() => {
setDeviceValueDialogOpen(false);
setSelectedDashboardItem(undefined);
});
},
[selectedDashboardItem, sendDeviceValue, LL]
);
await sendDeviceValue({ id, c: devicevalue.c ?? '', v: devicevalue.v });
}
setDeviceValueDialogOpen(false);
setSelectedDashboardItem(undefined);
};
const dashboard_theme = useMemo(
() =>
useTheme({
Table: `
const dashboard_theme = useTheme({
Table: `
--data-table-library_grid-template-columns: minmax(80px, auto) 120px 32px;
`,
BaseRow: `
BaseRow: `
font-size: 14px;
.td {
height: 28px;
}
`,
Row: `
Row: `
cursor: pointer;
background-color: #1e1e1e;
&:nth-of-type(odd) .td {
@@ -120,7 +117,7 @@ const Dashboard = memo(() => {
background-color: #177ac9;
},
`,
BaseCell: `
BaseCell: `
&:nth-of-type(2) {
text-align: right;
}
@@ -128,9 +125,7 @@ const Dashboard = memo(() => {
text-align: right;
}
`
}),
[]
);
});
const tree = useTree(
{ nodes: [...data.nodes] },
@@ -164,79 +159,71 @@ const Dashboard = memo(() => {
}
});
const nodeIds = useMemo(
() => data.nodes.map((item: DashboardItem) => item.id),
[data.nodes]
);
useEffect(() => {
const nodeIds = data.nodes.map((item: DashboardItem) => item.id);
showAll
? tree.fns.onAddAll(nodeIds) // expand tree
: tree.fns.onRemoveAll(); // collapse tree
}, [parentNodes]);
const showType = useCallback(
(n?: string, t?: number) => {
// if we have a name show it
if (n) {
return n;
const showType = (n?: string, t?: number) => {
// if we have a name show it
if (n) {
return n;
}
if (t) {
// otherwise pick translation based on type
switch (t) {
case DeviceType.CUSTOM:
return LL.CUSTOM_ENTITIES(0);
case DeviceType.ANALOGSENSOR:
return LL.ANALOG_SENSORS();
case DeviceType.TEMPERATURESENSOR:
return LL.TEMP_SENSORS();
case DeviceType.COMMAND:
return LL.COMMANDS();
case DeviceType.SCHEDULER:
return LL.SCHEDULER();
default:
break;
}
if (t) {
// otherwise pick translation based on type
switch (t) {
case DeviceType.CUSTOM:
return LL.CUSTOM_ENTITIES(0);
case DeviceType.ANALOGSENSOR:
return LL.ANALOG_SENSORS();
case DeviceType.TEMPERATURESENSOR:
return LL.TEMP_SENSORS();
case DeviceType.SCHEDULER:
return LL.SCHEDULER();
default:
break;
}
}
return '';
},
[LL]
);
}
return '';
};
const showName = useCallback(
(di: DashboardItem) => {
if (di.id < 100) {
// if its a device (parent node) and has entities
if (di.nodes?.length) {
return (
<span style={{ fontSize: '15px' }}>
<DeviceIcon type_id={di.t ?? 0} />
&nbsp;&nbsp;{showType(di.n, di.t)}
<span style={{ color: 'lightblue' }}>&nbsp;({di.nodes?.length})</span>
</span>
);
}
const showName = (di: DashboardItem) => {
if (di.id < 100) {
// if its a device (parent node) and has entities
if (di.nodes?.length) {
return (
<span style={{ fontSize: '15px' }}>
<DeviceIcon type_id={di.t ?? 0} />
&nbsp;&nbsp;{showType(di.n, di.t)}
<span style={{ color: 'lightblue' }}>&nbsp;({di.nodes?.length})</span>
</span>
);
}
if (di.dv) {
return <span>{di.dv.id.slice(2)}</span>;
}
return null;
},
[showType]
);
}
if (di.dv) {
return <span>{di.dv.id.slice(2)}</span>;
}
return null;
};
const hasMask = useCallback(
(id: string, mask: number) => (parseInt(id.slice(0, 2), 16) & mask) === mask,
[]
);
const hasMask = (id: string, mask: number) =>
(parseInt(id.slice(0, 2), 16) & mask) === mask;
const editDashboardValue = useCallback(
(di: DashboardItem) => {
if (me.admin && di.dv?.c) {
setSelectedDashboardItem(di);
setDeviceValueDialogOpen(true);
}
},
[me.admin]
);
const editDashboardValue = (di: DashboardItem) => {
// don't execute on parent nodes
if (!me.admin || di.id <= 99) {
return;
}
if (di.dv?.c) {
setSelectedDashboardItem(di);
setDeviceValueDialogOpen(true);
}
};
const handleShowAll = (
_event: React.MouseEvent<HTMLElement>,
@@ -248,10 +235,9 @@ const Dashboard = memo(() => {
}
};
const hasFavEntities = useMemo(
() => data.nodes.filter((item: DashboardItem) => item.id <= 90).length,
[data.nodes]
);
const hasFavEntities = data.nodes.filter(
(item: DashboardItem) => item.id <= 90
).length;
const renderContent = () => {
if (!data) {
@@ -344,20 +330,34 @@ const Dashboard = memo(() => {
<Cell>
{me.admin &&
di.dv?.c &&
!hasMask(di.dv.id, DeviceEntityMask.DV_READONLY) && (
!hasMask(di.dv.id, DeviceEntityMask.DV_READONLY) &&
(di.dv.v === '' || di.dv.v === undefined ? (
<IconButton
size="small"
aria-label={LL.RUN()}
onClick={() => editDashboardValue(di)}
style={{ backgroundColor: 'transparent' }}
>
<PlayArrowIcon
color="primary"
sx={{ fontSize: 16 }}
/>
</IconButton>
) : (
<IconButton
size="small"
aria-label={
LL.CHANGE_VALUE() + ' ' + LL.VALUE(0)
}
onClick={() => editDashboardValue(di)}
style={{ backgroundColor: 'transparent' }}
>
<EditIcon
color="primary"
sx={{ fontSize: 16 }}
/>
</IconButton>
)}
))}
</Cell>
</>
) : (
@@ -402,7 +402,10 @@ const Dashboard = memo(() => {
{selectedDashboardItem && selectedDashboardItem.dv && (
<DevicesDialog
open={deviceValueDialogOpen}
onClose={() => setDeviceValueDialogOpen(false)}
onClose={() => {
setDeviceValueDialogOpen(false);
setSelectedDashboardItem(undefined);
}}
onSave={deviceValueDialogSave}
selectedItem={selectedDashboardItem.dv}
writeable={true}
+1
View File
@@ -38,6 +38,7 @@ const deviceIconLookup: Record<DeviceType, IconType | null> = {
[DeviceType.CUSTOM]: MdPlaylistAdd,
[DeviceType.UNKNOWN]: MdOutlineSensors,
[DeviceType.SYSTEM]: null,
[DeviceType.COMMAND]: MdPlaylistAdd,
[DeviceType.SCHEDULER]: MdMoreTime,
[DeviceType.GENERIC]: MdOutlineSensors,
[DeviceType.VENTILATION]: PiFan
+113 -122
View File
@@ -4,12 +4,10 @@ import {
useContext,
useEffect,
useLayoutEffect,
useMemo,
useState
} from 'react';
import { IconContext } from 'react-icons';
import { Link, useNavigate } from 'react-router';
import { toast } from 'react-toastify';
import CommentsDisabledOutlinedIcon from '@mui/icons-material/CommentsDisabledOutlined';
import ConstructionIcon from '@mui/icons-material/Construction';
@@ -65,6 +63,7 @@ import {
SectionContent,
useLayoutTitle
} from 'components';
import { toast } from 'components/toast';
import { AuthenticatedContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react';
import { useInterval } from 'utils';
@@ -133,21 +132,19 @@ const Devices = memo(() => {
};
}, []);
const leftOffset = useCallback(() => {
const leftOffset = () => {
const devicesWindow = document.getElementById('devices-window');
if (!devicesWindow) return 0;
const { left, right } = devicesWindow.getBoundingClientRect();
if (!left || !right) return 0;
return left + (right - left < 400 ? 0 : 200);
}, []);
};
const common_theme = useMemo(
() =>
useTheme({
BaseRow: `
const common_theme = useTheme({
BaseRow: `
font-size: 14px;
`,
HeaderRow: `
HeaderRow: `
text-transform: uppercase;
background-color: black;
color: #90CAF9;
@@ -155,7 +152,7 @@ const Devices = memo(() => {
border-bottom: 1px solid #565656;
}
`,
Row: `
Row: `
cursor: pointer;
background-color: #1E1E1E;
.td {
@@ -165,88 +162,78 @@ const Devices = memo(() => {
background-color: #177ac9;
}
`
}),
[]
);
});
const device_theme = useMemo(
() =>
useTheme([
common_theme,
{
BaseRow: `
font-size: 15px;
.td {
height: 28px;
}
`,
Table: `
--data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) 130px;
`,
HeaderRow: `
.th {
padding: 8px;
`,
Row: `
&:nth-of-type(odd) .td {
background-color: #303030;
},
&:hover .td {
background-color: #177ac9;
},
`
}
]),
[common_theme]
);
const data_theme = useMemo(
() =>
useTheme([
common_theme,
{
Table: `
--data-table-library_grid-template-columns: minmax(200px, auto) minmax(150px, auto) 40px;
height: auto;
max-height: 100%;
overflow-y: scroll;
::-webkit-scrollbar {
display:none;
const device_theme = useTheme([
common_theme,
{
BaseRow: `
font-size: 15px;
.td {
height: 28px;
}
`,
BaseRow: `
.td {
height: 32px;
}
`,
BaseCell: `
&:nth-of-type(1) {
border-left: 1px solid #177ac9;
},
&:nth-of-type(2) {
text-align: right;
},
&:nth-of-type(3) {
border-right: 1px solid #177ac9;
}
`,
HeaderRow: `
.th {
border-top: 1px solid #565656;
}
`,
Row: `
&:nth-of-type(odd) .td {
Table: `
--data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) 130px;
`,
HeaderRow: `
.th {
padding: 8px;
`,
Row: `
&:nth-of-type(odd) .td {
background-color: #303030;
},
&:hover .td {
background-color: #177ac9;
},
&:hover .td {
background-color: #177ac9;
},
`
}
]);
const data_theme = useTheme([
common_theme,
{
Table: `
--data-table-library_grid-template-columns: minmax(200px, auto) minmax(150px, auto) 40px;
height: auto;
max-height: 100%;
overflow-y: scroll;
::-webkit-scrollbar {
display:none;
}
`
}
]),
[common_theme]
);
`,
BaseRow: `
.td {
height: 32px;
}
`,
BaseCell: `
&:nth-of-type(1) {
border-left: 1px solid #177ac9;
},
&:nth-of-type(2) {
text-align: right;
},
&:nth-of-type(3) {
border-right: 1px solid #177ac9;
}
`,
HeaderRow: `
.th {
border-top: 1px solid #565656;
}
`,
Row: `
&:nth-of-type(odd) .td {
background-color: #303030;
},
&:hover .td {
background-color: #177ac9;
}
`
}
]);
const getSortIcon = (state: State, sortKey: unknown) => {
if (state.sortKey === sortKey && state.reverse) {
@@ -345,10 +332,8 @@ const Devices = memo(() => {
return sc;
};
const hasMask = useCallback(
(id: string, mask: number) => (parseInt(id.slice(0, 2), 16) & mask) === mask,
[]
);
const hasMask = (id: string, mask: number) =>
(parseInt(id.slice(0, 2), 16) & mask) === mask;
const handleDownloadCsv = () => {
const deviceIndex = coreData.devices.findIndex(
@@ -573,16 +558,26 @@ const Devices = memo(() => {
<CircularProgress sx={{ margin: 1 }} size={18} />
)}
{tableList.map((device: Device) => (
<Row key={device.id} item={device}>
<Row key={device.id} item={device} disabled={device.e === 0}>
<Cell>
<DeviceIcon type_id={device.t} />
&nbsp;&nbsp;
{device.n}
<span style={{ color: 'lightblue' }}>
&nbsp;&nbsp;({device.e})
<span style={{ color: device.e === 0 ? 'grey' : 'white' }}>
{device.n}
</span>
{device.e !== 0 && (
<span style={{ color: 'lightblue' }}>
&nbsp;&nbsp;({device.e})
</span>
)}
</Cell>
<Cell stiff>
<ButtonTooltip
title={`DeviceID: 0x${('00' + device.d.toString(16).toUpperCase()).slice(-2)}, ProductID: ${device.p}`}
>
<span>{device.tn}</span>
</ButtonTooltip>
</Cell>
<Cell stiff>{device.tn}</Cell>
</Row>
))}
</Body>
@@ -597,6 +592,7 @@ const Devices = memo(() => {
const deviceValueDialogClose = () => {
setDeviceValueDialogOpen(false);
setSelectedDeviceValue(undefined);
if (selectedDevice !== undefined) {
void sendDeviceData(selectedDevice);
}
@@ -607,41 +603,35 @@ const Devices = memo(() => {
return;
}
const showDeviceValue = useCallback((dv: DeviceValue) => {
const showDeviceValue = (dv: DeviceValue) => {
setSelectedDeviceValue(dv);
setDeviceValueDialogOpen(true);
}, []);
};
const renderNameCell = useCallback(
(dv: DeviceValue) => (
<>
{dv.id.slice(2)}&nbsp;
{hasMask(dv.id, DeviceEntityMask.DV_FAVORITE) && (
<StarIcon color="primary" sx={{ fontSize: 12 }} />
)}
{hasMask(dv.id, DeviceEntityMask.DV_READONLY) && (
<EditOffOutlinedIcon color="primary" sx={{ fontSize: 12 }} />
)}
{hasMask(dv.id, DeviceEntityMask.DV_API_MQTT_EXCLUDE) && (
<CommentsDisabledOutlinedIcon color="primary" sx={{ fontSize: 12 }} />
)}
</>
),
[hasMask]
const renderNameCell = (dv: DeviceValue) => (
<>
{dv.id.slice(2)}&nbsp;
{hasMask(dv.id, DeviceEntityMask.DV_FAVORITE) && (
<StarIcon color="primary" sx={{ fontSize: 12 }} />
)}
{hasMask(dv.id, DeviceEntityMask.DV_READONLY) && (
<EditOffOutlinedIcon color="primary" sx={{ fontSize: 12 }} />
)}
{hasMask(dv.id, DeviceEntityMask.DV_API_MQTT_EXCLUDE) && (
<CommentsDisabledOutlinedIcon color="primary" sx={{ fontSize: 12 }} />
)}
</>
);
const shown_data = useMemo(() => {
if (onlyFav) {
return deviceData.nodes.filter(
const shown_data = onlyFav
? deviceData.nodes.filter(
(dv: DeviceValue) =>
hasMask(dv.id, DeviceEntityMask.DV_FAVORITE) &&
dv.id.slice(2).toLowerCase().includes(search.toLowerCase())
)
: deviceData.nodes.filter((dv: DeviceValue) =>
dv.id.slice(2).toLowerCase().includes(search.toLowerCase())
);
}
return deviceData.nodes.filter((dv: DeviceValue) =>
dv.id.slice(2).toLowerCase().includes(search.toLowerCase())
);
}, [deviceData.nodes, onlyFav, search]);
const deviceIndex = coreData.devices.findIndex(
(d: Device) => d.id === device_select.state.id
@@ -798,6 +788,7 @@ const Devices = memo(() => {
<IconButton
size="small"
onClick={() => showDeviceValue(dv)}
style={{ backgroundColor: 'transparent' }}
>
{dv.v === '' ? (
<PlayArrowIcon color="primary" sx={{ fontSize: 16 }} />
+126 -105
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useEffect, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import WarningIcon from '@mui/icons-material/Warning';
import {
Box,
@@ -18,15 +19,18 @@ import {
Typography
} from '@mui/material';
import { callAction } from '@/api/app';
import { dialogStyle } from 'CustomTheme';
import type Schema from 'async-validator';
import type { ValidateFieldsError } from 'async-validator';
import { useRequest } from 'alova/client';
import { ValidatedTextField } from 'components';
import { toast } from 'components/toast';
import { useI18nContext } from 'i18n/i18n-react';
import { numberValue, updateValue } from 'utils';
import { ValidationError, validate } from 'validators';
import type Schema from 'validators/schema';
import type { ValidateFieldsError } from 'validators/schema';
import { DeviceValueUOM, DeviceValueUOM_s } from './types';
import { DeviceValueUOM, DeviceValueUOM_s, getDeviceValueKind } from './types';
import type { DeviceValue } from './types';
interface DevicesDialogProps {
@@ -52,7 +56,7 @@ const DevicesDialog = ({
const [editItem, setEditItem] = useState<DeviceValue>(selectedItem);
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const updateFormValue = useMemo(() => updateValue(setEditItem), [setEditItem]);
const updateFormValue = updateValue(setEditItem);
useEffect(() => {
if (open) {
@@ -61,36 +65,48 @@ const DevicesDialog = ({
}
}, [open, selectedItem]);
const save = useCallback(async () => {
const { send: executeCommand } = useRequest(
(id: string) => callAction({ action: 'executeCommand', param: id }),
{ immediate: false }
)
.onSuccess(() => {
toast.success(LL.COMMAND_EXECUTED());
})
.onError((error) => {
toast.error(String(error.error?.message || 'An error occurred'));
});
const doAction = async () => {
try {
setFieldErrors(undefined);
await validate(validator, editItem);
if (editItem.v === undefined && editItem.c !== undefined) {
await executeCommand(editItem.c);
} else {
await validate(validator, editItem);
}
onSave(editItem);
} catch (error) {
setFieldErrors((error as ValidationError).fieldErrors);
}
}, [validator, editItem, onSave]);
};
const setUom = useCallback(
(uom?: DeviceValueUOM) => {
if (uom === undefined) {
return;
}
switch (uom) {
case DeviceValueUOM.HOURS:
return LL.HOURS();
case DeviceValueUOM.MINUTES:
return LL.MINUTES();
case DeviceValueUOM.SECONDS:
return LL.SECONDS();
default:
return DeviceValueUOM_s[uom];
}
},
[LL]
);
const setUom = (uom?: DeviceValueUOM) => {
if (uom === undefined) {
return;
}
switch (uom) {
case DeviceValueUOM.HOURS:
return LL.HOURS();
case DeviceValueUOM.MINUTES:
return LL.MINUTES();
case DeviceValueUOM.SECONDS:
return LL.SECONDS();
default:
return DeviceValueUOM_s[uom];
}
};
const showHelperText = useCallback((dv: DeviceValue) => {
const showHelperText = (dv: DeviceValue) => {
if (dv.h) return dv.h;
if (dv.l) return dv.l.join(' | ');
if (dv.m !== undefined && dv.x !== undefined) {
@@ -101,26 +117,23 @@ const DevicesDialog = ({
);
}
return undefined;
}, []);
};
const isCommand = useMemo(
() => selectedItem.v === '' && selectedItem.c,
[selectedItem.v, selectedItem.c]
);
const kind = getDeviceValueKind(selectedItem);
const dialogTitle = useMemo(() => {
if (isCommand) return LL.RUN_COMMAND();
return writeable ? LL.CHANGE_VALUE() : LL.VALUE(0);
}, [isCommand, writeable, LL]);
const dialogTitle =
kind === 'command' || kind === 'deviceCommand'
? LL.RUN() + ' ' + LL.COMMAND(0)
: kind === 'scheduler'
? LL.UPDATE() + ' ' + LL.SCHEDULE(0)
: writeable
? LL.CHANGE_VALUE()
: LL.VALUE(0);
const buttonLabel = useMemo(() => {
return isCommand ? LL.EXECUTE() : LL.UPDATE();
}, [isCommand, LL]);
const buttonLabel =
kind === 'command' || kind === 'deviceCommand' ? LL.RUN() : LL.UPDATE();
const helperText = useMemo(
() => showHelperText(editItem),
[editItem, showHelperText]
);
const helperText = showHelperText(editItem);
const valueLabel = LL.VALUE(0);
@@ -131,67 +144,69 @@ const DevicesDialog = ({
<Typography sx={{ mb: 2 }} color="warning" variant="body2">
{editItem.id.slice(2)}
</Typography>
<Grid container>
<Grid size={12}>
{editItem.l ? (
<TextField
name="v"
value={editItem.v}
aria-label={valueLabel}
disabled={!writeable}
sx={{ width: '30ch' }}
select
onChange={updateFormValue}
>
{editItem.l.map((val) => (
<MenuItem value={val} key={val}>
{val}
</MenuItem>
))}
</TextField>
) : editItem.s || editItem.u !== DeviceValueUOM.NONE ? (
<ValidatedTextField
fieldErrors={fieldErrors || {}}
name="v"
label={valueLabel}
value={numberValue(Math.round((editItem.v as number) * 10) / 10)}
autoFocus
disabled={!writeable}
type="number"
sx={{ width: '30ch' }}
onChange={updateFormValue}
slotProps={{
htmlInput: editItem.s
? { min: editItem.m, max: editItem.x, step: editItem.s }
: {},
input: {
startAdornment: (
<InputAdornment position="start">
{setUom(editItem.u)}
</InputAdornment>
)
}
}}
/>
) : (
<ValidatedTextField
fieldErrors={fieldErrors || {}}
name="v"
label={valueLabel}
value={editItem.v}
disabled={!writeable}
sx={{ width: '30ch' }}
multiline={!editItem.u}
onChange={updateFormValue}
/>
{kind !== 'command' && (
<Grid container>
<Grid size={12}>
{editItem.l ? (
<TextField
name="v"
value={editItem.v}
aria-label={valueLabel}
disabled={!writeable}
sx={{ width: '30ch' }}
select
onChange={updateFormValue}
>
{editItem.l.map((val) => (
<MenuItem value={val} key={val}>
{val}
</MenuItem>
))}
</TextField>
) : editItem.s || editItem.u !== DeviceValueUOM.NONE ? (
<ValidatedTextField
fieldErrors={fieldErrors || {}}
name="v"
label={valueLabel}
value={numberValue(Math.round((editItem.v as number) * 10) / 10)}
autoFocus
disabled={!writeable}
type="number"
sx={{ width: '30ch' }}
onChange={updateFormValue}
slotProps={{
htmlInput: editItem.s
? { min: editItem.m, max: editItem.x, step: editItem.s }
: {},
input: {
startAdornment: (
<InputAdornment position="start">
{setUom(editItem.u)}
</InputAdornment>
)
}
}}
/>
) : (
<ValidatedTextField
fieldErrors={fieldErrors || {}}
name="v"
label={valueLabel}
value={editItem.v}
disabled={!writeable}
sx={{ width: '30ch' }}
multiline={!editItem.u}
onChange={updateFormValue}
/>
)}
</Grid>
{writeable && helperText && (
<Grid>
<FormHelperText>{helperText}</FormHelperText>
</Grid>
)}
</Grid>
{writeable && helperText && (
<Grid>
<FormHelperText>{helperText}</FormHelperText>
</Grid>
)}
</Grid>
)}
</DialogContent>
<DialogActions>
@@ -213,10 +228,16 @@ const DevicesDialog = ({
{LL.CANCEL()}
</Button>
<Button
startIcon={<WarningIcon color="warning" />}
startIcon={
kind === 'command' ? (
<PlayArrowIcon />
) : kind === 'deviceEntity' || kind === 'deviceCommand' ? (
<WarningIcon color="warning" />
) : undefined
}
variant="outlined"
onClick={save}
color="primary"
onClick={doAction}
color={kind !== 'value' ? 'success' : 'primary'}
>
{buttonLabel}
</Button>
+67 -98
View File
@@ -1,5 +1,3 @@
import { useCallback, useMemo } from 'react';
import { ToggleButton, ToggleButtonGroup } from '@mui/material';
import OptionIcon from './OptionIcon';
@@ -11,7 +9,6 @@ interface EntityMaskToggleProps {
de: DeviceEntity;
}
// Available mask values
const MASK_VALUES = [
DeviceEntityMask.DV_WEB_EXCLUDE, // 1
DeviceEntityMask.DV_API_MQTT_EXCLUDE, // 2
@@ -20,123 +17,95 @@ const MASK_VALUES = [
DeviceEntityMask.DV_DELETED // 128
];
/**
* Converts an array of mask strings to a bitmask number
*/
const getMaskNumber = (newMask: string[]): number => {
return newMask.reduce((mask, entry) => mask | Number(entry), 0);
};
const getMaskNumber = (newMask: string[]): number =>
newMask.reduce((mask, entry) => mask | Number(entry), 0);
/**
* Converts a bitmask number to an array of mask strings
*/
const getMaskString = (mask: number): string[] => {
return MASK_VALUES.filter((value) => (mask & value) === value).map((value) =>
const getMaskString = (mask: number): string[] =>
MASK_VALUES.filter((value) => (mask & value) === value).map((value) =>
String(value)
);
};
/**
* Checks if a specific mask bit is set
*/
const hasMask = (mask: number, flag: number): boolean => (mask & flag) === flag;
const EntityMaskToggle = ({ onUpdate, de }: EntityMaskToggleProps) => {
const handleChange = useCallback(
(_event: unknown, mask: string[]) => {
// Convert selected masks to a number
const newMask = getMaskNumber(mask);
const updatedDe = { ...de };
const handleChange = (_event: unknown, mask: string[]) => {
const newMask = getMaskNumber(mask);
const updatedDe = { ...de };
// Apply business logic for mask interactions
// If entity has no name and is set to readonly, also exclude from web
if (updatedDe.n === '' && hasMask(newMask, DeviceEntityMask.DV_READONLY)) {
updatedDe.m = newMask | DeviceEntityMask.DV_WEB_EXCLUDE;
} else {
updatedDe.m = newMask;
}
// If entity has no name and is set to readonly, also exclude from web
if (updatedDe.n === '' && hasMask(newMask, DeviceEntityMask.DV_READONLY)) {
updatedDe.m = newMask | DeviceEntityMask.DV_WEB_EXCLUDE;
} else {
updatedDe.m = newMask;
}
// If excluded from web, cannot be favorite
if (hasMask(updatedDe.m, DeviceEntityMask.DV_WEB_EXCLUDE)) {
updatedDe.m = updatedDe.m & ~DeviceEntityMask.DV_FAVORITE;
}
// If excluded from web, cannot be favorite
if (hasMask(updatedDe.m, DeviceEntityMask.DV_WEB_EXCLUDE)) {
updatedDe.m = updatedDe.m & ~DeviceEntityMask.DV_FAVORITE;
}
onUpdate(updatedDe);
},
[de, onUpdate]
);
// Memoize mask string value
const maskStringValue = useMemo(() => getMaskString(de.m), [de.m]);
// Memoize disabled states
const isFavoriteDisabled = useMemo(
() =>
hasMask(de.m, DeviceEntityMask.DV_WEB_EXCLUDE | DeviceEntityMask.DV_DELETED) ||
de.n === undefined,
[de.m, de.n]
);
const isReadonlyDisabled = useMemo(
() =>
!de.w ||
hasMask(de.m, DeviceEntityMask.DV_WEB_EXCLUDE | DeviceEntityMask.DV_FAVORITE),
[de.w, de.m]
);
const isApiMqttExcludeDisabled = useMemo(
() => de.n === '' || hasMask(de.m, DeviceEntityMask.DV_DELETED),
[de.n, de.m]
);
const isWebExcludeDisabled = useMemo(
() => de.n === undefined || hasMask(de.m, DeviceEntityMask.DV_DELETED),
[de.n, de.m]
);
// Memoize mask flag checks
const isFavoriteSet = useMemo(
() => hasMask(de.m, DeviceEntityMask.DV_FAVORITE),
[de.m]
);
const isReadonlySet = useMemo(
() => hasMask(de.m, DeviceEntityMask.DV_READONLY),
[de.m]
);
const isApiMqttExcludeSet = useMemo(
() => hasMask(de.m, DeviceEntityMask.DV_API_MQTT_EXCLUDE),
[de.m]
);
const isWebExcludeSet = useMemo(
() => hasMask(de.m, DeviceEntityMask.DV_WEB_EXCLUDE),
[de.m]
);
const isDeletedSet = useMemo(
() => hasMask(de.m, DeviceEntityMask.DV_DELETED),
[de.m]
);
onUpdate(updatedDe);
};
return (
<ToggleButtonGroup
size="small"
color="secondary"
value={maskStringValue}
value={getMaskString(de.m)}
onChange={handleChange}
>
<ToggleButton value="8" disabled={isFavoriteDisabled}>
<OptionIcon type="favorite" isSet={isFavoriteSet} />
<ToggleButton
value="8"
disabled={
hasMask(
de.m,
DeviceEntityMask.DV_WEB_EXCLUDE | DeviceEntityMask.DV_DELETED
) || de.n === undefined
}
>
<OptionIcon
type="favorite"
isSet={hasMask(de.m, DeviceEntityMask.DV_FAVORITE)}
/>
</ToggleButton>
<ToggleButton value="4" disabled={isReadonlyDisabled}>
<OptionIcon type="readonly" isSet={isReadonlySet} />
<ToggleButton
value="4"
disabled={
!de.w ||
hasMask(
de.m,
DeviceEntityMask.DV_WEB_EXCLUDE | DeviceEntityMask.DV_FAVORITE
)
}
>
<OptionIcon
type="readonly"
isSet={hasMask(de.m, DeviceEntityMask.DV_READONLY)}
/>
</ToggleButton>
<ToggleButton value="2" disabled={isApiMqttExcludeDisabled}>
<OptionIcon type="api_mqtt_exclude" isSet={isApiMqttExcludeSet} />
<ToggleButton
value="2"
disabled={de.n === '' || hasMask(de.m, DeviceEntityMask.DV_DELETED)}
>
<OptionIcon
type="api_mqtt_exclude"
isSet={hasMask(de.m, DeviceEntityMask.DV_API_MQTT_EXCLUDE)}
/>
</ToggleButton>
<ToggleButton value="1" disabled={isWebExcludeDisabled}>
<OptionIcon type="web_exclude" isSet={isWebExcludeSet} />
<ToggleButton
value="1"
disabled={de.n === undefined || hasMask(de.m, DeviceEntityMask.DV_DELETED)}
>
<OptionIcon
type="web_exclude"
isSet={hasMask(de.m, DeviceEntityMask.DV_WEB_EXCLUDE)}
/>
</ToggleButton>
<ToggleButton value="128">
<OptionIcon type="deleted" isSet={isDeletedSet} />
<OptionIcon
type="deleted"
isSet={hasMask(de.m, DeviceEntityMask.DV_DELETED)}
/>
</ToggleButton>
</ToggleButtonGroup>
);
+27 -52
View File
@@ -1,6 +1,5 @@
import { memo, useCallback, useContext, useMemo, useState } from 'react';
import { memo, useContext, useState } from 'react';
import type { ReactElement } from 'react';
import { toast } from 'react-toastify';
import CommentIcon from '@mui/icons-material/CommentTwoTone';
import DownloadIcon from '@mui/icons-material/GetApp';
@@ -25,6 +24,7 @@ import type { SxProps, Theme } from '@mui/material/styles';
import { useRequest } from 'alova/client';
import { SectionContent, useLayoutTitle } from 'components';
import { toast } from 'components/toast';
import { AuthenticatedContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react';
import { saveFile } from 'utils';
@@ -60,6 +60,8 @@ const AVATAR_STYLES: SxProps<Theme> = {
bgcolor: '#72caf9'
};
const SYSTEM_INFO_API: APIcall = { device: 'system', cmd: 'info', id: 0 };
const HelpComponent = () => {
const { LL } = useI18nContext();
useLayoutTitle(LL.HELP());
@@ -72,12 +74,7 @@ const HelpComponent = () => {
});
const [imgError, setImgError] = useState<boolean>(false);
const getCustomSupportMethod = useMemo(
() => callAction({ action: 'getCustomSupport' }),
[]
);
useRequest(getCustomSupportMethod).onSuccess((event) => {
useRequest(callAction({ action: 'getCustomSupport' })).onSuccess((event) => {
if (event?.data && Object.keys(event.data).length !== 0) {
const { Support } = event.data as {
Support: { img_url?: string; html?: string[] };
@@ -100,47 +97,26 @@ const HelpComponent = () => {
toast.error(String(error.error?.message || 'An error occurred'));
});
// Optimize API call memoization
const apiCall = useMemo(() => ({ device: 'system', cmd: 'info', id: 0 }), []);
const helpLinks: HelpLink[] = [
{
href: 'https://emsesp.org',
icon: <MenuBookIcon />,
label: () => LL.HELP_INFORMATION_1()
},
{
href: 'https://discord.gg/GP9DPSgeJq',
icon: <CommentIcon />,
label: () => LL.HELP_INFORMATION_2()
},
{
href: 'https://github.com/emsesp/EMS-ESP32/issues/new/choose',
icon: <GitHubIcon />,
label: () => LL.HELP_INFORMATION_3()
}
];
const handleDownloadSystemInfo = useCallback(() => {
void sendAPI(apiCall);
}, [sendAPI, apiCall]);
const handleImageError = useCallback(() => {
setImgError(true);
}, []);
// Memoize help links to prevent recreation on every render
const helpLinks: HelpLink[] = useMemo(
() => [
{
href: 'https://emsesp.org',
icon: <MenuBookIcon />,
label: () => LL.HELP_INFORMATION_1()
},
{
href: 'https://discord.gg/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]
);
const imageSrc =
imgError || !customSupport.img_url ? DEFAULT_IMAGE_URL : customSupport.img_url;
return (
<SectionContent>
@@ -157,13 +133,13 @@ const HelpComponent = () => {
component="img"
referrerPolicy="no-referrer"
sx={IMAGE_STYLES}
onError={handleImageError}
onError={() => setImgError(true)}
src={imageSrc}
/>
</Stack>
)}
{isAdmin && (
{me?.admin && (
<List>
{helpLinks.map(({ href, icon, label }) => (
<ListItem key={href}>
@@ -191,7 +167,7 @@ const HelpComponent = () => {
startIcon={<DownloadIcon />}
variant="outlined"
color="primary"
onClick={handleDownloadSystemInfo}
onClick={() => void sendAPI(SYSTEM_INFO_API)}
>
{LL.SUPPORT_INFORMATION(0)}
</Button>
@@ -214,7 +190,6 @@ const HelpComponent = () => {
);
};
// Memoize the component to prevent unnecessary re-renders
const Help = memo(HelpComponent);
export default Help;
+60 -78
View File
@@ -1,6 +1,5 @@
import { memo, useCallback, useMemo, useState } from 'react';
import { memo, useState } from 'react';
import { useBlocker } from 'react-router';
import { toast } from 'react-toastify';
import CancelIcon from '@mui/icons-material/Cancel';
import CircleIcon from '@mui/icons-material/Circle';
@@ -25,6 +24,7 @@ import {
SectionContent,
useLayoutTitle
} from 'components';
import { toast } from 'components/toast';
import { useI18nContext } from 'i18n/i18n-react';
import { readModules, writeModules } from '../../api/app';
@@ -69,58 +69,53 @@ const Modules = () => {
}
);
const modules_theme = useTheme(
useMemo(
() => ({
Table: `
--data-table-library_grid-template-columns: 48px 180px 120px 100px repeat(1, minmax(160px, 1fr)) 180px;
`,
BaseRow: `
font-size: 14px;
.td {
height: 32px;
}
`,
BaseCell: `
&:nth-of-type(1) {
text-align: center;
}
`,
HeaderRow: `
text-transform: uppercase;
background-color: black;
color: #90CAF9;
.th {
border-bottom: 1px solid #565656;
height: 36px;
}
`,
Row: `
background-color: #1e1e1e;
position: relative;
cursor: pointer;
.td {
border-top: 1px solid #565656;
border-bottom: 1px solid #565656;
}
&:hover .td {
border-top: 1px solid #177ac9;
border-bottom: 1px solid #177ac9;
}
&:nth-of-type(odd) .td {
background-color: #303030;
}
`
}),
[]
)
);
const modules_theme = useTheme({
Table: `
--data-table-library_grid-template-columns: 48px 180px 120px 100px repeat(1, minmax(160px, 1fr)) 180px;
`,
BaseRow: `
font-size: 14px;
.td {
height: 32px;
}
`,
BaseCell: `
&:nth-of-type(1) {
text-align: center;
}
`,
HeaderRow: `
text-transform: uppercase;
background-color: black;
color: #90CAF9;
.th {
border-bottom: 1px solid #565656;
height: 36px;
}
`,
Row: `
background-color: #1e1e1e;
position: relative;
cursor: pointer;
.td {
border-top: 1px solid #565656;
border-bottom: 1px solid #565656;
}
&:hover .td {
border-top: 1px solid #177ac9;
border-bottom: 1px solid #177ac9;
}
&:nth-of-type(odd) .td {
background-color: #303030;
}
`
});
const onDialogClose = useCallback(() => {
const onDialogClose = () => {
setDialogOpen(false);
}, []);
};
const updateModuleItem = useCallback((updatedItem: ModuleItem) => {
const updateModuleItem = (updatedItem: ModuleItem) => {
void updateState(readModules(), (data: ModuleItem[]) => {
const new_data = data.map((mi) =>
mi.id === updatedItem.id ? { ...mi, ...updatedItem } : mi
@@ -128,28 +123,25 @@ const Modules = () => {
setNumChanges(new_data.filter(hasModulesChanged).length);
return new_data;
});
}, []);
};
const onDialogSave = useCallback(
(updatedItem: ModuleItem) => {
setDialogOpen(false);
updateModuleItem(updatedItem);
},
[updateModuleItem]
);
const onDialogSave = (updatedItem: ModuleItem) => {
setDialogOpen(false);
updateModuleItem(updatedItem);
};
const editModuleItem = useCallback((mi: ModuleItem) => {
const editModuleItem = (mi: ModuleItem) => {
setSelectedModuleItem(mi);
setDialogOpen(true);
}, []);
};
const onCancel = useCallback(async () => {
const onCancel = async () => {
await fetchModules().then(() => {
setNumChanges(0);
});
}, [fetchModules]);
};
const saveModules = useCallback(async () => {
const saveModules = async () => {
try {
await Promise.all(
modules.map((condensed_mi: ModuleItem) =>
@@ -167,9 +159,9 @@ const Modules = () => {
await fetchModules();
setNumChanges(0);
}
}, [modules, updateModules, LL, fetchModules]);
};
const content = useMemo(() => {
const renderContent = () => {
if (!modules) {
return (
<FormLoader onRetry={fetchModules} errorMessage={error?.message || ''} />
@@ -262,22 +254,12 @@ const Modules = () => {
</Box>
</>
);
}, [
modules,
fetchModules,
error,
modules_theme,
editModuleItem,
LL,
numChanges,
onCancel,
saveModules
]);
};
return (
<SectionContent>
{blocker ? <BlockNavigation blocker={blocker} /> : null}
{content}
{renderContent()}
{selectedModuleItem && (
<ModulesDialog
open={dialogOpen}
+8 -17
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useEffect, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel';
import DoneIcon from '@mui/icons-material/Done';
@@ -37,14 +37,10 @@ const ModulesDialog = ({
const { LL } = useI18nContext();
const [editItem, setEditItem] = useState<ModuleItem>(selectedItem);
const updateFormValue = useMemo(
() =>
updateValue(
setEditItem as unknown as React.Dispatch<
React.SetStateAction<Record<string, unknown>>
>
),
[]
const updateFormValue = updateValue(
setEditItem as unknown as React.Dispatch<
React.SetStateAction<Record<string, unknown>>
>
);
// Sync form state when dialog opens or selected item changes
@@ -54,18 +50,13 @@ const ModulesDialog = ({
}
}, [open, selectedItem]);
const handleSave = useCallback(() => {
const handleSave = () => {
onSave(editItem);
}, [editItem, onSave]);
const dialogTitle = useMemo(
() => `${LL.EDIT()} ${editItem.key}`,
[LL, editItem.key]
);
};
return (
<Dialog sx={dialogStyle} fullWidth maxWidth="xs" open={open} onClose={onClose}>
<DialogTitle>{dialogTitle}</DialogTitle>
<DialogTitle>{`${LL.EDIT()} ${editItem.key}`}</DialogTitle>
<DialogContent dividers>
<Grid container>
<BlockFormControlLabel
+73 -92
View File
@@ -1,6 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useEffect, useState } from 'react';
import { useBlocker } from 'react-router';
import { toast } from 'react-toastify';
import AddIcon from '@mui/icons-material/Add';
import CancelIcon from '@mui/icons-material/Cancel';
@@ -26,10 +25,11 @@ import {
SectionContent,
useLayoutTitle
} from 'components';
import { toast } from 'components/toast';
import { useI18nContext } from 'i18n/i18n-react';
import { useInterval } from 'utils';
import { readSchedule, writeSchedule } from '../../api/app';
import { readCommands, readSchedule, writeSchedule } from '../../api/app';
import SettingsSchedulerDialog from './SchedulerDialog';
import { ScheduleFlag } from './types';
import type { Schedule, ScheduleItem } from './types';
@@ -54,14 +54,13 @@ const DEFAULT_SCHEDULE_ITEM: Omit<ScheduleItem, 'id' | 'o_id'> = {
deleted: false,
flags: FLAG_ALL_DAYS,
time: '',
cmd: '',
value: '',
cmd_name: '',
name: ''
};
const scheduleTheme = {
Table: `
--data-table-library_grid-template-columns: 36px 210px 100px 192px repeat(1, minmax(100px, 1fr)) 160px;
--data-table-library_grid-template-columns: 36px 220px repeat(1, minmax(20px, 1fr)) 192px 160px;
`,
BaseRow: `
font-size: 14px;
@@ -70,11 +69,8 @@ const scheduleTheme = {
}
`,
BaseCell: `
&:nth-of-type(2) {
text-align: center;
}
&:nth-of-type(1) {
text-align: center;
text-align: 8px;
}
`,
HeaderRow: `
@@ -100,7 +96,6 @@ const scheduleTheme = {
};
const scheduleTypeLabels: Record<number, string> = {
[ScheduleFlag.SCHEDULE_IMMEDIATE]: 'Immediate',
[ScheduleFlag.SCHEDULE_TIMER]: 'Timer',
[ScheduleFlag.SCHEDULE_CONDITION]: 'Condition',
[ScheduleFlag.SCHEDULE_ONCHANGE]: 'On Change'
@@ -125,6 +120,11 @@ const Scheduler = () => {
initialData: []
});
const { data: commandNames } = useRequest(readCommands, {
initialData: [],
initializing: true
});
const { send: updateSchedule } = useRequest(
(data: Schedule) => writeSchedule(data),
{
@@ -132,7 +132,7 @@ const Scheduler = () => {
}
);
const hasScheduleChanged = useCallback((si: ScheduleItem) => {
const hasScheduleChanged = (si: ScheduleItem) => {
return (
si.id !== si.o_id ||
(si.name || '') !== (si.o_name || '') ||
@@ -140,18 +140,15 @@ const Scheduler = () => {
si.deleted !== si.o_deleted ||
si.flags !== si.o_flags ||
si.time !== si.o_time ||
si.cmd !== si.o_cmd ||
si.value !== si.o_value
si.cmd_name !== si.o_cmd_name
);
}, []);
};
const intervalCallback = useCallback(() => {
useInterval(() => {
if (numChanges === 0) {
void fetchSchedule();
}
}, [numChanges, fetchSchedule]);
useInterval(intervalCallback, INTERVAL_DELAY);
}, INTERVAL_DELAY);
useEffect(() => {
const formatter = new Intl.DateTimeFormat(locale, {
@@ -169,7 +166,7 @@ const Scheduler = () => {
const schedule_theme = useTheme(scheduleTheme);
const saveSchedule = useCallback(async () => {
const saveSchedule = async () => {
try {
await updateSchedule({
schedule: schedule
@@ -179,8 +176,7 @@ const Scheduler = () => {
active: condensed_si.active,
flags: condensed_si.flags,
time: condensed_si.time,
cmd: condensed_si.cmd,
value: condensed_si.value,
cmd_name: condensed_si.cmd_name,
name: condensed_si.name
}))
});
@@ -192,46 +188,43 @@ const Scheduler = () => {
await fetchSchedule();
setNumChanges(0);
}
}, [LL, schedule, updateSchedule, fetchSchedule]);
};
const editScheduleItem = useCallback((si: ScheduleItem) => {
const editScheduleItem = (si: ScheduleItem) => {
setCreating(false);
setSelectedScheduleItem(si);
setDialogOpen(true);
if (si.o_name === undefined) {
si.o_name = si.name;
}
}, []);
};
const onDialogClose = useCallback(() => {
const onDialogClose = () => {
setDialogOpen(false);
}, []);
};
const onDialogCancel = useCallback(async () => {
const onDialogCancel = async () => {
await fetchSchedule().then(() => {
setNumChanges(0);
});
}, [fetchSchedule]);
};
const onDialogSave = useCallback(
(updatedItem: ScheduleItem) => {
setDialogOpen(false);
void updateState(readSchedule(), (data: ScheduleItem[]) => {
const new_data = creating
? [...data, updatedItem]
: data.map((si) =>
si.id === updatedItem.id ? { ...si, ...updatedItem } : si
);
const onDialogSave = (updatedItem: ScheduleItem) => {
setDialogOpen(false);
void updateState(readSchedule(), (data: ScheduleItem[]) => {
const new_data = creating
? [...data, updatedItem]
: data.map((si) =>
si.id === updatedItem.id ? { ...si, ...updatedItem } : si
);
setNumChanges(new_data.filter((si) => hasScheduleChanged(si)).length);
setNumChanges(new_data.filter((si) => hasScheduleChanged(si)).length);
return new_data;
});
},
[creating, hasScheduleChanged]
);
return new_data;
});
};
const addScheduleItem = useCallback(() => {
const addScheduleItem = () => {
setCreating(true);
const newItem: ScheduleItem = {
id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID),
@@ -239,36 +232,29 @@ const Scheduler = () => {
};
setSelectedScheduleItem(newItem);
setDialogOpen(true);
}, []);
};
const filteredAndSortedSchedule = useMemo(
() =>
schedule
.filter((si: ScheduleItem) => !si.deleted)
.sort((a: ScheduleItem, b: ScheduleItem) => a.flags - b.flags),
[schedule]
);
const filteredAndSortedSchedule = schedule
.filter((si: ScheduleItem) => !si.deleted)
.sort((a: ScheduleItem, b: ScheduleItem) => a.flags - b.flags);
const dayBox = useCallback(
(si: ScheduleItem, flag: number) => {
const dayIndex = Math.log(flag) / LOG_2;
const isActive = (si.flags & flag) === flag;
const dayBox = (si: ScheduleItem, flag: number, isLast = false) => {
const dayIndex = Math.log(flag) / LOG_2;
const isActive = (si.flags & flag) === flag;
return (
<>
<Box>
<Typography sx={{ fontSize: 11 }} color={isActive ? 'primary' : 'grey'}>
{dow[dayIndex]}
</Typography>
</Box>
<Divider orientation="vertical" flexItem />
</>
);
},
[dow]
);
return (
<>
<Box>
<Typography sx={{ fontSize: 11 }} color={isActive ? 'primary' : 'grey'}>
{dow[dayIndex]}
</Typography>
</Box>
{!isLast && <Divider orientation="vertical" flexItem />}
</>
);
};
const scheduleType = useCallback((si: ScheduleItem) => {
const scheduleType = (si: ScheduleItem) => {
const label = scheduleTypeLabels[si.flags];
return (
@@ -278,9 +264,9 @@ const Scheduler = () => {
</Typography>
</Box>
);
}, []);
};
const renderSchedule = useCallback(() => {
const renderSchedule = () => {
if (!schedule) {
return (
<FormLoader onRetry={fetchSchedule} errorMessage={error?.message || ''} />
@@ -299,9 +285,10 @@ const Scheduler = () => {
<HeaderRow>
<HeaderCell />
<HeaderCell stiff>{LL.SCHEDULE(0)}</HeaderCell>
<HeaderCell stiff>{LL.TIME(0)}/Cond.</HeaderCell>
<HeaderCell stiff>
{LL.TIME(0)}/{LL.CONDITION()}
</HeaderCell>
<HeaderCell stiff>{LL.COMMAND(0)}</HeaderCell>
<HeaderCell stiff>{LL.VALUE(0)}</HeaderCell>
<HeaderCell stiff>{LL.NAME(0)}</HeaderCell>
</HeaderRow>
</Header>
@@ -311,12 +298,16 @@ const Scheduler = () => {
<Cell stiff>
<CircleIcon
color={si.active ? 'success' : 'error'}
sx={{ fontSize: ICON_SIZE, verticalAlign: 'middle' }}
sx={{
fontSize: ICON_SIZE,
ml: '4px',
position: 'relative',
top: '2px'
}}
/>
</Cell>
<Cell stiff>
<Stack spacing={0.5} direction="row">
<Divider orientation="vertical" flexItem />
{si.flags > SCHEDULE_FLAG_THRESHOLD ? (
scheduleType(si)
) : (
@@ -327,14 +318,13 @@ const Scheduler = () => {
{dayBox(si, ScheduleFlag.SCHEDULE_THU)}
{dayBox(si, ScheduleFlag.SCHEDULE_FRI)}
{dayBox(si, ScheduleFlag.SCHEDULE_SAT)}
{dayBox(si, ScheduleFlag.SCHEDULE_SUN)}
{dayBox(si, ScheduleFlag.SCHEDULE_SUN, true)}
</>
)}
</Stack>
</Cell>
<Cell>{si.time}</Cell>
<Cell>{si.cmd}</Cell>
<Cell>{si.value}</Cell>
<Cell>{si.time === '' ? LL.SCHEDULER_HELP_2() : si.time}</Cell>
<Cell>{si.cmd_name}</Cell>
<Cell>{si.name}</Cell>
</Row>
))}
@@ -343,17 +333,7 @@ const Scheduler = () => {
)}
</Table>
);
}, [
schedule,
error,
fetchSchedule,
filteredAndSortedSchedule,
schedule_theme,
editScheduleItem,
LL,
dayBox,
scheduleType
]);
};
return (
<SectionContent>
@@ -372,6 +352,7 @@ const Scheduler = () => {
selectedItem={selectedScheduleItem}
validator={schedulerItemValidation(schedule, selectedScheduleItem)}
dow={dow}
commandNames={commandNames.map((ci) => ci.name)}
/>
)}
+155 -218
View File
@@ -1,9 +1,9 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useEffect, useState } from 'react';
import AddIcon from '@mui/icons-material/Add';
import CancelIcon from '@mui/icons-material/Cancel';
import CircleIcon from '@mui/icons-material/Circle';
import DoneIcon from '@mui/icons-material/Done';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import RemoveIcon from '@mui/icons-material/RemoveCircleOutlined';
import {
Box,
@@ -14,6 +14,7 @@ import {
DialogContent,
DialogTitle,
Grid,
MenuItem,
TextField,
ToggleButton,
ToggleButtonGroup,
@@ -21,12 +22,12 @@ import {
} from '@mui/material';
import { dialogStyle } from 'CustomTheme';
import type Schema from 'async-validator';
import type { ValidateFieldsError } from 'async-validator';
import { BlockFormControlLabel, ValidatedTextField } from 'components';
import { useI18nContext } from 'i18n/i18n-react';
import { updateValue } from 'utils';
import { ValidationError, validate } from 'validators';
import type Schema from 'validators/schema';
import type { ValidateFieldsError } from 'validators/schema';
import { ScheduleFlag } from './types';
import type { ScheduleItem } from './types';
@@ -60,6 +61,12 @@ const FLAG_VALUES = [
ScheduleFlag.SCHEDULE_SAT
] as const;
const getFlagDOWnumber = (flags: string[]) =>
flags.reduce((acc, flag) => acc | Number(flag), 0) & FLAG_MASK_127;
const getFlagDOWstring = (f: number) =>
FLAG_VALUES.filter((flag) => (f & flag) === flag).map((flag) => String(flag));
interface SchedulerDialogProps {
open: boolean;
creating: boolean;
@@ -68,6 +75,7 @@ interface SchedulerDialogProps {
selectedItem: ScheduleItem;
validator: Schema;
dow: string[];
commandNames: string[];
}
const SchedulerDialog = ({
@@ -77,33 +85,24 @@ const SchedulerDialog = ({
onSave,
selectedItem,
validator,
dow
dow,
commandNames
}: SchedulerDialogProps) => {
const { LL } = useI18nContext();
const [editItem, setEditItem] = useState<ScheduleItem>(selectedItem);
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const [scheduleType, setScheduleType] = useState<ScheduleFlag>();
const updateFormValue = useMemo(
() =>
updateValue(
setEditItem as unknown as React.Dispatch<
React.SetStateAction<Record<string, unknown>>
>
),
[]
const updateFormValue = updateValue(
setEditItem as unknown as React.Dispatch<
React.SetStateAction<Record<string, unknown>>
>
);
useEffect(() => {
if (open) {
setFieldErrors(undefined);
setEditItem(selectedItem);
// Set the flags based on type when page is loaded:
// 0-127 is day schedule
// 128 is timer
// 129 is on change
// 130 is on condition
// 132 is immediate
setScheduleType(
selectedItem.flags <= SCHEDULE_TYPE_THRESHOLD
? ScheduleFlag.SCHEDULE_DAY
@@ -112,129 +111,89 @@ const SchedulerDialog = ({
}
}, [open, selectedItem]);
// Helper function to handle save operations
const handleSave = useCallback(
async (itemToSave: ScheduleItem) => {
try {
setFieldErrors(undefined);
await validate(validator, itemToSave);
onSave(itemToSave);
} catch (error) {
setFieldErrors((error as ValidationError).fieldErrors);
}
},
[validator, onSave]
);
const save = useCallback(async () => {
await handleSave(editItem);
}, [editItem, handleSave]);
const saveandactivate = useCallback(async () => {
await handleSave({ ...editItem, active: true });
}, [editItem, handleSave]);
const remove = useCallback(() => {
onSave({ ...editItem, deleted: true });
}, [editItem, onSave]);
// Optimize DOW flag conversion
const getFlagDOWnumber = useCallback((flags: string[]) => {
return flags.reduce((acc, flag) => acc | Number(flag), 0) & FLAG_MASK_127;
}, []);
const getFlagDOWstring = useCallback((f: number) => {
return FLAG_VALUES.filter((flag) => (f & flag) === flag).map((flag) =>
String(flag)
);
}, []);
// Day of week display component
const DayOfWeekButton = useCallback(
(flag: number) => {
const dayIndex = Math.log2(flag);
const isSelected = (editItem.flags & flag) === flag;
return (
<Typography
sx={{ fontSize: TYPOGRAPHY_FONT_SIZE }}
color={isSelected ? 'primary' : 'grey'}
>
{dow[dayIndex]}
</Typography>
);
},
[editItem.flags, dow]
);
const handleClose = useCallback(
(_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => {
if (reason !== 'backdropClick') {
onClose();
}
},
[onClose]
);
const handleScheduleTypeChange = useCallback(
(_event: React.SyntheticEvent<HTMLElement>, flag: ScheduleFlag | null) => {
if (flag !== null) {
setFieldErrors(undefined); // clear any validation errors
setScheduleType(flag);
// wipe the time field when changing the schedule type
// set the flags based on type
const newFlags = flag === ScheduleFlag.SCHEDULE_DAY ? FLAG_ALL_DAYS : flag;
setEditItem((prev) => ({ ...prev, time: '', flags: newFlags }));
}
},
[]
);
const handleDOWChange = useCallback(
(_event: React.SyntheticEvent<HTMLElement>, flags: string[]) => {
const newFlags =
getFlagDOWnumber(flags) === 0 ? FLAG_ALL_DAYS : getFlagDOWnumber(flags);
setEditItem((prev) => ({ ...prev, flags: newFlags }));
},
[getFlagDOWnumber]
);
// Memoize derived values
const isDaySchedule = useMemo(
() => scheduleType === ScheduleFlag.SCHEDULE_DAY,
[scheduleType]
);
const isTimerSchedule = useMemo(
() => scheduleType === ScheduleFlag.SCHEDULE_TIMER,
[scheduleType]
);
const isImmediateSchedule = useMemo(
() => scheduleType === ScheduleFlag.SCHEDULE_IMMEDIATE,
[scheduleType]
);
const needsTimeField = useMemo(
() => isDaySchedule || isTimerSchedule,
[isDaySchedule, isTimerSchedule]
);
const dowFlags = useMemo(
() => getFlagDOWstring(editItem.flags),
[editItem.flags, getFlagDOWstring]
);
const timeFieldValue = useMemo(() => {
if (needsTimeField) {
return editItem.time === '' ? DEFAULT_TIME : editItem.time;
const handleSave = async (itemToSave: ScheduleItem) => {
try {
setFieldErrors(undefined);
await validate(validator, itemToSave);
onSave(itemToSave);
} catch (error) {
setFieldErrors((error as ValidationError).fieldErrors);
}
return editItem.time === DEFAULT_TIME ? '' : editItem.time;
}, [editItem.time, needsTimeField]);
};
const timeFieldLabel = useMemo(() => {
const save = async () => {
await handleSave(editItem);
};
const remove = () => {
onSave({ ...editItem, deleted: true });
};
const DayOfWeekButton = (flag: number) => {
const dayIndex = Math.log2(flag);
const isSelected = (editItem.flags & flag) === flag;
return (
<Typography
sx={{ fontSize: TYPOGRAPHY_FONT_SIZE }}
color={isSelected ? 'primary' : 'grey'}
>
{dow[dayIndex]}
</Typography>
);
};
const handleClose = (
_event: React.SyntheticEvent,
reason: 'backdropClick' | 'escapeKeyDown'
) => {
if (reason !== 'backdropClick') {
onClose();
}
};
const handleScheduleTypeChange = (
_event: React.SyntheticEvent<HTMLElement>,
flag: ScheduleFlag | null
) => {
if (flag !== null) {
setFieldErrors(undefined); // clear any validation errors
setScheduleType(flag);
// wipe the time field when changing the schedule type
// set the flags based on type
const newFlags = flag === ScheduleFlag.SCHEDULE_DAY ? FLAG_ALL_DAYS : flag;
setEditItem((prev) => ({ ...prev, time: '', flags: newFlags }));
}
};
const handleDOWChange = (
_event: React.SyntheticEvent<HTMLElement>,
flags: string[]
) => {
const newFlags =
getFlagDOWnumber(flags) === 0 ? FLAG_ALL_DAYS : getFlagDOWnumber(flags);
setEditItem((prev) => ({ ...prev, flags: newFlags }));
};
const isDaySchedule = scheduleType === ScheduleFlag.SCHEDULE_DAY;
const isTimerSchedule = scheduleType === ScheduleFlag.SCHEDULE_TIMER;
const needsTimeField = isDaySchedule || isTimerSchedule;
const dowFlags = getFlagDOWstring(editItem.flags);
const timeFieldValue = needsTimeField
? editItem.time === ''
? DEFAULT_TIME
: editItem.time
: editItem.time === DEFAULT_TIME
? ''
: editItem.time;
const timeFieldLabel = (() => {
if (scheduleType === ScheduleFlag.SCHEDULE_TIMER) return LL.TIMER(1);
if (scheduleType === ScheduleFlag.SCHEDULE_CONDITION) return LL.CONDITION();
if (scheduleType === ScheduleFlag.SCHEDULE_ONCHANGE) return LL.ONCHANGE();
if (scheduleType === ScheduleFlag.SCHEDULE_IMMEDIATE) return LL.IMMEDIATE();
return LL.TIME(1);
}, [scheduleType, LL]);
})();
return (
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
@@ -287,14 +246,6 @@ const SchedulerDialog = ({
{LL.CONDITION()}
</Typography>
</ToggleButton>
<ToggleButton value={ScheduleFlag.SCHEDULE_IMMEDIATE}>
<Typography
sx={{ fontSize: TYPOGRAPHY_FONT_SIZE }}
color={isImmediateSchedule ? 'primary' : 'grey'}
>
{LL.IMMEDIATE()}
</Typography>
</ToggleButton>
</ToggleButtonGroup>
{isDaySchedule && (
@@ -312,78 +263,74 @@ const SchedulerDialog = ({
</ToggleButtonGroup>
)}
{!isImmediateSchedule && (
<>
<Grid container>
<BlockFormControlLabel
control={
<Checkbox
checked={editItem.active}
onChange={updateFormValue}
name="active"
/>
}
label={LL.ACTIVE()}
<Grid container>
<BlockFormControlLabel
control={
<Checkbox
checked={editItem.active}
onChange={updateFormValue}
name="active"
/>
</Grid>
<Grid container>
{needsTimeField ? (
<>
<TextField
name="time"
type="time"
label={timeFieldLabel}
value={timeFieldValue}
margin="normal"
onChange={updateFormValue}
/>
{isTimerSchedule && (
<Typography
sx={{ ml: 2, mt: 4 }}
color="warning"
variant="body2"
>
{LL.SCHEDULER_HELP_2()}
</Typography>
)}
</>
) : (
<TextField
name="time"
label={timeFieldLabel}
multiline
fullWidth
value={timeFieldValue}
margin="normal"
onChange={updateFormValue}
/>
}
label={LL.ACTIVE()}
/>
<CircleIcon
color={editItem.active ? 'success' : 'error'}
sx={{
fontSize: 16,
mt: '12px'
}}
/>
</Grid>
<Grid container>
{needsTimeField ? (
<>
<TextField
name="time"
type="time"
label={timeFieldLabel}
value={timeFieldValue}
margin="normal"
onChange={updateFormValue}
/>
{isTimerSchedule && (
<Typography sx={{ ml: 2, mt: 4 }} color="warning" variant="body2">
00:00 = {LL.SCHEDULER_HELP_2()}
</Typography>
)}
</Grid>
</>
)}
<ValidatedTextField
fieldErrors={fieldErrors || {}}
name="cmd"
label={LL.COMMAND(0)}
multiline
fullWidth
value={editItem.cmd}
margin="normal"
onChange={updateFormValue}
/>
</>
) : (
<TextField
name="time"
label={timeFieldLabel}
multiline
fullWidth
value={timeFieldValue}
margin="normal"
onChange={updateFormValue}
/>
)}
</Grid>
<TextField
name="value"
label={LL.VALUE(0)}
multiline
margin="normal"
name="cmd_name"
label={LL.COMMAND(0)}
value={editItem.cmd_name}
fullWidth
value={editItem.value}
select
margin="normal"
onChange={updateFormValue}
/>
>
{commandNames.map((name) => (
<MenuItem key={name} value={name}>
{name}
</MenuItem>
))}
</TextField>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
name="name"
label={LL.NAME(0) + ' (' + LL.OPTIONAL() + ')'}
label={LL.NAME(0)}
value={editItem.name}
fullWidth
margin="normal"
@@ -420,16 +367,6 @@ const SchedulerDialog = ({
>
{creating ? LL.ADD(0) : LL.UPDATE()}
</Button>
{isImmediateSchedule && editItem.cmd !== '' && (
<Button
startIcon={<PlayArrowIcon />}
variant="outlined"
onClick={saveandactivate}
color="success"
>
{LL.EXECUTE()}
</Button>
)}
</DialogActions>
</Dialog>
);
+246 -290
View File
@@ -1,5 +1,4 @@
import { useCallback, useContext, useMemo, useRef, useState } from 'react';
import { toast } from 'react-toastify';
import { useContext, useRef, useState } from 'react';
import AddCircleOutlineOutlinedIcon from '@mui/icons-material/AddCircleOutlineOutlined';
import KeyboardArrowDownOutlinedIcon from '@mui/icons-material/KeyboardArrowDownOutlined';
@@ -21,6 +20,7 @@ import { useTheme } from '@table-library/react-table-library/theme';
import type { State } from '@table-library/react-table-library/types/common';
import { useRequest } from 'alova/client';
import { SectionContent, useLayoutTitle } from 'components';
import { toast } from 'components/toast';
import { AuthenticatedContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react';
import { useInterval } from 'utils';
@@ -158,18 +158,16 @@ const Sensors = () => {
}
);
const intervalCallback = useCallback(() => {
useInterval(() => {
if (!temperatureDialogOpen && !analogDialogOpen) {
void fetchSensorData();
}
}, [temperatureDialogOpen, analogDialogOpen, fetchSensorData]);
useInterval(intervalCallback);
});
const temperature_theme = useTheme([common_theme, temperature_theme_config]);
const analog_theme = useTheme([common_theme, analog_theme_config]);
const getSortIcon = useCallback((state: State, sortKey: unknown) => {
const getSortIcon = (state: State, sortKey: unknown) => {
if (state.sortKey === sortKey && state.reverse) {
return <KeyboardArrowDownOutlinedIcon />;
}
@@ -177,7 +175,7 @@ const Sensors = () => {
return <KeyboardArrowUpOutlinedIcon />;
}
return <UnfoldMoreOutlinedIcon />;
}, []);
};
const analog_sort = useSort(
{ nodes: sensorData.as },
@@ -234,119 +232,104 @@ const Sensors = () => {
useLayoutTitle(LL.SENSORS());
const formatDurationMin = useCallback(
(duration_min: number) => {
const totalMs = duration_min * MS_PER_MINUTE;
const days = Math.trunc(totalMs / MS_PER_DAY);
const hours = Math.trunc(totalMs / MS_PER_HOUR) % 24;
const minutes = Math.trunc(totalMs / MS_PER_MINUTE) % 60;
const formatDurationMin = (duration_min: number) => {
const totalMs = duration_min * MS_PER_MINUTE;
const days = Math.trunc(totalMs / MS_PER_DAY);
const hours = Math.trunc(totalMs / MS_PER_HOUR) % 24;
const minutes = Math.trunc(totalMs / MS_PER_MINUTE) % 60;
const parts: string[] = [];
if (days > 0) {
parts.push(LL.NUM_DAYS({ num: days }));
}
if (hours > 0) {
parts.push(LL.NUM_HOURS({ num: hours }));
}
if (minutes > 0) {
parts.push(LL.NUM_MINUTES({ num: minutes }));
}
return parts.join(' ');
},
[LL]
);
const parts: string[] = [];
if (days > 0) {
parts.push(LL.NUM_DAYS({ num: days }));
}
if (hours > 0) {
parts.push(LL.NUM_HOURS({ num: hours }));
}
if (minutes > 0) {
parts.push(LL.NUM_MINUTES({ num: minutes }));
}
return parts.join(' ');
};
const formatValue = useCallback(
(value: unknown, uom: DeviceValueUOM) => {
if (value === undefined) {
return '';
}
if (typeof value !== 'number') {
return value as string;
}
switch (uom) {
case DeviceValueUOM.HOURS:
return value ? formatDurationMin(value * 60) : LL.NUM_HOURS({ num: 0 });
case DeviceValueUOM.MINUTES:
return value ? formatDurationMin(value) : LL.NUM_MINUTES({ num: 0 });
case DeviceValueUOM.SECONDS:
return LL.NUM_SECONDS({ num: value });
case DeviceValueUOM.NONE:
return new Intl.NumberFormat().format(value);
case DeviceValueUOM.DEGREES:
case DeviceValueUOM.DEGREES_R:
case DeviceValueUOM.FAHRENHEIT:
return (
new Intl.NumberFormat(undefined, {
minimumFractionDigits: 1
}).format(value) +
' ' +
DeviceValueUOM_s[uom]
);
default:
return new Intl.NumberFormat().format(value) + ' ' + DeviceValueUOM_s[uom];
}
},
[formatDurationMin, LL]
);
const formatValue = (value: unknown, uom: DeviceValueUOM) => {
if (value === undefined) {
return '';
}
if (typeof value !== 'number') {
return value as string;
}
switch (uom) {
case DeviceValueUOM.HOURS:
return value ? formatDurationMin(value * 60) : LL.NUM_HOURS({ num: 0 });
case DeviceValueUOM.MINUTES:
return value ? formatDurationMin(value) : LL.NUM_MINUTES({ num: 0 });
case DeviceValueUOM.SECONDS:
return LL.NUM_SECONDS({ num: value });
case DeviceValueUOM.NONE:
return new Intl.NumberFormat().format(value);
case DeviceValueUOM.DEGREES:
case DeviceValueUOM.DEGREES_R:
case DeviceValueUOM.FAHRENHEIT:
return (
new Intl.NumberFormat(undefined, {
minimumFractionDigits: 1
}).format(value) +
' ' +
DeviceValueUOM_s[uom]
);
default:
return new Intl.NumberFormat().format(value) + ' ' + DeviceValueUOM_s[uom];
}
};
const updateTemperatureSensor = useCallback(
(ts: TemperatureSensor) => {
if (me.admin) {
ts.o_n = ts.n;
setSelectedTemperatureSensor(ts);
setTemperatureDialogOpen(true);
}
},
[me.admin]
);
const updateTemperatureSensor = (ts: TemperatureSensor) => {
if (me.admin) {
ts.o_n = ts.n;
setSelectedTemperatureSensor(ts);
setTemperatureDialogOpen(true);
}
};
const onTemperatureDialogClose = useCallback(() => {
const onTemperatureDialogClose = () => {
setTemperatureDialogOpen(false);
void fetchSensorData();
}, [fetchSensorData]);
};
const onTemperatureDialogSave = useCallback(
async (ts: TemperatureSensor) => {
await sendTemperatureSensor({
id: ts.id,
name: ts.n,
offset: ts.o,
is_system: ts.s
const onTemperatureDialogSave = async (ts: TemperatureSensor) => {
await sendTemperatureSensor({
id: ts.id,
name: ts.n,
offset: ts.o,
is_system: ts.s
})
.then(() => {
toast.success(LL.UPDATED_OF(LL.SENSOR(1)));
})
.then(() => {
toast.success(LL.UPDATED_OF(LL.SENSOR(1)));
})
.catch(() => {
toast.error(LL.UPDATE_OF(LL.SENSOR(2)) + ' ' + LL.FAILED(1));
})
.finally(() => {
setTemperatureDialogOpen(false);
setSelectedTemperatureSensor(undefined);
void fetchSensorData();
});
},
[sendTemperatureSensor, LL, fetchSensorData]
);
.catch(() => {
toast.error(LL.UPDATE_OF(LL.SENSOR(2)) + ' ' + LL.FAILED(1));
})
.finally(() => {
setTemperatureDialogOpen(false);
setSelectedTemperatureSensor(undefined);
void fetchSensorData();
});
};
const updateAnalogSensor = useCallback(
(as: AnalogSensor) => {
if (me.admin) {
setCreating(false);
as.o_n = as.n;
setSelectedAnalogSensor(as);
setAnalogDialogOpen(true);
}
},
[me.admin]
);
const updateAnalogSensor = (as: AnalogSensor) => {
if (me.admin) {
setCreating(false);
as.o_n = as.n;
setSelectedAnalogSensor(as);
setAnalogDialogOpen(true);
}
};
const onAnalogDialogClose = useCallback(() => {
const onAnalogDialogClose = () => {
setAnalogDialogOpen(false);
void fetchSensorData();
}, [fetchSensorData]);
};
const addAnalogSensor = useCallback(() => {
const addAnalogSensor = () => {
if (firstAvailableGPIO.current === undefined) {
toast.error(LL.NO_GPIO());
return;
@@ -366,194 +349,167 @@ const Sensors = () => {
o_n: ''
});
setAnalogDialogOpen(true);
}, []);
};
const onAnalogDialogSave = useCallback(
async (as: AnalogSensor) => {
await sendAnalogSensor({
id: as.id,
gpio: as.g,
name: as.n,
offset: as.o,
factor: as.f,
uom: as.u,
type: as.t,
deleted: as.d,
is_system: as.s
const onAnalogDialogSave = async (as: AnalogSensor) => {
await sendAnalogSensor({
id: as.id,
gpio: as.g,
name: as.n,
offset: as.o,
factor: as.f,
uom: as.u,
type: as.t,
deleted: as.d,
is_system: as.s
})
.then(() => {
toast.success(LL.UPDATED_OF(LL.ANALOG_SENSOR(2)));
})
.then(() => {
toast.success(LL.UPDATED_OF(LL.ANALOG_SENSOR(2)));
})
.catch(() => {
toast.error(LL.UPDATE_OF(LL.ANALOG_SENSOR(5)) + ' ' + LL.FAILED(1));
})
.finally(() => {
setAnalogDialogOpen(false);
setSelectedAnalogSensor(undefined);
void fetchSensorData();
});
},
[sendAnalogSensor, LL, fetchSensorData]
.catch(() => {
toast.error(LL.UPDATE_OF(LL.ANALOG_SENSOR(5)) + ' ' + LL.FAILED(1));
})
.finally(() => {
setAnalogDialogOpen(false);
setSelectedAnalogSensor(undefined);
void fetchSensorData();
});
};
const RenderAnalogSensors = (
<Table
data={{ nodes: sensorData.as }}
theme={analog_theme}
sort={analog_sort}
layout={{ custom: true }}
>
{(tableList: AnalogSensor[]) => (
<>
<Header>
<HeaderRow>
<HeaderCell stiff>
<Button
fullWidth
style={HEADER_BUTTON_STYLE}
endIcon={getSortIcon(analog_sort.state, 'GPIO')}
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'GPIO' })}
>
GPIO
</Button>
</HeaderCell>
<HeaderCell resize>
<Button
fullWidth
style={HEADER_BUTTON_STYLE}
endIcon={getSortIcon(analog_sort.state, 'NAME')}
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'NAME' })}
>
{LL.NAME(0)}
</Button>
</HeaderCell>
<HeaderCell stiff>
<Button
fullWidth
style={HEADER_BUTTON_STYLE}
endIcon={getSortIcon(analog_sort.state, 'TYPE')}
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'TYPE' })}
>
{LL.TYPE(0)}
</Button>
</HeaderCell>
<HeaderCell stiff>
<Button
fullWidth
style={HEADER_BUTTON_STYLE_END}
endIcon={getSortIcon(analog_sort.state, 'VALUE')}
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'VALUE' })}
>
{LL.VALUE(0)}
</Button>
</HeaderCell>
</HeaderRow>
</Header>
<Body>
{tableList.map((as: AnalogSensor) => (
<Row
style={{ color: as.s ? 'grey' : 'inherit' }}
key={as.id}
item={as}
onClick={() => updateAnalogSensor(as)}
>
<Cell stiff>{as.g}</Cell>
<Cell>{as.n}</Cell>
<Cell stiff>{AnalogTypeNames[as.t - 1]} </Cell>
{(as.t === AnalogType.DIGITAL_OUT &&
as.g !== GPIO_25 &&
as.g !== GPIO_26) ||
as.t === AnalogType.DIGITAL_IN ||
as.t === AnalogType.PULSE ? (
<Cell stiff>{as.v ? LL.ON() : LL.OFF()}</Cell>
) : (
<Cell stiff>{formatValue(as.v, as.u)}</Cell>
)}
</Row>
))}
</Body>
</>
)}
</Table>
);
const RenderAnalogSensors = useMemo(
() => (
<Table
data={{ nodes: sensorData.as }}
theme={analog_theme}
sort={analog_sort}
layout={{ custom: true }}
>
{(tableList: AnalogSensor[]) => (
<>
<Header>
<HeaderRow>
<HeaderCell stiff>
<Button
fullWidth
style={HEADER_BUTTON_STYLE}
endIcon={getSortIcon(analog_sort.state, 'GPIO')}
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'GPIO' })}
>
GPIO
</Button>
</HeaderCell>
<HeaderCell resize>
<Button
fullWidth
style={HEADER_BUTTON_STYLE}
endIcon={getSortIcon(analog_sort.state, 'NAME')}
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'NAME' })}
>
{LL.NAME(0)}
</Button>
</HeaderCell>
<HeaderCell stiff>
<Button
fullWidth
style={HEADER_BUTTON_STYLE}
endIcon={getSortIcon(analog_sort.state, 'TYPE')}
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'TYPE' })}
>
{LL.TYPE(0)}
</Button>
</HeaderCell>
<HeaderCell stiff>
<Button
fullWidth
style={HEADER_BUTTON_STYLE_END}
endIcon={getSortIcon(analog_sort.state, 'VALUE')}
onClick={() =>
analog_sort.fns.onToggleSort({ sortKey: 'VALUE' })
}
>
{LL.VALUE(0)}
</Button>
</HeaderCell>
</HeaderRow>
</Header>
<Body>
{tableList.map((as: AnalogSensor) => (
<Row
style={{ color: as.s ? 'grey' : 'inherit' }}
key={as.id}
item={as}
onClick={() => updateAnalogSensor(as)}
const RenderTemperatureSensors = (
<Table
data={{ nodes: sensorData.ts }}
theme={temperature_theme}
sort={temperature_sort}
layout={{ custom: true }}
>
{(tableList: TemperatureSensor[]) => (
<>
<Header>
<HeaderRow>
<HeaderCell resize>
<Button
fullWidth
style={HEADER_BUTTON_STYLE}
endIcon={getSortIcon(temperature_sort.state, 'NAME')}
onClick={() =>
temperature_sort.fns.onToggleSort({ sortKey: 'NAME' })
}
>
<Cell stiff>{as.g}</Cell>
<Cell>{as.n}</Cell>
<Cell stiff>{AnalogTypeNames[as.t - 1]} </Cell>
{(as.t === AnalogType.DIGITAL_OUT &&
as.g !== GPIO_25 &&
as.g !== GPIO_26) ||
as.t === AnalogType.DIGITAL_IN ||
as.t === AnalogType.PULSE ? (
<Cell stiff>{as.v ? LL.ON() : LL.OFF()}</Cell>
) : (
<Cell stiff>{formatValue(as.v, as.u)}</Cell>
)}
</Row>
))}
</Body>
</>
)}
</Table>
),
[
analog_sort,
analog_theme,
getSortIcon,
sensorData.as,
LL,
updateAnalogSensor,
formatValue
]
);
const RenderTemperatureSensors = useMemo(
() => (
<Table
data={{ nodes: sensorData.ts }}
theme={temperature_theme}
sort={temperature_sort}
layout={{ custom: true }}
>
{(tableList: TemperatureSensor[]) => (
<>
<Header>
<HeaderRow>
<HeaderCell resize>
<Button
fullWidth
style={HEADER_BUTTON_STYLE}
endIcon={getSortIcon(temperature_sort.state, 'NAME')}
onClick={() =>
temperature_sort.fns.onToggleSort({ sortKey: 'NAME' })
}
>
{LL.NAME(0)}
</Button>
</HeaderCell>
<HeaderCell stiff>
<Button
fullWidth
style={HEADER_BUTTON_STYLE_END}
endIcon={getSortIcon(temperature_sort.state, 'VALUE')}
onClick={() =>
temperature_sort.fns.onToggleSort({ sortKey: 'VALUE' })
}
>
{LL.VALUE(0)}
</Button>
</HeaderCell>
</HeaderRow>
</Header>
<Body>
{tableList.map((ts: TemperatureSensor) => (
<Row
style={{ color: ts.s ? 'grey' : 'inherit' }}
key={ts.id}
item={ts}
onClick={() => updateTemperatureSensor(ts)}
{LL.NAME(0)}
</Button>
</HeaderCell>
<HeaderCell stiff>
<Button
fullWidth
style={HEADER_BUTTON_STYLE_END}
endIcon={getSortIcon(temperature_sort.state, 'VALUE')}
onClick={() =>
temperature_sort.fns.onToggleSort({ sortKey: 'VALUE' })
}
>
<Cell>{ts.n}</Cell>
<Cell>{formatValue(ts.t, ts.u)}</Cell>
</Row>
))}
</Body>
</>
)}
</Table>
),
[
temperature_sort,
temperature_theme,
getSortIcon,
sensorData.ts,
LL,
updateTemperatureSensor,
formatValue
]
{LL.VALUE(0)}
</Button>
</HeaderCell>
</HeaderRow>
</Header>
<Body>
{tableList.map((ts: TemperatureSensor) => (
<Row
style={{ color: ts.s ? 'grey' : 'inherit' }}
key={ts.id}
item={ts}
onClick={() => updateTemperatureSensor(ts)}
>
<Cell>{ts.n}</Cell>
<Cell>{formatValue(ts.t, ts.u)}</Cell>
</Row>
))}
</Body>
</>
)}
</Table>
);
return (
+60 -94
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useEffect, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel';
import DoneIcon from '@mui/icons-material/Done';
@@ -18,12 +18,12 @@ import {
} from '@mui/material';
import { dialogStyle } from 'CustomTheme';
import type Schema from 'async-validator';
import type { ValidateFieldsError } from 'async-validator';
import { ValidatedTextField } from 'components';
import { useI18nContext } from 'i18n/i18n-react';
import { numberValue, updateValue } from 'utils';
import { ValidationError, validate } from 'validators';
import type Schema from 'validators/schema';
import type { ValidateFieldsError } from 'validators/schema';
import { AnalogType, AnalogTypeNames, DeviceValueUOM_s } from './types';
import type { AnalogSensor } from './types';
@@ -53,84 +53,54 @@ const SensorsAnalogDialog = ({
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const [editItem, setEditItem] = useState<AnalogSensor>(selectedItem);
const updateFormValue = useMemo(
() =>
updateValue((updater) =>
setEditItem(
(prev) =>
updater(
prev as unknown as Record<string, unknown>
) as unknown as AnalogSensor
)
),
[setEditItem]
const updateFormValue = updateValue((updater) =>
setEditItem(
(prev) =>
updater(
prev as unknown as Record<string, unknown>
) as unknown as AnalogSensor
)
);
// Memoize helper functions to check sensor type conditions
const isCounterOrRate = useMemo(
() =>
editItem.t === AnalogType.COUNTER ||
editItem.t === AnalogType.RATE ||
(editItem.t >= AnalogType.CNT_0 && editItem.t <= AnalogType.CNT_2),
[editItem.t]
);
const isCounter = useMemo(
() =>
editItem.t === AnalogType.COUNTER ||
(editItem.t >= AnalogType.CNT_0 && editItem.t <= AnalogType.CNT_2),
[editItem.t]
);
const isFreqType = useMemo(
() => editItem.t >= AnalogType.FREQ_0 && editItem.t <= AnalogType.FREQ_2,
[editItem.t]
);
const isPWM = useMemo(
() =>
editItem.t === AnalogType.PWM_0 ||
editItem.t === AnalogType.PWM_1 ||
editItem.t === AnalogType.PWM_2,
[editItem.t]
);
const isDACOutGPIO = useMemo(
() =>
editItem.t === AnalogType.DIGITAL_OUT &&
(editItem.g === 25 || editItem.g === 26),
[editItem.t, editItem.g]
);
const isDigitalOutGPIO = useMemo(
() =>
editItem.t === AnalogType.DIGITAL_OUT &&
editItem.g !== 25 &&
editItem.g !== 26,
[editItem.t, editItem.g]
);
const isCounterOrRate =
editItem.t === AnalogType.COUNTER ||
editItem.t === AnalogType.RATE ||
(editItem.t >= AnalogType.CNT_0 && editItem.t <= AnalogType.CNT_2);
const isCounter =
editItem.t === AnalogType.COUNTER ||
(editItem.t >= AnalogType.CNT_0 && editItem.t <= AnalogType.CNT_2);
const isFreqType =
editItem.t >= AnalogType.FREQ_0 && editItem.t <= AnalogType.FREQ_2;
const isPWM =
editItem.t === AnalogType.PWM_0 ||
editItem.t === AnalogType.PWM_1 ||
editItem.t === AnalogType.PWM_2;
const isDACOutGPIO =
editItem.t === AnalogType.DIGITAL_OUT &&
(editItem.g === 25 || editItem.g === 26);
const isDigitalOutGPIO =
editItem.t === AnalogType.DIGITAL_OUT && editItem.g !== 25 && editItem.g !== 26;
// Memoize menu items to avoid recreation on each render
const analogTypeMenuItems = useMemo(
() =>
AnalogTypeNames.map((val, i) => ({ name: val, value: i + 1 }))
.sort((a, b) => a.name.localeCompare(b.name))
.map(({ name, value }) => (
<MenuItem
key={name}
value={value}
disabled={disabledTypeList?.includes(value)}
>
{name}
</MenuItem>
)),
[disabledTypeList]
);
const analogTypeMenuItems = AnalogTypeNames.map((val, i) => ({
name: val,
value: i + 1
}))
.sort((a, b) => a.name.localeCompare(b.name))
.map(({ name, value }) => (
<MenuItem
key={name}
value={value}
disabled={disabledTypeList?.includes(value)}
>
{name}
</MenuItem>
));
const uomMenuItems = useMemo(
() =>
DeviceValueUOM_s.map((val, i) => (
<MenuItem key={val} value={i}>
{val}
</MenuItem>
)),
[]
);
const uomMenuItems = DeviceValueUOM_s.map((val, i) => (
<MenuItem key={val} value={i}>
{val}
</MenuItem>
));
const analogGPIOMenuItems = () =>
// add selectedItem.g to the list
@@ -157,16 +127,16 @@ const SensorsAnalogDialog = ({
}
}, [open, selectedItem]);
const handleClose = useCallback(
(_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => {
if (reason !== 'backdropClick') {
onClose();
}
},
[onClose]
);
const handleClose = (
_event: React.SyntheticEvent,
reason: 'backdropClick' | 'escapeKeyDown'
) => {
if (reason !== 'backdropClick') {
onClose();
}
};
const save = useCallback(async () => {
const save = async () => {
try {
setFieldErrors(undefined);
await validate(validator, editItem);
@@ -174,17 +144,13 @@ const SensorsAnalogDialog = ({
} catch (error) {
setFieldErrors((error as ValidationError).fieldErrors);
}
}, [validator, editItem, onSave]);
};
const remove = useCallback(() => {
const remove = () => {
onSave({ ...editItem, d: true });
}, [editItem, onSave]);
};
const dialogTitle = useMemo(
() =>
`${creating ? LL.ADD(1) + ' ' + LL.NEW(0) : LL.EDIT()} ${LL.ANALOG_SENSOR(0)}`,
[creating, LL]
);
const dialogTitle = `${creating ? LL.ADD(1) + ' ' + LL.NEW(0) : LL.EDIT()} ${LL.ANALOG_SENSOR(0)}`;
return (
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useEffect, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel';
import DoneIcon from '@mui/icons-material/Done';
@@ -16,12 +16,12 @@ import {
} from '@mui/material';
import { dialogStyle } from 'CustomTheme';
import type Schema from 'async-validator';
import type { ValidateFieldsError } from 'async-validator';
import { ValidatedTextField } from 'components';
import { useI18nContext } from 'i18n/i18n-react';
import { numberValue, updateValue } from 'utils';
import { ValidationError, validate } from 'validators';
import type Schema from 'validators/schema';
import type { ValidateFieldsError } from 'validators/schema';
import type { TemperatureSensor } from './types';
@@ -50,16 +50,12 @@ const SensorsTemperatureDialog = ({
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const [editItem, setEditItem] = useState<TemperatureSensor>(selectedItem);
const updateFormValue = useMemo(
() =>
updateValue(
setEditItem as unknown as (
updater: (
prevState: Readonly<Record<string, unknown>>
) => Record<string, unknown>
) => void
),
[setEditItem]
const updateFormValue = updateValue(
setEditItem as unknown as (
updater: (
prevState: Readonly<Record<string, unknown>>
) => Record<string, unknown>
) => void
);
useEffect(() => {
@@ -69,16 +65,13 @@ const SensorsTemperatureDialog = ({
}
}, [open, selectedItem]);
const handleClose = useCallback(
(_event: React.SyntheticEvent, reason?: string) => {
if (reason !== 'backdropClick') {
onClose();
}
},
[onClose]
);
const handleClose = (_event: React.SyntheticEvent, reason?: string) => {
if (reason !== 'backdropClick') {
onClose();
}
};
const save = useCallback(async () => {
const save = async () => {
try {
setFieldErrors(undefined);
await validate(validator, editItem);
@@ -86,29 +79,11 @@ const SensorsTemperatureDialog = ({
} catch (error) {
setFieldErrors((error as ValidationError).fieldErrors);
}
}, [validator, editItem, onSave]);
const dialogTitle = useMemo(() => `${LL.EDIT()} ${LL.TEMP_SENSOR()}`, [LL]);
const offsetValue = useMemo(() => numberValue(editItem.o), [editItem.o]);
const slotProps = useMemo(
() => ({
input: {
startAdornment: <InputAdornment position="start">{TEMP_UNIT}</InputAdornment>
},
htmlInput: {
min: OFFSET_MIN,
max: OFFSET_MAX,
step: OFFSET_STEP
}
}),
[]
);
};
return (
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
<DialogTitle>{dialogTitle}</DialogTitle>
<DialogTitle>{`${LL.EDIT()} ${LL.TEMP_SENSOR()}`}</DialogTitle>
<DialogContent dividers>
<Typography sx={{ mb: 2 }} color="warning" variant="body2">
{LL.ID_OF(LL.SENSOR(0))}: {editItem.id}
@@ -128,12 +103,23 @@ const SensorsTemperatureDialog = ({
<TextField
name="o"
label={LL.OFFSET()}
value={offsetValue}
value={numberValue(editItem.o)}
sx={{ width: '11ch' }}
type="number"
variant="outlined"
onChange={updateFormValue}
slotProps={slotProps}
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">{TEMP_UNIT}</InputAdornment>
)
},
htmlInput: {
min: OFFSET_MIN,
max: OFFSET_MAX,
step: OFFSET_STEP
}
}}
/>
</Grid>
</Grid>
+3 -3
View File
@@ -1,4 +1,4 @@
import { memo, useCallback, useContext } from 'react';
import { memo, useContext } from 'react';
import PersonIcon from '@mui/icons-material/Person';
import {
@@ -23,9 +23,9 @@ const UserProfileComponent = () => {
useLayoutTitle(LL.USER_PROFILE());
const handleSignOut = useCallback(() => {
const handleSignOut = () => {
signOut(true);
}, [signOut]);
};
return (
<SectionContent>
+47 -8
View File
@@ -1,4 +1,5 @@
export interface Settings {
system_name: string;
locale: string;
tx_mode: number;
ems_bus_id: number;
@@ -43,7 +44,17 @@ export interface Settings {
modbus_port: number;
modbus_max_clients: number;
modbus_timeout: number;
email_enabled: boolean;
email_security: number;
email_server: string;
email_port: number;
email_login: string;
email_pass: string;
email_sender: string;
email_recp: string;
email_subject: string;
developer_mode: boolean;
disable_reset: boolean;
}
export enum busConnectionStatus {
@@ -199,6 +210,18 @@ export enum DeviceValueUOM {
HERTZ
}
export type DeviceValueKind =
'value' | 'scheduler' | 'command' | 'deviceCommand' | 'deviceEntity';
export function getDeviceValueKind(item: DeviceValue): DeviceValueKind {
if (!item.c) return 'value';
if (item.v !== undefined && item.l !== undefined && item.u === undefined)
return 'scheduler';
if (item.v === undefined && item.l === undefined) return 'command';
if (item.u === DeviceValueUOM.NONE) return 'deviceCommand';
return 'deviceEntity';
}
export const DeviceValueUOM_s = [
'',
'°C',
@@ -284,6 +307,7 @@ export const BOARD_PROFILES = {
LOLIN: 'Lolin D32',
OLIMEX: 'Olimex ESP32-EVB',
OLIMEXPOE: 'Olimex ESP32-POE',
OLIMEXPOEW: 'Olimex ESP32-POE-ISO WROVER (PSRAM)',
C3MINI: 'Wemos C3 Mini',
S2MINI: 'Wemos S2 Mini',
S3MINI: 'Liligo S3'
@@ -339,21 +363,19 @@ export enum DeviceEntityMask {
}
export interface ScheduleItem {
id: number; // unique index
id: number; // unique index for table
active: boolean;
deleted?: boolean;
flags: number;
time: string; // also used for Condition and On Change
cmd: string;
value: string;
name: string; // can be empty
cmd_name: string; // references a named Command
name: string;
o_id?: number;
o_active?: boolean;
o_deleted?: boolean;
o_flags?: number;
o_time?: string;
o_cmd?: string;
o_value?: string;
o_cmd_name?: string;
o_name?: string;
}
@@ -361,6 +383,23 @@ export interface Schedule {
readonly schedule: readonly ScheduleItem[];
}
export interface CommandItem {
id: number;
cmd: string;
value: string;
name: string;
deleted?: boolean;
o_id?: number;
o_cmd?: string;
o_value?: string;
o_name?: string;
o_deleted?: boolean;
}
export interface Commands {
readonly commands: readonly CommandItem[];
}
export interface ModuleItem {
id: number; // unique index
key: string;
@@ -391,8 +430,7 @@ export enum ScheduleFlag {
SCHEDULE_DAY = 0, // no bits set
SCHEDULE_TIMER = 128, // bit 8
SCHEDULE_ONCHANGE = 129, // bit 1
SCHEDULE_CONDITION = 130, // bit 2
SCHEDULE_IMMEDIATE = 132 // bit 3
SCHEDULE_CONDITION = 130 // bit 2
}
export interface EntityItem {
@@ -435,6 +473,7 @@ export const enum DeviceType {
ANALOGSENSOR = 2,
SCHEDULER = 3,
CUSTOM = 4,
COMMAND = 5,
BOILER,
THERMOSTAT,
MIXER,
+26 -3
View File
@@ -1,9 +1,10 @@
import Schema from 'async-validator';
import type { InternalRuleItem } from 'async-validator';
import Schema from 'validators/schema';
import type { InternalRuleItem } from 'validators/schema';
import { IP_OR_HOSTNAME_VALIDATOR } from 'validators/shared';
import type {
AnalogSensor,
CommandItem,
DeviceValue,
EntityItem,
ScheduleItem,
@@ -232,7 +233,29 @@ export const schedulerItemValidation = (
scheduleItem: ScheduleItem
) =>
new Schema({
name: [NAME_PATTERN, uniqueNameValidator(schedule, scheduleItem.o_name)],
name: [
{ required: true, message: 'Name is required' },
NAME_PATTERN_REQUIRED,
uniqueNameValidator(schedule, scheduleItem.o_name)
],
cmd_name: [{ required: true, message: 'Command is required' }]
});
export const uniqueCommandNameValidator = (
commands: CommandItem[],
o_name?: string
) => createUniqueNameValidator(commands, o_name);
export const commandItemValidation = (
commands: CommandItem[],
commandItem: CommandItem
) =>
new Schema({
name: [
{ required: true, message: 'Name is required' },
NAME_PATTERN_REQUIRED,
uniqueCommandNameValidator(commands, commandItem.o_name)
],
cmd: [
{ required: true, message: 'Command is required' },
{
+10 -20
View File
@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from 'react';
import { useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel';
import WarningIcon from '@mui/icons-material/Warning';
@@ -6,7 +6,6 @@ import { Button, Checkbox, MenuItem } from '@mui/material';
import * as APApi from 'api/ap';
import type { ValidateFieldsError } from 'async-validator';
import {
BlockFormControlLabel,
BlockNavigation,
@@ -22,9 +21,9 @@ import type { APSettingsType } from 'types';
import { APProvisionMode } from 'types';
import { numberValue, updateValueDirty, useRest } from 'utils';
import { ValidationError, createAPSettingsValidator, validate } from 'validators';
import type { ValidateFieldsError } from 'validators/schema';
export const isAPEnabled = ({ provision_mode }: APSettingsType) =>
provision_mode === APProvisionMode.AP_MODE_ALWAYS ||
provision_mode === APProvisionMode.AP_MODE_DISCONNECTED;
// Efficient range function without recursion
@@ -63,22 +62,16 @@ const APSettings = () => {
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const updateFormValue = useMemo(
() =>
updateValueDirty(
origData as unknown as Record<string, unknown>,
dirtyFlags,
setDirtyFlags,
updateDataValue as (value: unknown) => void
),
[origData, dirtyFlags, setDirtyFlags, updateDataValue]
const updateFormValue = updateValueDirty(
origData as unknown as Record<string, unknown>,
dirtyFlags,
setDirtyFlags,
updateDataValue as (value: unknown) => void
);
// Memoize AP enabled state
const apEnabled = useMemo(() => (data ? isAPEnabled(data) : false), [data]);
const apEnabled = data ? isAPEnabled(data) : false;
// Memoize validation and submit handler
const validateAndSubmit = useCallback(async () => {
const validateAndSubmit = async () => {
if (!data) return;
try {
@@ -88,7 +81,7 @@ const APSettings = () => {
} catch (error) {
setFieldErrors((error as ValidationError).fieldErrors);
}
}, [data, saveData]);
};
const content = () => {
if (!data) {
@@ -108,9 +101,6 @@ const APSettings = () => {
onChange={updateFormValue}
margin="normal"
>
<MenuItem value={APProvisionMode.AP_MODE_ALWAYS}>
{LL.AP_PROVIDE_TEXT_1()}
</MenuItem>
<MenuItem value={APProvisionMode.AP_MODE_DISCONNECTED}>
{LL.AP_PROVIDE_TEXT_2()}
</MenuItem>
@@ -1,5 +1,4 @@
import { useCallback, useMemo, useState } from 'react';
import { toast } from 'react-toastify';
import { useContext, useEffect, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel';
import PowerSettingsNewIcon from '@mui/icons-material/PowerSettingsNew';
@@ -20,7 +19,6 @@ import { readSystemStatus } from 'api/system';
import { useRequest } from 'alova/client';
import SystemMonitor from 'app/status/SystemMonitor';
import type { ValidateFieldsError } from 'async-validator';
import {
BlockFormControlLabel,
BlockNavigation,
@@ -28,12 +26,16 @@ import {
FormLoader,
MessageBox,
SectionContent,
ValidatedPasswordField,
ValidatedTextField,
useLayoutTitle
} from 'components';
import { toast } from 'components/toast';
import { AuthenticatedContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react';
import { numberValue, updateValueDirty, useRest } from 'utils';
import { ValidationError, validate } from 'validators';
import type { ValidateFieldsError } from 'validators/schema';
import { API, getBoardProfile, readSettings, writeSettings } from '../../api/app';
import { BOARD_PROFILES } from '../main/types';
@@ -67,6 +69,14 @@ const ApplicationSettings = () => {
update: writeSettings
});
// the menu drawer reads the system name from the authentication context
const { setSystemName } = useContext(AuthenticatedContext);
useEffect(() => {
if (origData?.system_name !== undefined) {
setSystemName(origData.system_name);
}
}, [origData?.system_name, setSystemName]);
const [restarting, setRestarting] = useState<boolean>();
const { LL } = useI18nContext();
@@ -106,82 +116,79 @@ const ApplicationSettings = () => {
});
});
// Memoized input props to prevent recreation on every render
const SecondsInputProps = useMemo(
() => ({
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
}),
[LL]
);
const SecondsInputProps = {
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
};
const MinutesInputProps = useMemo(
() => ({
endAdornment: <InputAdornment position="end">{LL.MINUTES()}</InputAdornment>
}),
[LL]
);
const MinutesInputProps = {
endAdornment: <InputAdornment position="end">{LL.MINUTES()}</InputAdornment>
};
const HoursInputProps = useMemo(
() => ({
endAdornment: <InputAdornment position="end">{LL.HOURS()}</InputAdornment>
}),
[LL]
);
const HoursInputProps = {
endAdornment: <InputAdornment position="end">{LL.HOURS()}</InputAdornment>
};
const doRestart = useCallback(async () => {
const doRestart = async () => {
setRestarting(true);
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
(error: Error) => {
toast.error(error.message);
}
);
}, [sendAPI]);
};
const updateBoardProfile = useCallback(
async (board_profile: string) => {
await readBoardProfile(board_profile).catch((error: Error) => {
toast.error(error.message);
});
},
[readBoardProfile]
);
const updateBoardProfile = async (board_profile: string) => {
await readBoardProfile(board_profile).catch((error: Error) => {
toast.error(error.message);
});
};
useLayoutTitle(LL.APPLICATION());
const validateAndSubmit = useCallback(async () => {
const validateAndSubmit = async () => {
try {
setFieldErrors(undefined);
await validate(createSettingsValidator(data), data);
await saveData();
} catch (error) {
setFieldErrors((error as ValidationError).fieldErrors);
} finally {
await saveData();
}
}, [data, saveData]);
};
const changeBoardProfile = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const boardProfile = event.target.value;
updateFormValue(event);
if (boardProfile === 'CUSTOM') {
updateDataValue({
...data,
board_profile: boardProfile
});
} else {
void updateBoardProfile(boardProfile);
}
},
[data, updateBoardProfile, updateFormValue, updateDataValue]
);
const changeBoardProfile = (event: React.ChangeEvent<HTMLInputElement>) => {
const boardProfile = event.target.value;
updateFormValue(event);
if (boardProfile === 'CUSTOM') {
updateDataValue({
...data,
board_profile: boardProfile
});
} else {
void updateBoardProfile(boardProfile);
}
};
const restart = useCallback(async () => {
const restart = async () => {
await validateAndSubmit();
await doRestart();
}, [validateAndSubmit, doRestart]);
};
// Memoize board profile select items to prevent recreation
const boardProfileItems = useMemo(() => boardProfileSelectItems(), []);
const sendmail = async () => {
await sendAPI({
device: 'system',
cmd: 'sendmail',
data: '"Email notification test successful!"',
id: 0
})
.then(() => {
toast.success(LL.TEST_EMAIL_SUCCESSFUL());
})
.catch((error: Error) => {
toast.error(error.message);
});
};
const boardProfileItems = boardProfileSelectItems();
const content = () => {
if (!data || !hardwareData) {
@@ -190,7 +197,19 @@ const ApplicationSettings = () => {
return (
<>
<Typography sx={{ pb: 1 }} variant="h6" color="primary">
<Typography variant="h6" color="primary">
{LL.SYSTEM(0)}&nbsp;
{LL.CUSTOMIZATIONS()}
</Typography>
<TextField
name="system_name"
label={LL.SYSTEM_NAME()}
value={data.system_name}
variant="outlined"
onChange={updateFormValue}
margin="normal"
/>
<Typography sx={{ pb: 1, pt: 2 }} variant="h6" color="primary">
{LL.SERVICES()}
</Typography>
<Typography color="secondary">API</Typography>
@@ -351,6 +370,148 @@ const ApplicationSettings = () => {
</Grid>
</Grid>
)}
<Typography color="secondary">eMail</Typography>
<BlockFormControlLabel
control={
<Checkbox
checked={data.email_enabled}
onChange={updateFormValue}
name="email_enabled"
disabled={!hardwareData.psram}
/>
}
label={
<Typography color={!hardwareData.psram ? 'grey' : 'default'}>
Enable eMail notification
{!hardwareData.psram && (
<Typography variant="caption">
&nbsp; &#40;{LL.IS_REQUIRED('PSRAM')}&#41;
</Typography>
)}
</Typography>
}
/>
{data.email_enabled && (
<>
<Grid
container
spacing={2}
direction="row"
sx={{ justifyContent: 'flex-start', alignItems: 'flex-start' }}
>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
name="email_server"
label="SMTP Server"
variant="outlined"
value={data.email_server}
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
sx={{ width: '12ch' }}
name="email_port"
variant="outlined"
label="Port"
value={numberValue(data.email_port)}
type="number"
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid>
<TextField
sx={{ width: '20ch' }}
name="email_security"
label={LL.SECURITY(0)}
value={data.email_security}
variant="outlined"
onChange={updateFormValue}
margin="normal"
select
>
<MenuItem value={0}>{LL.OFF()}</MenuItem>
<MenuItem value={1}>SSL</MenuItem>
<MenuItem value={2}>StartTLS</MenuItem>
</TextField>
</Grid>
</Grid>
<Grid container spacing={2} rowSpacing={0}>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
name="email_login"
label="Login"
variant="outlined"
value={data.email_login}
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid>
<ValidatedPasswordField
fieldErrors={fieldErrors || {}}
name="email_pass"
label="Password"
variant="outlined"
value={data.email_pass}
onChange={updateFormValue}
margin="normal"
/>
</Grid>
</Grid>
<Grid container spacing={2} rowSpacing={0}>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
name="email_sender"
label="From"
variant="outlined"
value={data.email_sender}
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
name="email_recp"
label="To"
variant="outlined"
value={data.email_recp}
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
name="email_subject"
label="Subject"
variant="outlined"
value={data.email_subject}
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid>
<Button
sx={{ mt: 3 }}
variant="outlined"
color="primary"
disabled={dirtyFlags.length !== 0}
onClick={sendmail}
>
Send test email
</Button>
</Grid>
</Grid>
</>
)}
<Typography sx={{ pb: 1, pt: 2 }} variant="h6" color="primary">
{LL.SENSORS()}
</Typography>
@@ -751,6 +912,16 @@ const ApplicationSettings = () => {
}
label={LL.DEVELOPER_MODE()}
/>
<BlockFormControlLabel
control={
<Checkbox
checked={data.disable_reset}
onChange={updateFormValue}
name="disable_reset"
/>
}
label={LL.DISABLE_RESET()}
/>
<BlockFormControlLabel
control={
<Checkbox
+27 -41
View File
@@ -1,5 +1,4 @@
import { useCallback, useMemo, useState } from 'react';
import { toast } from 'react-toastify';
import { useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel';
import DownloadIcon from '@mui/icons-material/GetApp';
@@ -27,6 +26,7 @@ import {
SingleUpload,
useLayoutTitle
} from 'components';
import { toast } from 'components/toast';
import { useI18nContext } from 'i18n/i18n-react';
import { saveFile } from 'utils';
@@ -57,7 +57,7 @@ const DownloadUpload = () => {
const { data, send: loadData, error } = useRequest(SystemApi.readSystemStatus);
const doRestart = useCallback(async () => {
const doRestart = async () => {
setRestarting(true);
try {
await sendAPI({ device: 'system', cmd: 'restart', id: 0 });
@@ -65,16 +65,33 @@ const DownloadUpload = () => {
toast.error((error as Error).message);
setRestarting(false);
}
}, [sendAPI]);
};
useLayoutTitle(LL.DOWNLOAD_UPLOAD());
const handleCloseBackupDialog = useCallback(() => {
const handleCloseBackupDialog = () => {
setConfirmBackup(false);
}, []);
};
const renderBackupDialog = useMemo(
() => (
const handleDownload = (type: string) => () => {
void sendExportData(type);
setConfirmBackup(false);
};
if (restarting) {
return <SystemMonitor />;
}
if (!data) {
return (
<SectionContent>
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
</SectionContent>
);
}
return (
<SectionContent>
<Dialog
sx={dialogStyle}
open={confirmBackup}
@@ -98,40 +115,13 @@ const DownloadUpload = () => {
<Button
startIcon={<DownloadIcon />}
variant="outlined"
onClick={() => handleDownload('systembackup')()}
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 (
<SectionContent>
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
</SectionContent>
);
}
return (
<SectionContent>
{renderBackupDialog}
<Typography sx={{ pb: 2 }} variant="h6" color="primary">
{LL.DOWNLOAD(0)}
@@ -175,11 +165,7 @@ const DownloadUpload = () => {
{LL.UPLOAD()}
</Typography>
<Typography sx={{ pb: 2 }} color="warning" variant="body1">
{LL.UPLOAD_TEXT()}:
</Typography>
<SingleUpload doRestart={doRestart} />
<SingleUpload text={LL.UPLOAD_BACKUP_TEXT()} doRestart={doRestart} />
</SectionContent>
);
};
+34 -46
View File
@@ -1,5 +1,4 @@
import { useCallback, useMemo, useState } from 'react';
import { toast } from 'react-toastify';
import { useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel';
import SettingsBackupRestoreIcon from '@mui/icons-material/SettingsBackupRestore';
@@ -17,7 +16,6 @@ import {
import * as MqttApi from 'api/mqtt';
import type { ValidateFieldsError } from 'async-validator';
import {
BlockFormControlLabel,
BlockNavigation,
@@ -28,10 +26,12 @@ import {
ValidatedTextField,
useLayoutTitle
} from 'components';
import { toast } from 'components/toast';
import { useI18nContext } from 'i18n/i18n-react';
import type { MqttSettingsType } from 'types';
import { numberValue, updateValueDirty, useRest } from 'utils';
import { ValidationError, createMqttSettingsValidator, validate } from 'validators';
import type { ValidateFieldsError } from 'validators/schema';
import { callAction } from '../../api/app';
@@ -57,7 +57,7 @@ const MqttSettings = () => {
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const sendResetMQTT = useCallback(() => {
const sendResetMQTT = () => {
void callAction({ action: 'resetMQTT' })
.then(() => {
toast.success('MQTT ' + LL.REFRESH() + ' successful');
@@ -65,29 +65,20 @@ const MqttSettings = () => {
.catch((error) => {
toast.error(String(error.error?.message || 'An error occurred'));
});
}, []);
};
const updateFormValue = useMemo(
() =>
updateValueDirty(
origData as unknown as Record<string, unknown>,
dirtyFlags,
setDirtyFlags,
updateDataValue as (value: unknown) => void
),
[origData, dirtyFlags, setDirtyFlags, updateDataValue]
const updateFormValue = updateValueDirty(
origData as unknown as Record<string, unknown>,
dirtyFlags,
setDirtyFlags,
updateDataValue as (value: unknown) => void
);
const SecondsInputProps = useMemo(
() => ({
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
}),
[LL]
);
const SecondsInputProps = {
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
};
const emptyFieldErrors = useMemo(() => ({}), []);
const validateAndSubmit = useCallback(async () => {
const validateAndSubmit = async () => {
if (!data) return;
try {
setFieldErrors(undefined);
@@ -96,25 +87,22 @@ const MqttSettings = () => {
} catch (error) {
setFieldErrors((error as ValidationError).fieldErrors);
}
}, [data, saveData]);
};
const publishIntervalFields = useMemo(
() => [
{ name: 'publish_time_heartbeat', label: 'Heartbeat', validated: true },
{ name: 'publish_time_boiler', label: LL.MQTT_INT_BOILER(), validated: false },
{
name: 'publish_time_thermostat',
label: LL.MQTT_INT_THERMOSTATS(),
validated: false
},
{ name: 'publish_time_solar', label: LL.MQTT_INT_SOLAR(), validated: false },
{ name: 'publish_time_mixer', label: LL.MQTT_INT_MIXER(), validated: false },
{ name: 'publish_time_water', label: LL.MQTT_INT_WATER(), validated: false },
{ name: 'publish_time_sensor', label: LL.SENSORS(), validated: false },
{ name: 'publish_time_other', label: LL.DEFAULT(0), validated: false }
],
[LL]
);
const publishIntervalFields = [
{ name: 'publish_time_heartbeat', label: 'Heartbeat', validated: true },
{ name: 'publish_time_boiler', label: LL.MQTT_INT_BOILER(), validated: false },
{
name: 'publish_time_thermostat',
label: LL.MQTT_INT_THERMOSTATS(),
validated: false
},
{ name: 'publish_time_solar', label: LL.MQTT_INT_SOLAR(), validated: false },
{ name: 'publish_time_mixer', label: LL.MQTT_INT_MIXER(), validated: false },
{ name: 'publish_time_water', label: LL.MQTT_INT_WATER(), validated: false },
{ name: 'publish_time_sensor', label: LL.SENSORS(), validated: false },
{ name: 'publish_time_other', label: LL.DEFAULT(0), validated: false }
];
if (!data) {
return (
@@ -154,7 +142,7 @@ const MqttSettings = () => {
<Grid container spacing={2} rowSpacing={0}>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors ?? emptyFieldErrors}
fieldErrors={fieldErrors ?? {}}
name="host"
label={LL.ADDRESS_OF(LL.BROKER())}
multiline
@@ -166,7 +154,7 @@ const MqttSettings = () => {
</Grid>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors ?? emptyFieldErrors}
fieldErrors={fieldErrors ?? {}}
name="port"
label="Port"
variant="outlined"
@@ -178,7 +166,7 @@ const MqttSettings = () => {
</Grid>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors ?? emptyFieldErrors}
fieldErrors={fieldErrors ?? {}}
name="base"
label={LL.BASE_TOPIC()}
variant="outlined"
@@ -219,7 +207,7 @@ const MqttSettings = () => {
</Grid>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors ?? emptyFieldErrors}
fieldErrors={fieldErrors ?? {}}
name="keep_alive"
label="Keep Alive"
slotProps={{
@@ -438,7 +426,7 @@ const MqttSettings = () => {
<Grid key={field.name}>
{field.validated ? (
<ValidatedTextField
fieldErrors={fieldErrors ?? emptyFieldErrors}
fieldErrors={fieldErrors ?? {}}
name={field.name}
label={field.label}
slotProps={{
+43 -81
View File
@@ -1,5 +1,4 @@
import { useCallback, useMemo, useState } from 'react';
import { toast } from 'react-toastify';
import { useState } from 'react';
import AccessTimeIcon from '@mui/icons-material/AccessTime';
import CancelIcon from '@mui/icons-material/Cancel';
@@ -24,7 +23,6 @@ import { readNTPSettings } from 'api/ntp';
import { dialogStyle } from 'CustomTheme';
import { useRequest } from 'alova/client';
import { updateState } from 'alova/client';
import type { ValidateFieldsError } from 'async-validator';
import {
BlockFormControlLabel,
BlockNavigation,
@@ -34,11 +32,13 @@ import {
ValidatedTextField,
useLayoutTitle
} from 'components';
import { toast } from 'components/toast';
import { useI18nContext } from 'i18n/i18n-react';
import type { NTPSettingsType, Time } from 'types';
import { formatLocalDateTime, updateValueDirty, useRest } from 'utils';
import { ValidationError, validate } from 'validators';
import { NTP_SETTINGS_VALIDATOR } from 'validators/ntp';
import type { ValidateFieldsError } from 'validators/schema';
import {
TIME_ZONES,
@@ -67,19 +67,16 @@ const NTPSettings = () => {
const { LL } = useI18nContext();
useLayoutTitle('NTP');
// Memoized timezone select items for better performance
const timeZoneItems = useTimeZoneSelectItems();
const timeZoneItemsT = timeZoneSelectItemsT();
// Memoized selected timezone value
const selectedTzValue = useMemo(
() => (data ? selectedTimeZone(data.tz_label, data.tz_format) : undefined),
[data?.tz_label, data?.tz_format]
);
const selectedTzValueT = useMemo(
() => (data ? selectedTimeZone(data.tz_label_t, data.tz_format_t) : undefined),
[data?.tz_label_t, data?.tz_format_t]
);
const selectedTzValue = data
? selectedTimeZone(data.tz_label, data.tz_format)
: undefined;
const selectedTzValueT = data
? selectedTimeZone(data.tz_label_t, data.tz_format_t)
: undefined;
const [localTime, setLocalTime] = useState<string>('');
const [settingTime, setSettingTime] = useState<boolean>(false);
const [processing, setProcessing] = useState<boolean>(false);
@@ -92,32 +89,22 @@ const NTPSettings = () => {
}
);
// Memoize updateFormValue to prevent recreation on every render
const updateFormValue = useMemo(
() =>
updateValueDirty(
origData as unknown as Record<string, unknown>,
dirtyFlags,
setDirtyFlags,
updateDataValue as (value: unknown) => void
),
[origData, dirtyFlags, setDirtyFlags, updateDataValue]
const updateFormValue = updateValueDirty(
origData as unknown as Record<string, unknown>,
dirtyFlags,
setDirtyFlags,
updateDataValue as (value: unknown) => void
);
// Memoize updateLocalTime handler
const updateLocalTime = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => setLocalTime(event.target.value),
[]
);
const updateLocalTime = (event: React.ChangeEvent<HTMLInputElement>) =>
setLocalTime(event.target.value);
// Memoize openSetTime handler
const openSetTime = useCallback(() => {
const openSetTime = () => {
setLocalTime(formatLocalDateTime(new Date()));
setSettingTime(true);
}, []);
};
// Memoize configureTime handler
const configureTime = useCallback(async () => {
const configureTime = async () => {
setProcessing(true);
try {
@@ -130,13 +117,11 @@ const NTPSettings = () => {
} finally {
setProcessing(false);
}
}, [localTime, updateTime, LL, loadData]);
};
// Memoize close dialog handler
const handleCloseSetTime = useCallback(() => setSettingTime(false), []);
const handleCloseSetTime = () => setSettingTime(false);
// Memoize validate and submit handler
const validateAndSubmit = useCallback(async () => {
const validateAndSubmit = async () => {
if (!data) return;
try {
setFieldErrors(undefined);
@@ -145,35 +130,27 @@ const NTPSettings = () => {
} 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]
);
const changeTimeZone = (event: React.ChangeEvent<HTMLInputElement>) => {
void updateState(readNTPSettings(), (settings: NTPSettingsType) => ({
...settings,
tz_label: event.target.value,
tz_format: TIME_ZONES[event.target.value]
}));
updateFormValue(event);
};
const changeTimeZoneT = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
void updateState(readNTPSettings(), (settings: NTPSettingsType) => ({
...settings,
tz_label_t: event.target.value,
tz_format_t: TIME_ZONES[event.target.value]
}));
updateFormValue(event);
},
[updateFormValue]
);
const changeTimeZoneT = (event: React.ChangeEvent<HTMLInputElement>) => {
void updateState(readNTPSettings(), (settings: NTPSettingsType) => ({
...settings,
tz_label_t: event.target.value,
tz_format_t: TIME_ZONES[event.target.value]
}));
updateFormValue(event);
};
// Memoize render content to prevent unnecessary re-renders
const renderContent = useMemo(() => {
const renderContent = () => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />;
}
@@ -299,27 +276,12 @@ const NTPSettings = () => {
)}
</>
);
}, [
data,
errorMessage,
loadData,
updateFormValue,
fieldErrors,
selectedTzValue,
selectedTzValueT,
changeTimeZone,
timeZoneItems,
dirtyFlags,
openSetTime,
saving,
validateAndSubmit,
LL
]);
};
return (
<SectionContent>
{blocker ? <BlockNavigation blocker={blocker} /> : null}
{renderContent}
{renderContent()}
<Dialog sx={dialogStyle} open={settingTime} onClose={handleCloseSetTime}>
<DialogTitle>{LL.SET_TIME(1)}</DialogTitle>
<DialogContent dividers>
+78 -160
View File
@@ -1,190 +1,108 @@
import { useCallback, useMemo, useState } from 'react';
import { useContext } from 'react';
import AccessTimeIcon from '@mui/icons-material/AccessTime';
import CancelIcon from '@mui/icons-material/Cancel';
import BuildIcon from '@mui/icons-material/Build';
import DeviceHubIcon from '@mui/icons-material/DeviceHub';
import ImportExportIcon from '@mui/icons-material/ImportExport';
import LockIcon from '@mui/icons-material/Lock';
import SettingsBackupRestoreIcon from '@mui/icons-material/SettingsBackupRestore';
import SettingsEthernetIcon from '@mui/icons-material/SettingsEthernet';
import SettingsInputAntennaIcon from '@mui/icons-material/SettingsInputAntenna';
import TuneIcon from '@mui/icons-material/Tune';
import ViewModuleIcon from '@mui/icons-material/ViewModule';
import {
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Divider,
List
} from '@mui/material';
import { List } from '@mui/material';
import { API } from 'api/app';
import { dialogStyle } from 'CustomTheme';
import { useRequest } from 'alova/client';
import type { APIcall } from 'app/main/types';
import { SectionContent, useLayoutTitle } from 'components';
import ListMenuItem from 'components/layout/ListMenuItem';
import { AuthenticatedContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react';
import SystemMonitor from '../status/SystemMonitor';
const Settings = () => {
const { LL } = useI18nContext();
const { versions } = useContext(AuthenticatedContext);
useLayoutTitle(LL.SETTINGS(0));
const [confirmFactoryReset, setConfirmFactoryReset] = useState(false);
const [restarting, setRestarting] = useState<boolean>();
const upgradeAvailable = versions?.current?.upgradeable ?? false;
const firmwareText = versions?.current?.version
? `v${versions.current.version}${upgradeAvailable ? ` (${LL.UPDATE_AVAILABLE()})` : ''}`
: '';
const { send: sendAPI } = useRequest((data: APIcall) => API(data), {
immediate: false
});
return (
<SectionContent>
<List>
<ListMenuItem
icon={BuildIcon}
bgcolor="#72caf9"
label="EMS-ESP Firmware"
text={firmwareText}
to="/settings/version"
badge={upgradeAvailable}
/>
const doFormat = useCallback(async () => {
await sendAPI({ device: 'system', cmd: 'format', id: 0 }).then(() => {
setRestarting(true);
setConfirmFactoryReset(false);
});
}, [sendAPI]);
<ListMenuItem
icon={TuneIcon}
bgcolor="#134ba2"
label={LL.APPLICATION()}
text={LL.APPLICATION_SETTINGS_1()}
to="application"
/>
const handleFactoryResetClose = useCallback(() => {
setConfirmFactoryReset(false);
}, []);
<ListMenuItem
icon={SettingsEthernetIcon}
bgcolor="#40828f"
label={LL.NETWORK(0)}
text={LL.CONFIGURE(LL.SETTINGS_OF(LL.NETWORK(1)))}
to="network"
/>
const handleFactoryResetClick = useCallback(() => {
setConfirmFactoryReset(true);
}, []);
<ListMenuItem
icon={SettingsInputAntennaIcon}
bgcolor="#5f9a5f"
label={LL.ACCESS_POINT(0)}
text={LL.CONFIGURE(LL.ACCESS_POINT(1))}
to="ap"
/>
const content = useMemo(() => {
return (
<>
<List>
<ListMenuItem
icon={TuneIcon}
bgcolor="#134ba2"
label={LL.APPLICATION()}
text={LL.APPLICATION_SETTINGS_1()}
to="application"
/>
<ListMenuItem
icon={AccessTimeIcon}
bgcolor="#c5572c"
label="NTP"
text={LL.CONFIGURE(LL.LOCAL_TIME(1))}
to="ntp"
/>
<ListMenuItem
icon={SettingsEthernetIcon}
bgcolor="#40828f"
label={LL.NETWORK(0)}
text={LL.CONFIGURE(LL.SETTINGS_OF(LL.NETWORK(1)))}
to="network"
/>
<ListMenuItem
icon={DeviceHubIcon}
bgcolor="#68374d"
label="MQTT"
text={LL.CONFIGURE('MQTT')}
to="mqtt"
/>
<ListMenuItem
icon={SettingsInputAntennaIcon}
bgcolor="#5f9a5f"
label={LL.ACCESS_POINT(0)}
text={LL.CONFIGURE(LL.ACCESS_POINT(1))}
to="ap"
/>
<ListMenuItem
icon={LockIcon}
label={LL.SECURITY(0)}
text={LL.SECURITY_1()}
to="security"
/>
<ListMenuItem
icon={AccessTimeIcon}
bgcolor="#c5572c"
label="NTP"
text={LL.CONFIGURE(LL.LOCAL_TIME(1))}
to="ntp"
/>
<ListMenuItem
icon={ViewModuleIcon}
bgcolor="#efc34b"
label={LL.MODULES()}
text={LL.MODULES_1()}
to="modules"
/>
<ListMenuItem
icon={DeviceHubIcon}
bgcolor="#68374d"
label="MQTT"
text={LL.CONFIGURE('MQTT')}
to="mqtt"
/>
<ListMenuItem
icon={LockIcon}
label={LL.SECURITY(0)}
text={LL.SECURITY_1()}
to="security"
/>
<ListMenuItem
icon={ViewModuleIcon}
bgcolor="#efc34b"
label={LL.MODULES()}
text={LL.MODULES_1()}
to="modules"
/>
<ListMenuItem
icon={ImportExportIcon}
bgcolor="#5d89f7"
label={LL.DOWNLOAD_UPLOAD()}
text={LL.DOWNLOAD_UPLOAD_1()}
to="downloadUpload"
/>
</List>
<Dialog
sx={dialogStyle}
open={confirmFactoryReset}
onClose={handleFactoryResetClose}
>
<DialogTitle>{LL.FACTORY_RESET()}</DialogTitle>
<DialogContent dividers>{LL.SYSTEM_FACTORY_TEXT_DIALOG()}</DialogContent>
<DialogActions>
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={handleFactoryResetClose}
color="secondary"
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<SettingsBackupRestoreIcon />}
variant="outlined"
onClick={doFormat}
color="error"
>
{LL.FACTORY_RESET()}
</Button>
</DialogActions>
</Dialog>
<Divider />
<Box
sx={{
mt: 2,
display: 'flex',
justifyContent: 'flex-end',
flexWrap: 'nowrap',
whiteSpace: 'nowrap'
}}
>
<Button
startIcon={<SettingsBackupRestoreIcon />}
variant="outlined"
onClick={handleFactoryResetClick}
color="error"
>
{LL.FACTORY_RESET()}
</Button>
</Box>
</>
);
}, [
LL,
handleFactoryResetClick,
handleFactoryResetClose,
doFormat,
confirmFactoryReset,
restarting
]);
return restarting ? <SystemMonitor /> : <SectionContent>{content}</SectionContent>;
<ListMenuItem
icon={ImportExportIcon}
bgcolor="#5d89f7"
label={LL.DOWNLOAD_UPLOAD()}
text={LL.DOWNLOAD_UPLOAD_1()}
to="downloadUpload"
/>
</List>
</SectionContent>
);
};
export default Settings;
+4 -16
View File
@@ -1,5 +1,3 @@
import { useMemo } from 'react';
import { MenuItem } from '@mui/material';
export const TIME_ZONES: Record<string, string> = {
@@ -472,26 +470,16 @@ export function selectedTimeZone(label: string, format: string) {
return TIME_ZONES[label] === format ? label : undefined;
}
// Memoized version for use in components
export function useTimeZoneSelectItems() {
return useMemo(
() =>
TIME_ZONE_LABELS.map((label) => (
<MenuItem key={label} value={label}>
{label}
</MenuItem>
)),
[]
);
}
// Fallback export for backward compatibility - now memoized
const precomputedTimeZoneItems = TIME_ZONE_LABELS.map((label) => (
<MenuItem key={label} value={label}>
{label}
</MenuItem>
));
export function useTimeZoneSelectItems() {
return precomputedTimeZoneItems;
}
export function timeZoneSelectItems() {
return precomputedTimeZoneItems;
}
+946
View File
@@ -0,0 +1,946 @@
import { memo, useContext, useEffect, useState } from 'react';
import { Link } from 'react-router';
import CancelIcon from '@mui/icons-material/Cancel';
import CloseIcon from '@mui/icons-material/Close';
import CheckIcon from '@mui/icons-material/Done';
import DownloadIcon from '@mui/icons-material/GetApp';
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
import PowerSettingsNewIcon from '@mui/icons-material/PowerSettingsNew';
import SettingsBackupRestoreIcon from '@mui/icons-material/SettingsBackupRestore';
import WarningIcon from '@mui/icons-material/Warning';
import {
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Grid,
IconButton,
Table,
TableBody,
TableCell,
TableRow,
Typography
} from '@mui/material';
import * as SystemApi from 'api/system';
import { API, callAction } from 'api/app';
import { dialogStyle } from 'CustomTheme';
import { useRequest } from 'alova/client';
import type { APIcall } from 'app/main/types';
import SystemMonitor from 'app/status/SystemMonitor';
import {
FormLoader,
SectionContent,
SingleUpload,
useLayoutTitle
} from 'components';
import { toast } from 'components/toast';
import { AuthenticatedContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react';
import type { TranslationFunctions } from 'i18n/i18n-types';
import type { VersionInfo } from 'types';
import { prettyDateTime } from 'utils/time';
// Constants moved outside component to avoid recreation
const STABLE_URL = 'https://github.com/emsesp/EMS-ESP32/releases/download/';
const STABLE_RELNOTES_URL =
'https://github.com/emsesp/EMS-ESP32/blob/main/CHANGELOG.md';
const DEV_URL = 'https://github.com/emsesp/EMS-ESP32/releases/download/latest/';
const DEV_RELNOTES_URL =
'https://github.com/emsesp/EMS-ESP32/blob/dev/CHANGELOG_LATEST.md';
// Types for better type safety
interface PartitionData {
partition: string;
version: string;
install_date?: string;
size: number;
}
interface VersionData {
emsesp_version: string;
arduino_version: string;
esp_platform: string;
flash_chip_size: number;
psram: boolean;
build_flags?: string;
partition: string;
partitions: PartitionData[];
developer_mode: boolean;
disable_reset: boolean;
}
// Memoized components for better performance
const VersionInfoDialog = memo(
({
showVersionInfo,
latestVersion,
latestDevVersion,
partitionVersion,
partition,
currentPartition,
size,
locale,
LL,
onClose
}: {
showVersionInfo: number;
latestVersion: VersionInfo | undefined;
latestDevVersion: VersionInfo | undefined;
partitionVersion: VersionInfo | undefined;
partition: string;
currentPartition: string;
size: number;
locale: string;
LL: TranslationFunctions;
onClose: () => void;
}) => {
if (showVersionInfo === 0) return null;
const isStable = showVersionInfo === 1;
const isDev = showVersionInfo === 2;
const isPartition = showVersionInfo === 3;
const version = isStable
? latestVersion
: isDev
? latestDevVersion
: partitionVersion;
const relNotesUrl = isStable
? STABLE_RELNOTES_URL
: isDev
? DEV_RELNOTES_URL
: '';
return (
<Dialog sx={dialogStyle} open={showVersionInfo !== 0} onClose={onClose}>
<DialogTitle>{LL.FIRMWARE_VERSION_INFO()}</DialogTitle>
<DialogContent dividers>
<Table size="small" sx={{ borderCollapse: 'collapse', minWidth: 0 }}>
<TableBody>
<TableRow sx={{ height: 24, borderBottom: 'none' }}>
<TableCell
component="th"
scope="row"
sx={{
color: 'lightblue',
borderBottom: 'none',
pr: 1,
py: 0.5,
fontSize: 13
}}
>
{LL.VERSION()}
</TableCell>
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
{isPartition
? typeof version === 'string'
? version
: version?.version
: version?.version}
</TableCell>
</TableRow>
<TableRow sx={{ height: 24, borderBottom: 'none' }}>
<TableCell
component="th"
scope="row"
sx={{
color: 'lightblue',
borderBottom: 'none',
pr: 1,
py: 0.5,
fontSize: 13,
width: 140
}}
>
{isPartition ? LL.TYPE(0) : LL.RELEASE_TYPE()}
</TableCell>
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
{partition === currentPartition && LL.ACTIVE() + ' '}
{isStable
? LL.STABLE()
: isDev
? LL.DEVELOPMENT()
: 'Partition ' + LL.VERSION()}
</TableCell>
</TableRow>
{isPartition && (
<TableRow sx={{ height: 24, borderBottom: 'none' }}>
<TableCell
component="th"
scope="row"
sx={{
color: 'lightblue',
borderBottom: 'none',
pr: 1,
py: 0.5,
fontSize: 13
}}
>
Partition
</TableCell>
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
{partition}
</TableCell>
</TableRow>
)}
{isPartition && (
<TableRow sx={{ height: 24, borderBottom: 'none' }}>
<TableCell
component="th"
scope="row"
sx={{
color: 'lightblue',
borderBottom: 'none',
pr: 1,
py: 0.5,
fontSize: 13
}}
>
Size
</TableCell>
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
{size} KB
</TableCell>
</TableRow>
)}
{version && version.date && (
<TableRow sx={{ height: 24, borderBottom: 'none' }}>
<TableCell
component="th"
scope="row"
sx={{
color: 'lightblue',
borderBottom: 'none',
pr: 1,
py: 0.5,
fontSize: 13
}}
>
{isPartition ? 'Install Date' : 'Build Date'}
</TableCell>
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
{prettyDateTime(locale, new Date(version.date))}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</DialogContent>
<DialogActions>
{!isPartition && (
<Button
variant="outlined"
component="a"
href={relNotesUrl}
target="_blank"
color="primary"
>
Changelog
</Button>
)}
<Button variant="outlined" onClick={onClose} color="secondary">
{LL.CLOSE()}
</Button>
</DialogActions>
</Dialog>
);
}
);
const InstallDialog = memo(
({
openInstallDialog,
fetchDevVersion,
latestVersion,
latestDevVersion,
upgradeImportantMessageType,
platform,
LL,
onClose,
onInstall
}: {
openInstallDialog: boolean;
fetchDevVersion: boolean;
latestVersion: VersionInfo | undefined;
latestDevVersion: VersionInfo | undefined;
upgradeImportantMessageType: number;
platform: string;
LL: TranslationFunctions;
onClose: () => void;
onInstall: (url: string) => void;
}) => {
const binURL = (() => {
if (!latestVersion || !latestDevVersion) return '';
const version = fetchDevVersion ? latestDevVersion : latestVersion;
const filename = `EMS-ESP-${version.version.replaceAll('.', '_')}-${platform}.bin`;
return fetchDevVersion
? `${DEV_URL}${filename}`
: `${STABLE_URL}v${version.version}/${filename}`;
})();
return (
<Dialog sx={dialogStyle} open={openInstallDialog} onClose={onClose}>
<DialogTitle>
{`${LL.INSTALL()} ${fetchDevVersion ? LL.DEVELOPMENT() : LL.STABLE()} Firmware`}
</DialogTitle>
<DialogContent dividers>
<Typography sx={{ mb: 2 }}>
{LL.INSTALL_VERSION(
LL.INSTALL(),
fetchDevVersion ? latestDevVersion?.version : latestVersion?.version
)}
</Typography>
{upgradeImportantMessageType === 2 && LL.UPGRADE_IMPORTANT_MESSAGES_2()}
{upgradeImportantMessageType === 1 && (
<>
{LL.UPGRADE_IMPORTANT_MESSAGES_1()}
<Typography sx={{ mt: 2 }}>
<Link to="/settings/downloadUpload" style={{ color: 'lightblue' }}>
{LL.DOWNLOAD_SYSTEM_BACKUP()}
</Link>
</Typography>
</>
)}
<Typography sx={{ mt: 2 }}>
<Link
to="https://docs.emsesp.org/FAQ#upgrading-the-firmware"
target="_blank"
rel="noreferrer"
style={{ color: 'lightblue' }}
>
{LL.ONLINE_HELP()}
</Link>
</Typography>
</DialogContent>
<DialogActions>
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={onClose}
color="secondary"
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<DownloadIcon />}
variant="outlined"
onClick={onClose}
color="primary"
>
<Link
to={binURL}
target="_blank"
rel="noreferrer"
style={{ color: 'lightblue', textDecoration: 'none' }}
>
{LL.DOWNLOAD(0)}
</Link>
</Button>
<Button
startIcon={<WarningIcon color="warning" />}
variant="outlined"
onClick={() => onInstall(binURL)}
color="primary"
>
{LL.INSTALL()}
</Button>
</DialogActions>
</Dialog>
);
}
);
const InstallPartitionDialog = memo(
({
openInstallPartitionDialog,
version,
partition,
LL,
onClose,
onInstall
}: {
openInstallPartitionDialog: boolean;
version: string;
partition: string;
LL: TranslationFunctions;
onClose: () => void;
onInstall: (partition: string) => void;
}) => {
return (
<Dialog sx={dialogStyle} open={openInstallPartitionDialog} onClose={onClose}>
<DialogTitle>
{LL.INSTALL()} {LL.STORED_VERSIONS()}
</DialogTitle>
<DialogContent dividers>
<Typography sx={{ mb: 2 }}>
{LL.INSTALL_VERSION(LL.INSTALL(), version)}
</Typography>
</DialogContent>
<DialogActions>
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={onClose}
color="secondary"
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<WarningIcon color="warning" />}
variant="outlined"
onClick={() => onInstall(partition)}
color="primary"
>
{LL.INSTALL()}
</Button>
</DialogActions>
</Dialog>
);
}
);
// Helper function moved outside component
const getPlatform = (data: VersionData): string => {
return `${data.esp_platform}-${data.flash_chip_size >= 16384 ? '16MB' : '4MB'}${data.psram ? '+' : ''}`;
};
const Version = () => {
const { LL, locale } = useI18nContext();
const { me, versions, refreshVersions } = useContext(AuthenticatedContext);
const [restarting, setRestarting] = useState<boolean>(false);
const [confirmFactoryReset, setConfirmFactoryReset] = useState<boolean>(false);
const [confirmRestart, setConfirmRestart] = useState<boolean>(false);
const [openInstallDialog, setOpenInstallDialog] = useState<boolean>(false);
const [partitionVersion, setPartitionVersion] = useState<VersionInfo | undefined>(
undefined
);
const [partition, setPartition] = useState<string>('');
const [openInstallPartitionDialog, setOpenInstallPartitionDialog] =
useState<boolean>(false);
const [fetchDevVersion, setFetchDevVersion] = useState<boolean>(false);
const [showVersionInfo, setShowVersionInfo] = useState<number>(0); // 1 = stable, 2 = dev, 3 = partition
const [firmwareSize, setFirmwareSize] = useState<number>(0);
const latestVersion: VersionInfo | undefined = versions?.stable
? { version: versions.stable.version, date: versions.stable.date }
: undefined;
const latestDevVersion: VersionInfo | undefined = versions?.dev
? { version: versions.dev.version, date: versions.dev.date }
: undefined;
const usingDevVersion = versions?.current?.type === 'dev';
const stableUpgradeAvailable = versions?.stable?.upgradeable ?? false;
const devUpgradeAvailable = versions?.dev?.upgradeable ?? false;
const internetLive = Boolean(versions?.stable || versions?.dev);
useEffect(() => {
if (internetLive) {
return;
}
const interval = setInterval(() => {
void refreshVersions();
}, 3000);
return () => clearInterval(interval);
}, [internetLive, refreshVersions]);
const { send: sendSetPartition } = useRequest(
(partition: string) => callAction({ action: 'setPartition', param: partition }),
{ immediate: false }
).onError((error) => {
toast.error(String(error.error?.message || 'An error occurred'));
});
const { data, send: loadData, error } = useRequest(SystemApi.readSystemStatus);
const { send: sendUploadURL } = useRequest(
(url: string) => callAction({ action: 'uploadURL', param: url }),
{ immediate: false }
);
const { send: sendAPI } = useRequest((data: APIcall) => API(data), {
immediate: false
});
const [upgradeImportantMessageType, setUpgradeImportantMessageType] =
useState<number>(0);
const { send: checkUpgradeImportantMessages } = useRequest(
(version: string) =>
callAction({ action: 'upgradeImportantMessages', param: version }),
{
immediate: false
}
)
.onSuccess((event) => {
const upgradeImportantMessageType_n = (
event.data as { upgradeImportantMessageType: number }
).upgradeImportantMessageType;
setUpgradeImportantMessageType(upgradeImportantMessageType_n);
})
.onError((error) => {
toast.error(String(error.error?.message || 'An error occurred'));
});
const platform = data ? getPlatform(data) : '';
const otherPartitions =
data?.partitions.filter((p) => p.partition !== data.partition) ?? [];
const setPartitionVersionInfo = (partition: string) => {
setShowVersionInfo(3);
const partitionData = data?.partitions.find((p) => p.partition === partition);
if (partitionData) {
setPartitionVersion({
version: partitionData.version,
date: partitionData.install_date ?? ''
});
setPartition(partitionData.partition);
setFirmwareSize(partitionData.size);
}
};
const doRestart = async () => {
setConfirmRestart(false);
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
(error: Error) => {
toast.error(error.message);
}
);
setRestarting(true);
};
const doFormat = async () => {
await sendAPI({ device: 'system', cmd: 'format', id: 0 }).then(() => {
setRestarting(true);
setConfirmFactoryReset(false);
});
};
const handleFactoryResetClose = () => setConfirmFactoryReset(false);
const handleFactoryResetClick = () => setConfirmFactoryReset(true);
const handleRestartClose = () => setConfirmRestart(false);
const handleRestartClick = () => setConfirmRestart(true);
const installFirmwareURL = async (url: string) => {
await sendUploadURL(url).catch((error: Error) => {
toast.error(error.message);
});
await doRestart();
};
const installPartitionFirmware = async (partition: string) => {
await sendSetPartition(partition).catch((error: Error) => {
toast.error(error.message);
});
setRestarting(true);
};
const showPartitionDialog = (
version: string,
partition: string,
install_date: string
) => {
setOpenInstallPartitionDialog(true);
setPartitionVersion({ version: version, date: install_date });
setPartition(partition);
};
const showFirmwareDialog = (useDevVersion: boolean) => {
setFetchDevVersion(useDevVersion);
const targetVersion = useDevVersion
? latestDevVersion?.version
: latestVersion?.version;
if (targetVersion) {
void checkUpgradeImportantMessages(targetVersion);
}
setOpenInstallDialog(true);
};
const closeInstallDialog = () => setOpenInstallDialog(false);
const closeInstallPartitionDialog = () => setOpenInstallPartitionDialog(false);
const handleVersionInfoClose = () => {
setShowVersionInfo(0);
setPartitionVersion(undefined);
setPartition('');
};
useLayoutTitle('EMS-ESP Firmware');
const showButtons = (showingDev: boolean) => {
const choice = showingDev
? !usingDevVersion
? LL.SWITCH_RELEASE_TYPE(LL.DEVELOPMENT())
: devUpgradeAvailable
? LL.UPDATE_AVAILABLE()
: undefined
: usingDevVersion
? LL.SWITCH_RELEASE_TYPE(LL.STABLE())
: stableUpgradeAvailable
? LL.UPDATE_AVAILABLE()
: undefined;
if (!choice) {
return (
<>
<CheckIcon
color="success"
sx={{ verticalAlign: 'middle', ml: 0.5, mr: 0.5 }}
/>
<span style={{ color: '#66bb6a', fontSize: '0.8em' }}>
{LL.LATEST_VERSION(usingDevVersion ? LL.DEVELOPMENT() : LL.STABLE())}
</span>
<Button
sx={{ ml: 1 }}
variant="outlined"
size="small"
onClick={() => showFirmwareDialog(showingDev)}
>
{LL.REINSTALL()}
</Button>
</>
);
}
if (!me.admin) return null;
const isUpdateAvailable = choice === LL.UPDATE_AVAILABLE();
return (
<Button
sx={{ ml: 1 }}
variant="outlined"
color={isUpdateAvailable ? 'success' : 'warning'}
size="small"
onClick={() => showFirmwareDialog(showingDev)}
>
{choice}
{isUpdateAvailable && (
<Box
component="span"
aria-label="update available"
sx={{
display: 'inline-block',
width: 8,
height: 8,
ml: 1,
verticalAlign: 'middle',
borderRadius: '50%',
backgroundColor: '#ffeb3b',
boxShadow: '0 0 6px rgba(255, 235, 59, 0.8)'
}}
/>
)}
</Button>
);
};
if (restarting) {
return <SystemMonitor />;
}
if (!data) {
return (
<SectionContent>
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
</SectionContent>
);
}
return (
<SectionContent>
<Box sx={{ p: 2, border: '1px solid #565656', borderRadius: 2 }}>
<Typography sx={{ mb: 1 }} variant="h6" color="primary">
{LL.THIS_VERSION()}
</Typography>
<Grid
container
direction="row"
sx={{
justifyContent: 'flex-start',
alignItems: 'baseline'
}}
>
<Grid size={{ xs: 4, md: 2 }}>
<Typography color="secondary">{LL.VERSION()}</Typography>
</Grid>
<Grid size={{ xs: 8, md: 10 }}>
<Typography>
{data.emsesp_version}
{data.build_flags && (
<Typography variant="caption">
&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>
<Grid size={{ xs: 4, md: 2 }}>
<Typography color="secondary">{LL.PLATFORM()}</Typography>
</Grid>
<Grid size={{ xs: 8, md: 10 }}>
<Typography>
{platform}
<Typography variant="caption">
&nbsp; &#40;
{data.psram ? (
<CheckIcon
color="success"
sx={{
fontSize: '1.5em',
verticalAlign: 'middle'
}}
/>
) : (
<CloseIcon
color="error"
sx={{
fontSize: '1.5em',
verticalAlign: 'middle'
}}
/>
)}
PSRAM&#41;
</Typography>
</Typography>
</Grid>
</Grid>
{internetLive ? (
<>
<Typography sx={{ mt: 4, mb: 1 }} variant="h6" color="primary">
{LL.AVAILABLE_VERSION()}
</Typography>
<Grid
container
direction="row"
rowSpacing={1}
sx={{
justifyContent: 'flex-start',
alignItems: 'baseline'
}}
>
{otherPartitions.length > 0 && data.developer_mode && (
<>
<Grid size={{ xs: 4, md: 2 }}>
<Typography color="secondary">{LL.STORED_VERSIONS()}</Typography>
</Grid>
<Grid size={{ xs: 8, md: 10 }}>
{otherPartitions.map((partition) => (
<Typography key={partition.partition} sx={{ mb: 1 }}>
{partition.version}
<IconButton
onClick={() =>
setPartitionVersionInfo(partition.partition)
}
aria-label={LL.FIRMWARE_VERSION_INFO()}
>
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
</IconButton>
<Button
sx={{ ml: 0 }}
variant="outlined"
size="small"
onClick={() =>
showPartitionDialog(
partition.version,
partition.partition,
partition.install_date ?? ''
)
}
>
{LL.INSTALL()}
</Button>
</Typography>
))}
</Grid>
</>
)}
<Grid size={{ xs: 4, md: 2 }}>
<Typography color="secondary">{LL.STABLE()}</Typography>
</Grid>
<Grid size={{ xs: 8, md: 10 }}>
<Typography>
{latestVersion?.version}
<IconButton
onClick={() => setShowVersionInfo(1)}
aria-label={LL.FIRMWARE_VERSION_INFO()}
>
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
</IconButton>
{showButtons(false)}
</Typography>
</Grid>
<Grid size={{ xs: 4, md: 2 }}>
<Typography color="secondary">{LL.DEVELOPMENT()}</Typography>
</Grid>
<Grid size={{ xs: 8, md: 10 }}>
<Typography>
{latestDevVersion?.version}
<IconButton
onClick={() => setShowVersionInfo(2)}
aria-label={LL.FIRMWARE_VERSION_INFO()}
>
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
</IconButton>
{showButtons(true)}
</Typography>
</Grid>
</Grid>
</>
) : (
<Typography sx={{ mt: 2 }} color="warning">
{LL.INTERNET_CONNECTION_CHECK()}&hellip;
</Typography>
)}
{me.admin && (
<>
<VersionInfoDialog
showVersionInfo={showVersionInfo}
latestVersion={latestVersion}
latestDevVersion={latestDevVersion}
partitionVersion={partitionVersion}
locale={locale}
partition={partition}
currentPartition={data?.partition ?? ''}
size={firmwareSize}
LL={LL}
onClose={handleVersionInfoClose}
/>
<InstallDialog
openInstallDialog={openInstallDialog}
fetchDevVersion={fetchDevVersion}
latestVersion={latestVersion}
latestDevVersion={latestDevVersion}
upgradeImportantMessageType={upgradeImportantMessageType}
platform={platform}
LL={LL}
onClose={closeInstallDialog}
onInstall={installFirmwareURL}
/>
<InstallPartitionDialog
openInstallPartitionDialog={openInstallPartitionDialog}
version={partitionVersion?.version || ''}
partition={partition}
LL={LL}
onClose={closeInstallPartitionDialog}
onInstall={installPartitionFirmware}
/>
<Typography sx={{ pt: 2, pb: 2 }} variant="h6" color="primary">
{LL.UPLOAD()}
</Typography>
<SingleUpload text={LL.UPLOAD_FIRMWARE_TEXT()} doRestart={doRestart} />
</>
)}
</Box>
{me.admin && (
<>
<Dialog
sx={dialogStyle}
open={confirmFactoryReset}
onClose={handleFactoryResetClose}
>
<DialogTitle>{LL.FACTORY_RESET()}</DialogTitle>
<DialogContent dividers>{LL.SYSTEM_FACTORY_TEXT_DIALOG()}</DialogContent>
<DialogActions>
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={handleFactoryResetClose}
color="secondary"
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<SettingsBackupRestoreIcon />}
variant="outlined"
onClick={doFormat}
color="error"
>
{LL.FACTORY_RESET()}
</Button>
</DialogActions>
</Dialog>
<Dialog
sx={dialogStyle}
open={confirmRestart}
onClose={handleRestartClose}
>
<DialogTitle>{LL.RESTART()}</DialogTitle>
<DialogContent dividers>{LL.RESTART_CONFIRM()}</DialogContent>
<DialogActions>
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={handleRestartClose}
color="secondary"
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<PowerSettingsNewIcon />}
variant="outlined"
onClick={doRestart}
color="success"
>
{LL.RESTART()}
</Button>
</DialogActions>
</Dialog>
<Box
sx={{
mt: 2,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexWrap: 'nowrap',
whiteSpace: 'nowrap',
gap: 1
}}
>
{!data.disable_reset && (
<Button
startIcon={<SettingsBackupRestoreIcon />}
variant="outlined"
onClick={handleFactoryResetClick}
color="error"
>
{LL.FACTORY_RESET()}
</Button>
)}
<Button
startIcon={<PowerSettingsNewIcon />}
variant="outlined"
onClick={handleRestartClick}
color="success"
>
{LL.RESTART()}
</Button>
</Box>
</>
)}
</SectionContent>
);
};
export default memo(Version);
+15 -49
View File
@@ -1,12 +1,5 @@
import { memo, useCallback, useMemo, useState } from 'react';
import {
Navigate,
Route,
Routes,
matchRoutes,
useLocation,
useNavigate
} from 'react-router';
import { memo, useState } from 'react';
import { Outlet, useMatch, useNavigate } from 'react-router';
import { Tab } from '@mui/material';
@@ -14,52 +7,32 @@ import { RouterTabs, useLayoutTitle } from 'components';
import { useI18nContext } from 'i18n/i18n-react';
import type { WiFiNetwork } from 'types';
import NetworkSettings from './NetworkSettings';
import { WiFiConnectionContext } from './WiFiConnectionContext';
import WiFiNetworkScanner from './WiFiNetworkScanner';
const Network = () => {
const { LL } = useI18nContext();
useLayoutTitle(LL.NETWORK(0));
// this also works!
// const routerTab = useMatch(`settings/network/:path/*`)?.pathname || false;
const matchedRoutes = matchRoutes(
[
{
path: '/settings/network/settings',
element: <NetworkSettings />
},
{ path: '/settings/network/scan', element: <WiFiNetworkScanner /> }
],
useLocation()
);
const routerTab = matchedRoutes?.[0]?.route.path || false;
const routerTab = useMatch('/settings/network/:tab')?.pathname || false;
const navigate = useNavigate();
const [selectedNetwork, setSelectedNetwork] = useState<WiFiNetwork>();
const selectNetwork = useCallback(
(network: WiFiNetwork) => {
setSelectedNetwork(network);
void navigate('/settings/network/settings');
},
[navigate]
);
const selectNetwork = (network: WiFiNetwork) => {
setSelectedNetwork(network);
void navigate('/settings/network/settings');
};
const deselectNetwork = useCallback(() => {
const deselectNetwork = () => {
setSelectedNetwork(undefined);
}, []);
};
const contextValue = useMemo(
() => ({
...(selectedNetwork && { selectedNetwork }),
selectNetwork,
deselectNetwork
}),
[selectedNetwork, selectNetwork, deselectNetwork]
);
const contextValue = {
...(selectedNetwork && { selectedNetwork }),
selectNetwork,
deselectNetwork
};
return (
<WiFiConnectionContext.Provider value={contextValue}>
@@ -70,14 +43,7 @@ const Network = () => {
/>
<Tab value="/settings/network/scan" label={LL.NETWORK_SCAN()} />
</RouterTabs>
<Routes>
<Route path="scan" element={<WiFiNetworkScanner />} />
<Route path="settings" element={<NetworkSettings />} />
<Route
path="*"
element={<Navigate replace to="/settings/network/settings" />}
/>
</Routes>
<Outlet />
</WiFiConnectionContext.Provider>
);
};
@@ -1,5 +1,4 @@
import { memo, useCallback, useContext, useEffect, useState } from 'react';
import { toast } from 'react-toastify';
import CancelIcon from '@mui/icons-material/Cancel';
import DeleteIcon from '@mui/icons-material/Delete';
@@ -23,10 +22,10 @@ import {
import * as NetworkApi from 'api/network';
import { API } from 'api/app';
import { readSystemStatus } from 'api/system';
import { updateState, useRequest } from 'alova/client';
import type { APIcall } from 'app/main/types';
import type { ValidateFieldsError } from 'async-validator';
import {
BlockFormControlLabel,
BlockNavigation,
@@ -37,11 +36,13 @@ import {
ValidatedPasswordField,
ValidatedTextField
} from 'components';
import { toast } from 'components/toast';
import { useI18nContext } from 'i18n/i18n-react';
import type { NetworkSettingsType } from 'types';
import { updateValueDirty, useRest } from 'utils';
import { ValidationError, validate } from 'validators';
import { createNetworkSettingsValidator } from 'validators/network';
import type { ValidateFieldsError } from 'validators/schema';
import SystemMonitor from '../../status/SystemMonitor';
import { WiFiConnectionContext } from './WiFiConnectionContext';
@@ -50,6 +51,8 @@ import { isNetworkOpen, networkSecurityMode } from './WiFiNetworkSelector';
const NetworkSettings = () => {
const { LL } = useI18nContext();
const { data: hardwareData } = useRequest(readSystemStatus);
const { selectedNetwork, deselectNetwork } = useContext(WiFiConnectionContext);
const [initialized, setInitialized] = useState(false);
@@ -89,10 +92,11 @@ const NetworkSettings = () => {
static_ip_config: false,
bandwidth20: false,
tx_power: 0,
nosleep: false,
nosleep: true,
enableMDNS: true,
enableCORS: false,
CORSOrigin: '*'
CORSOrigin: '*',
eth_10mbit: current_data ? current_data.eth_10mbit : false
})
);
}
@@ -121,22 +125,22 @@ const NetworkSettings = () => {
deselectNetwork();
}, [data, saveData, deselectNetwork]);
const setCancel = useCallback(async () => {
const setCancel = async () => {
deselectNetwork();
await loadData();
}, [deselectNetwork, loadData]);
};
const doRestart = useCallback(async () => {
const doRestart = async () => {
setRestarting(true);
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
(error: Error) => {
toast.error(error.message);
}
);
}, [sendAPI]);
};
const content = () => {
if (!data) {
if (!data || !hardwareData) {
return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />;
}
@@ -173,7 +177,7 @@ const NetworkSettings = () => {
<ValidatedTextField
fieldErrors={fieldErrors || {}}
name="ssid"
label={'SSID (' + LL.NETWORK_BLANK_SSID() + ')'}
label="SSID"
fullWidth
variant="outlined"
value={data.ssid}
@@ -246,6 +250,23 @@ const NetworkSettings = () => {
}
label={LL.NETWORK_LOW_BAND()}
/>
{hardwareData.esp_platform === 'ESP32' && (
<>
<Typography sx={{ pt: 2 }} variant="h6" color="primary">
Ethernet
</Typography>
<BlockFormControlLabel
control={
<Checkbox
name="eth_10mbit"
checked={data.eth_10mbit}
onChange={updateFormValue}
/>
}
label="Force 10Mbit half-duplex"
/>
</>
)}
<Typography sx={{ pt: 2 }} variant="h6" color="primary">
{LL.GENERAL_OPTIONS()}
</Typography>
@@ -1,4 +1,4 @@
import { memo, useCallback, useRef, useState } from 'react';
import { memo, useRef, useState } from 'react';
import PermScanWifiIcon from '@mui/icons-material/PermScanWifi';
import { Button } from '@mui/material';
@@ -48,12 +48,12 @@ const WiFiNetworkScanner = () => {
}
});
const renderNetworkScanner = useCallback(() => {
const renderNetworkScanner = () => {
if (!networkList) {
return <FormLoader errorMessage={errorMessage || ''} />;
}
return <WiFiNetworkSelector networkList={networkList} />;
}, [networkList, errorMessage]);
};
return (
<SectionContent>
@@ -1,4 +1,4 @@
import { memo, useCallback, useContext } from 'react';
import { memo, useContext } from 'react';
import LockIcon from '@mui/icons-material/Lock';
import LockOpenIcon from '@mui/icons-material/LockOpen';
@@ -63,34 +63,31 @@ const WiFiNetworkSelector = ({ networkList }: { networkList: WiFiNetworkList })
const wifiConnectionContext = useContext(WiFiConnectionContext);
const renderNetwork = useCallback(
(network: WiFiNetwork) => (
<ListItem
key={network.bssid}
onClick={() => wifiConnectionContext.selectNetwork(network)}
>
<ListItemAvatar>
<Avatar>{isNetworkOpen(network) ? <LockOpenIcon /> : <LockIcon />}</Avatar>
</ListItemAvatar>
<ListItemText
primary={network.ssid}
secondary={
'Security: ' +
networkSecurityMode(network) +
', Ch: ' +
network.channel +
', bssid: ' +
network.bssid
}
/>
<ListItemIcon>
<Badge badgeContent={network.rssi + 'dBm'}>
<WifiIcon sx={{ color: networkQualityHighlight(network, theme) }} />
</Badge>
</ListItemIcon>
</ListItem>
),
[wifiConnectionContext, theme]
const renderNetwork = (network: WiFiNetwork) => (
<ListItem
key={network.bssid}
onClick={() => wifiConnectionContext.selectNetwork(network)}
>
<ListItemAvatar>
<Avatar>{isNetworkOpen(network) ? <LockOpenIcon /> : <LockIcon />}</Avatar>
</ListItemAvatar>
<ListItemText
primary={network.ssid}
secondary={
'Security: ' +
networkSecurityMode(network) +
', Ch: ' +
network.channel +
', bssid: ' +
network.bssid
}
/>
<ListItemIcon>
<Badge badgeContent={network.rssi + 'dBm'}>
<WifiIcon sx={{ color: networkQualityHighlight(network, theme) }} />
</Badge>
</ListItemIcon>
</ListItem>
);
if (networkList.networks.length === 0) {
@@ -1,4 +1,4 @@
import { memo, useCallback, useContext, useMemo, useState } from 'react';
import { memo, useCallback, useContext, useState } from 'react';
import { useBlocker } from 'react-router';
import CancelIcon from '@mui/icons-material/Cancel';
@@ -55,16 +55,14 @@ const ManageUsers = () => {
const blocker = useBlocker(changed !== 0);
const { LL } = useI18nContext();
const table_theme = useMemo(
() =>
useTheme({
Table: `
const table_theme = useTheme({
Table: `
--data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) minmax(120px, max-content) 120px;
`,
BaseRow: `
BaseRow: `
font-size: 14px;
`,
HeaderRow: `
HeaderRow: `
text-transform: uppercase;
background-color: black;
color: #90CAF9;
@@ -74,7 +72,7 @@ const ManageUsers = () => {
border-bottom: 1px solid #565656;
}
`,
Row: `
Row: `
.td {
padding: 8px;
border-top: 1px solid #565656;
@@ -87,7 +85,7 @@ const ManageUsers = () => {
background-color: #1e1e1e;
}
`,
BaseCell: `
BaseCell: `
&:nth-of-type(2) {
text-align: center;
}
@@ -95,44 +93,36 @@ const ManageUsers = () => {
text-align: right;
}
`
}),
[]
);
});
const noAdminConfigured = useCallback(
() => !data?.users.find((u) => u.admin),
[data]
);
const noAdminConfigured = () => !data?.users.find((u) => u.admin);
const removeUser = useCallback(
(toRemove: UserType) => {
if (!data) return;
const users = data.users.filter((u) => u.username !== toRemove.username);
updateDataValue({ ...data, users });
setChanged(changed + 1);
},
[data, updateDataValue, changed]
);
const removeUser = (toRemove: UserType) => {
if (!data) return;
const users = data.users.filter((u) => u.username !== toRemove.username);
updateDataValue({ ...data, users });
setChanged(changed + 1);
};
const createUser = useCallback(() => {
const createUser = () => {
setCreating(true);
setUser({
username: '',
password: '',
admin: true
});
}, []);
};
const editUser = useCallback((toEdit: UserType) => {
const editUser = (toEdit: UserType) => {
setCreating(false);
setUser({ ...toEdit });
}, []);
};
const cancelEditingUser = useCallback(() => {
const cancelEditingUser = () => {
setUser(undefined);
}, []);
};
const doneEditingUser = useCallback(() => {
const doneEditingUser = () => {
if (user && data) {
const users = [
...data.users.filter(
@@ -144,26 +134,26 @@ const ManageUsers = () => {
setUser(undefined);
setChanged(changed + 1);
}
}, [user, data, updateDataValue, changed]);
};
const closeGenerateToken = useCallback(() => {
setGeneratingToken(undefined);
}, []);
const generateTokenForUser = useCallback((username: string) => {
const generateTokenForUser = (username: string) => {
setGeneratingToken(username);
}, []);
};
const onSubmit = useCallback(async () => {
const onSubmit = async () => {
await saveData();
await authenticatedContext.refresh();
setChanged(0);
}, [saveData, authenticatedContext]);
};
const onCancelSubmit = useCallback(async () => {
const onCancelSubmit = async () => {
await loadData();
setChanged(0);
}, [loadData]);
};
const content = () => {
if (!data) {
@@ -177,15 +167,10 @@ const ManageUsers = () => {
admin: boolean;
}
// add id to the type, needed for the table
const user_table = useMemo(
() =>
data.users.map((u) => ({
...u,
id: u.username
})) as UserType2[],
[data.users]
);
const user_table = data.users.map((u) => ({
...u,
id: u.username
})) as UserType2[];
return (
<>
@@ -1,35 +1,16 @@
import { memo, useMemo } from 'react';
import { Navigate, Route, Routes, matchRoutes, useLocation } from 'react-router';
import { memo } from 'react';
import { Outlet, useMatch } from 'react-router';
import { Tab } from '@mui/material';
import { RouterTabs, useLayoutTitle } from 'components';
import { useI18nContext } from 'i18n/i18n-react';
import ManageUsers from './ManageUsers';
import SecuritySettings from './SecuritySettings';
const Security = () => {
const { LL } = useI18nContext();
useLayoutTitle(LL.SECURITY(0));
const location = useLocation();
const matchedRoutes = useMemo(
() =>
matchRoutes(
[
{
path: '/settings/security/settings',
element: <ManageUsers />
},
{ path: '/settings/security/users', element: <SecuritySettings /> }
],
location
),
[location]
);
const routerTab = matchedRoutes?.[0]?.route.path || false;
const routerTab = useMatch('/settings/security/:tab')?.pathname || false;
return (
<>
@@ -40,14 +21,7 @@ const Security = () => {
/>
<Tab value="/settings/security/users" label={LL.MANAGE_USERS()} />
</RouterTabs>
<Routes>
<Route path="users" element={<ManageUsers />} />
<Route path="settings" element={<SecuritySettings />} />
<Route
path="*"
element={<Navigate replace to="/settings/security/settings" />}
/>
</Routes>
<Outlet />
</>
);
};
@@ -6,7 +6,6 @@ import { Button } from '@mui/material';
import * as SecurityApi from 'api/security';
import type { ValidateFieldsError } from 'async-validator';
import {
BlockNavigation,
ButtonRow,
@@ -20,6 +19,7 @@ import { useI18nContext } from 'i18n/i18n-react';
import type { SecuritySettingsType } from 'types';
import { updateValueDirty, useRest } from 'utils';
import { SECURITY_SETTINGS_VALIDATOR, ValidationError, validate } from 'validators';
import type { ValidateFieldsError } from 'validators/schema';
const SecuritySettings = () => {
const { LL } = useI18nContext();
@@ -79,7 +79,7 @@ const SecuritySettings = () => {
onChange={updateFormValue}
margin="normal"
/>
<MessageBox level="info" message={LL.SU_TEXT()} mt={1} />
<MessageBox level="info" message={LL.SU_TEXT()} sx={{ mt: 1 }} />
{dirtyFlags && dirtyFlags.length !== 0 && (
<ButtonRow>
<Button
+5 -5
View File
@@ -1,4 +1,4 @@
import { memo, useCallback, useEffect, useState } from 'react';
import { memo, useEffect, useState } from 'react';
import type { FC } from 'react';
import CancelIcon from '@mui/icons-material/Cancel';
@@ -14,8 +14,6 @@ import {
} from '@mui/material';
import { dialogStyle } from 'CustomTheme';
import type Schema from 'async-validator';
import type { ValidateFieldsError } from 'async-validator';
import {
BlockFormControlLabel,
ValidatedPasswordField,
@@ -25,6 +23,8 @@ import { useI18nContext } from 'i18n/i18n-react';
import type { UserType } from 'types';
import { updateValue } from 'utils';
import { ValidationError, validate } from 'validators';
import type Schema from 'validators/schema';
import type { ValidateFieldsError } from 'validators/schema';
interface UserFormProps {
creating: boolean;
@@ -62,7 +62,7 @@ const User: FC<UserFormProps> = ({
}
}, [open]);
const validateAndDone = useCallback(async () => {
const validateAndDone = async () => {
if (user) {
try {
setFieldErrors(undefined);
@@ -72,7 +72,7 @@ const User: FC<UserFormProps> = ({
setFieldErrors((error as ValidationError).fieldErrors);
}
}
}, [user, validator, onDoneEditing]);
};
return (
<Dialog
+25 -33
View File
@@ -1,5 +1,3 @@
import { useCallback, useMemo } from 'react';
import {
Body,
Cell,
@@ -36,16 +34,14 @@ const SystemActivity = () => {
useLayoutTitle(LL.DATA_TRAFFIC());
const stats_theme = tableTheme(
useMemo(
() => ({
Table: `
const stats_theme = tableTheme({
Table: `
--data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) 90px 90px 80px;
`,
BaseRow: `
BaseRow: `
font-size: 14px;
`,
HeaderRow: `
HeaderRow: `
text-transform: uppercase;
background-color: black;
color: #90CAF9;
@@ -55,7 +51,7 @@ const SystemActivity = () => {
border-bottom: 1px solid #565656;
}
`,
Row: `
Row: `
.td {
padding: 8px;
border-top: 1px solid #565656;
@@ -69,26 +65,20 @@ const SystemActivity = () => {
background-color: #1e1e1e;
}
`,
BaseCell: `
BaseCell: `
&:not(:first-of-type) {
text-align: center;
}
`
}),
[]
)
);
});
const showName = useCallback(
(id: number) => {
const name: keyof Translation['STATUS_NAMES'] =
id.toString() as keyof Translation['STATUS_NAMES'];
return LL.STATUS_NAMES[name]();
},
[LL]
);
const showName = (id: number) => {
const name: keyof Translation['STATUS_NAMES'] =
id.toString() as keyof Translation['STATUS_NAMES'];
return LL.STATUS_NAMES[name]();
};
const showQuality = useCallback((stat: Stat) => {
const showQuality = (stat: Stat) => {
if (stat.q === 0 || stat.s + stat.f === 0) {
return;
}
@@ -100,14 +90,18 @@ const SystemActivity = () => {
} else {
return <div style={{ color: QUALITY_COLORS.POOR }}>{stat.q}%</div>;
}
}, []);
const content = useMemo(() => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
}
};
if (!data) {
return (
<SectionContent>
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
</SectionContent>
);
}
return (
<SectionContent>
<Table
data={{ nodes: data.stats }}
theme={stats_theme}
@@ -136,10 +130,8 @@ const SystemActivity = () => {
</>
)}
</Table>
);
}, [data, loadData, error?.message, stats_theme, LL, showName, showQuality]);
return <SectionContent>{content}</SectionContent>;
</SectionContent>
);
};
export default SystemActivity;
+8 -9
View File
@@ -1,4 +1,4 @@
import { type FC, memo, useMemo } from 'react';
import { type FC, memo } from 'react';
import AutoAwesomeMotionIcon from '@mui/icons-material/AutoAwesomeMotion';
import DeviceHubIcon from '@mui/icons-material/DeviceHub';
@@ -127,16 +127,15 @@ const MqttStatus = () => {
void loadData();
});
// Memoize error message separately to avoid re-renders on error object changes
const errorMessage = error?.message || '';
const mqttStatusText = useMemo(() => {
if (!data) return '';
if (!data.enabled) return LL.NOT_ENABLED();
return data.connected
? `${LL.CONNECTED(0)} (${data.connect_count})`
: `${LL.DISCONNECTED()} (${data.connect_count})`;
}, [data, LL]);
const mqttStatusText = !data
? ''
: !data.enabled
? LL.NOT_ENABLED()
: data.connected
? `${LL.CONNECTED(0)} (${data.connect_count})`
: `${LL.DISCONNECTED()} (${data.connect_count})`;
if (!data) {
return (
+11 -11
View File
@@ -1,5 +1,3 @@
import { useMemo } from 'react';
import AccessTimeIcon from '@mui/icons-material/AccessTime';
import DnsIcon from '@mui/icons-material/Dns';
import SwapVerticalCircleIcon from '@mui/icons-material/SwapVerticalCircle';
@@ -67,12 +65,16 @@ const NTPStatus = () => {
}
};
const content = useMemo(() => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
}
if (!data) {
return (
<SectionContent>
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
</SectionContent>
);
}
return (
<SectionContent>
<List>
<ListItem>
<ListItemAvatar>
@@ -121,10 +123,8 @@ const NTPStatus = () => {
</ListItem>
<Divider variant="inset" component="li" />
</List>
);
}, [data, error, loadData, LL, theme]);
return <SectionContent>{content}</SectionContent>;
</SectionContent>
);
};
export default NTPStatus;
+15 -15
View File
@@ -1,5 +1,3 @@
import { useMemo } from 'react';
import DeviceHubIcon from '@mui/icons-material/DeviceHub';
import DnsIcon from '@mui/icons-material/Dns';
import GiteIcon from '@mui/icons-material/Gite';
@@ -124,16 +122,20 @@ const NetworkStatus = () => {
const theme = useTheme();
const content = useMemo(() => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
}
const statusText = getNetworkStatusText(data.status, data.reconnect_count, LL);
const statusColor = networkStatusHighlight(data, theme);
const qualityColor = networkQualityHighlight(data, theme);
if (!data) {
return (
<SectionContent>
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
</SectionContent>
);
}
const statusText = getNetworkStatusText(data.status, data.reconnect_count, LL);
const statusColor = networkStatusHighlight(data, theme);
const qualityColor = networkQualityHighlight(data, theme);
return (
<SectionContent>
<List>
<ListItem>
<ListItemAvatar>
@@ -227,10 +229,8 @@ const NetworkStatus = () => {
</>
)}
</List>
);
}, [data, error, loadData, LL, theme]);
return <SectionContent>{content}</SectionContent>;
</SectionContent>
);
};
export default NetworkStatus;
+108 -273
View File
@@ -1,25 +1,16 @@
import { useCallback, useContext, useMemo, useState } from 'react';
import { toast } from 'react-toastify';
import { useContext } from 'react';
import AccessTimeIcon from '@mui/icons-material/AccessTime';
import BuildIcon from '@mui/icons-material/Build';
import CancelIcon from '@mui/icons-material/Cancel';
import DeviceHubIcon from '@mui/icons-material/DeviceHub';
import DirectionsBusIcon from '@mui/icons-material/DirectionsBus';
import LogoDevIcon from '@mui/icons-material/LogoDev';
import MemoryIcon from '@mui/icons-material/Memory';
import MonitorHeartIcon from '@mui/icons-material/MonitorHeart';
import PowerSettingsNewIcon from '@mui/icons-material/PowerSettingsNew';
import RouterIcon from '@mui/icons-material/Router';
import SettingsInputAntennaIcon from '@mui/icons-material/SettingsInputAntenna';
import WifiIcon from '@mui/icons-material/Wifi';
import {
Avatar,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
List,
ListItem,
ListItemAvatar,
@@ -27,12 +18,10 @@ import {
useTheme
} from '@mui/material';
import { API } from 'api/app';
import { readSystemStatus } from 'api/system';
import { dialogStyle } from 'CustomTheme';
import { useRequest } from 'alova/client';
import { type APIcall, busConnectionStatus } from 'app/main/types';
import { busConnectionStatus } from 'app/main/types';
import { FormLoader, SectionContent, useLayoutTitle } from 'components';
import ListMenuItem from 'components/layout/ListMenuItem';
import { AuthenticatedContext } from 'contexts/authentication';
@@ -41,9 +30,6 @@ import { NTPSyncStatus, NetworkConnectionStatus, SystemStatusCodes } from 'types
import { useInterval } from 'utils';
import { formatDateTime } from 'utils/time';
import SystemMonitor from './SystemMonitor';
// Pure functions moved outside component to avoid recreation on each render
const formatNumber = (num: number) => new Intl.NumberFormat().format(num);
const formatDurationSec = (
@@ -72,24 +58,7 @@ const SystemStatus = () => {
const { me } = useContext(AuthenticatedContext);
const [confirmRestart, setConfirmRestart] = useState<boolean>(false);
const [restarting, setRestarting] = useState<boolean>();
const { send: sendAPI } = useRequest((data: APIcall) => API(data), {
immediate: false
});
const {
data,
send: loadData,
error
} = useRequest(readSystemStatus, {
async middleware(_, next) {
if (!restarting) {
await next();
}
}
});
const { data, send: loadData, error } = useRequest(readSystemStatus);
useInterval(() => {
void loadData();
@@ -97,10 +66,8 @@ const SystemStatus = () => {
const theme = useTheme();
// Memoize derived status values to avoid recalculation on every render
const busStatus = useMemo(() => {
const busStatus = (() => {
if (!data) return 'EMS state unknown';
switch (data.bus_status) {
case busConnectionStatus.BUS_STATUS_CONNECTED:
return `EMS ${LL.CONNECTED(0)} (${formatDurationSec(data.bus_uptime, LL)})`;
@@ -111,12 +78,10 @@ const SystemStatus = () => {
default:
return 'EMS state unknown';
}
}, [data?.bus_status, data?.bus_uptime, LL]);
})();
// Memoize derived status values to avoid recalculation on every render
const systemStatus = useMemo(() => {
const systemStatus = (() => {
if (!data) return '??';
switch (data.status) {
case SystemStatusCodes.SYSTEM_STATUS_PENDING_UPLOAD:
case SystemStatusCodes.SYSTEM_STATUS_UPLOADING:
@@ -129,14 +94,12 @@ const SystemStatus = () => {
case SystemStatusCodes.SYSTEM_STATUS_INVALID_GPIO:
return LL.GPIO_OF(LL.FAILED(0));
default:
// SystemStatusCodes.SYSTEM_STATUS_NORMAL
return 'OK';
}
}, [data?.status, LL]);
})();
const busStatusHighlight = useMemo(() => {
const busStatusHighlight = (() => {
if (!data) return theme.palette.warning.main;
switch (data.bus_status) {
case busConnectionStatus.BUS_STATUS_TX_ERRORS:
return theme.palette.warning.main;
@@ -147,11 +110,10 @@ const SystemStatus = () => {
default:
return theme.palette.warning.main;
}
}, [data?.bus_status, theme.palette]);
})();
const ntpStatus = useMemo(() => {
const ntpStatus = (() => {
if (!data) return LL.UNKNOWN();
switch (data.ntp_status) {
case NTPSyncStatus.NTP_DISABLED:
return LL.NOT_ENABLED();
@@ -164,11 +126,10 @@ const SystemStatus = () => {
default:
return LL.UNKNOWN();
}
}, [data?.ntp_status, data?.ntp_time, LL]);
})();
const ntpStatusHighlight = useMemo(() => {
const ntpStatusHighlight = (() => {
if (!data) return theme.palette.error.main;
switch (data.ntp_status) {
case NTPSyncStatus.NTP_DISABLED:
return theme.palette.info.main;
@@ -179,11 +140,10 @@ const SystemStatus = () => {
default:
return theme.palette.error.main;
}
}, [data?.ntp_status, theme.palette]);
})();
const networkStatusHighlight = useMemo(() => {
const networkStatusHighlight = (() => {
if (!data) return theme.palette.warning.main;
switch (data.network_status) {
case NetworkConnectionStatus.WIFI_STATUS_IDLE:
case NetworkConnectionStatus.WIFI_STATUS_DISCONNECTED:
@@ -198,11 +158,10 @@ const SystemStatus = () => {
default:
return theme.palette.warning.main;
}
}, [data?.network_status, theme.palette]);
})();
const networkStatus = useMemo(() => {
const networkStatus = (() => {
if (!data) return LL.UNKNOWN();
switch (data.network_status) {
case NetworkConnectionStatus.WIFI_STATUS_NO_SHIELD:
return LL.INACTIVE(1);
@@ -223,227 +182,103 @@ const SystemStatus = () => {
default:
return LL.UNKNOWN();
}
}, [data?.network_status, data?.wifi_rssi, LL]);
})();
const activeHighlight = useCallback(
(value: boolean) =>
value ? theme.palette.success.main : theme.palette.info.main,
[theme.palette]
);
const doRestart = useCallback(async () => {
setConfirmRestart(false);
setRestarting(true);
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
(error: Error) => {
toast.error(error.message);
}
);
}, [sendAPI]);
const handleCloseRestartDialog = useCallback(() => {
setConfirmRestart(false);
}, []);
const renderRestartDialog = useMemo(
() => (
<Dialog
sx={dialogStyle}
open={confirmRestart}
onClose={handleCloseRestartDialog}
>
<DialogTitle>{LL.RESTART()}</DialogTitle>
<DialogContent dividers>{LL.RESTART_CONFIRM()}</DialogContent>
<DialogActions>
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={handleCloseRestartDialog}
color="secondary"
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<PowerSettingsNewIcon />}
variant="outlined"
onClick={doRestart}
color="error"
>
{LL.RESTART()}
</Button>
</DialogActions>
</Dialog>
),
[confirmRestart, handleCloseRestartDialog, doRestart, LL]
);
// Memoize formatted values
const firmwareVersion = useMemo(
() => `v${data?.emsesp_version || ''}`,
[data?.emsesp_version]
);
const uptimeText = useMemo(
() => (data ? formatDurationSec(data.uptime, LL) : ''),
[data?.uptime, LL]
);
const freeMemoryText = useMemo(
() => (data ? `${formatNumber(data.free_heap)} KB ${LL.FREE_MEMORY()}` : ''),
[data?.free_heap, LL]
);
const networkIcon = useMemo(
() =>
data?.network_status === NetworkConnectionStatus.WIFI_STATUS_CONNECTED
? WifiIcon
: RouterIcon,
[data?.network_status]
);
const mqttStatusText = useMemo(
() => (data?.mqtt_status ? LL.CONNECTED(0) : LL.INACTIVE(0)),
[data?.mqtt_status, LL]
);
const apStatusText = useMemo(
() => (data?.ap_status ? LL.ACTIVE() : LL.INACTIVE(0)),
[data?.ap_status, LL]
);
const handleRestartClick = useCallback(() => {
setConfirmRestart(true);
}, []);
const content = useMemo(() => {
if (!data || !LL) {
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
}
const activeHighlight = (value: boolean) =>
value ? theme.palette.success.main : theme.palette.info.main;
if (!data || !LL) {
return (
<>
<List>
<ListMenuItem
icon={BuildIcon}
bgcolor="#72caf9"
label="EMS-ESP Firmware"
text={firmwareVersion}
to="version"
/>
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: '#c5572c', color: 'white' }}>
<MonitorHeartIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={LL.STATUS_OF(LL.SYSTEM(0))}
secondary={`${systemStatus} (${LL.UPTIME()}: ${uptimeText})`}
/>
{me.admin && (
<Button
startIcon={<PowerSettingsNewIcon />}
variant="outlined"
color="error"
onClick={handleRestartClick}
>
{LL.RESTART()}
</Button>
)}
</ListItem>
<ListMenuItem
disabled={!me.admin}
icon={MemoryIcon}
bgcolor="#68374d"
label={LL.HARDWARE()}
text={freeMemoryText}
to="/status/hardwarestatus"
/>
<ListMenuItem
disabled={!me.admin}
icon={DirectionsBusIcon}
bgcolor={busStatusHighlight}
label={LL.DATA_TRAFFIC()}
text={busStatus}
to="/status/activity"
/>
<ListMenuItem
disabled={!me.admin}
icon={networkIcon}
bgcolor={networkStatusHighlight}
label={LL.NETWORK(1)}
text={networkStatus}
to="/status/network"
/>
<ListMenuItem
disabled={!me.admin}
icon={DeviceHubIcon}
bgcolor={activeHighlight(data.mqtt_status)}
label="MQTT"
text={mqttStatusText}
to="/status/mqtt"
/>
<ListMenuItem
disabled={!me.admin}
icon={AccessTimeIcon}
bgcolor={ntpStatusHighlight}
label="NTP"
text={ntpStatus}
to="/status/ntp"
/>
<ListMenuItem
disabled={!me.admin}
icon={SettingsInputAntennaIcon}
bgcolor={activeHighlight(data.ap_status)}
label={LL.ACCESS_POINT(0)}
text={apStatusText}
to="/status/ap"
/>
<ListMenuItem
disabled={!me.admin}
icon={LogoDevIcon}
bgcolor="#40828f"
label={LL.LOG_OF(LL.SYSTEM(0))}
text={LL.VIEW_LOG()}
to="/status/log"
/>
</List>
{renderRestartDialog}
</>
<SectionContent>
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
</SectionContent>
);
}, [
data,
LL,
firmwareVersion,
uptimeText,
freeMemoryText,
networkIcon,
mqttStatusText,
apStatusText,
busStatus,
busStatusHighlight,
networkStatusHighlight,
networkStatus,
ntpStatusHighlight,
ntpStatus,
activeHighlight,
me.admin,
handleRestartClick,
error,
loadData,
renderRestartDialog
]);
}
return restarting ? <SystemMonitor /> : <SectionContent>{content}</SectionContent>;
return (
<SectionContent>
<List>
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: '#c5572c', color: 'white' }}>
<MonitorHeartIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={LL.STATUS_OF(LL.SYSTEM(0))}
secondary={`${systemStatus} (${LL.UPTIME()}: ${formatDurationSec(data.uptime, LL)})`}
/>
</ListItem>
<ListMenuItem
disabled={!me.admin}
icon={MemoryIcon}
bgcolor="#68374d"
label={LL.HARDWARE()}
text={`${formatNumber(data.free_heap)} KB ${LL.FREE_MEMORY()}`}
to="/status/hardwarestatus"
/>
<ListMenuItem
disabled={!me.admin}
icon={DirectionsBusIcon}
bgcolor={busStatusHighlight}
label={LL.DATA_TRAFFIC()}
text={busStatus}
to="/status/activity"
/>
<ListMenuItem
disabled={!me.admin}
icon={
data.network_status === NetworkConnectionStatus.WIFI_STATUS_CONNECTED
? WifiIcon
: RouterIcon
}
bgcolor={networkStatusHighlight}
label={LL.NETWORK(1)}
text={networkStatus}
to="/status/network"
/>
<ListMenuItem
disabled={!me.admin}
icon={DeviceHubIcon}
bgcolor={activeHighlight(data.mqtt_status)}
label="MQTT"
text={data.mqtt_status ? LL.CONNECTED(0) : LL.INACTIVE(0)}
to="/status/mqtt"
/>
<ListMenuItem
disabled={!me.admin}
icon={AccessTimeIcon}
bgcolor={ntpStatusHighlight}
label="NTP"
text={ntpStatus}
to="/status/ntp"
/>
<ListMenuItem
disabled={!me.admin}
icon={SettingsInputAntennaIcon}
bgcolor={activeHighlight(data.ap_status)}
label={LL.ACCESS_POINT(0)}
text={data.ap_status ? LL.ACTIVE() : LL.INACTIVE(0)}
to="/status/ap"
/>
<ListMenuItem
disabled={!me.admin}
icon={LogoDevIcon}
bgcolor="#40828f"
label={LL.LOG_OF(LL.SYSTEM(0))}
text={LL.VIEW_LOG()}
to="/status/log"
/>
</List>
</SectionContent>
);
};
export default SystemStatus;
+11 -19
View File
@@ -1,12 +1,4 @@
import {
memo,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState
} from 'react';
import { toast } from 'react-toastify';
import { memo, useEffect, useLayoutEffect, useRef, useState } from 'react';
import DownloadIcon from '@mui/icons-material/GetApp';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
@@ -33,6 +25,7 @@ import {
SectionContent,
useLayoutTitle
} from 'components';
import { toast } from 'components/toast';
import { useI18nContext } from 'i18n/i18n-react';
import type { LogEntry, LogSettings } from 'types';
import { LogLevel } from 'types';
@@ -185,8 +178,7 @@ const SystemLog = () => {
};
}, [data]); // Recalculate when data changes (in case layout shifts)
// Memoize message handler to avoid recreating on every render
const handleLogMessage = useCallback((message: { data: string }) => {
const handleLogMessage = (message: { data: string }) => {
const rawData = message.data;
const logentry = JSON.parse(rawData) as LogEntry;
setLogEntries((log) => {
@@ -200,7 +192,7 @@ const SystemLog = () => {
const newLog = [...log, logentry];
return newLog;
});
}, []);
};
useSSE(fetchLogES, {
immediate: true,
@@ -211,7 +203,7 @@ const SystemLog = () => {
toast.error('No connection to Log service');
});
const onDownload = useCallback(() => {
const onDownload = () => {
const result = logEntries
.map((i) => `${i.t} ${levelLabel(i.l)} ${i.i}: [${i.n}] ${i.m}`)
.join('\n');
@@ -225,11 +217,11 @@ const SystemLog = () => {
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}, [logEntries]);
};
const saveSettings = useCallback(async () => {
const saveSettings = async () => {
await saveData();
}, [saveData]);
};
// handle scrolling - optimized to only scroll when needed
const ref = useRef<HTMLDivElement>(null);
@@ -246,7 +238,7 @@ const SystemLog = () => {
}
}, [logEntries.length, autoscroll]);
const sendReadCommand = useCallback(() => {
const sendReadCommand = () => {
if (readValue === '') {
setReadOpen(!readOpen);
return;
@@ -257,7 +249,7 @@ const SystemLog = () => {
setReadOpen(false);
setReadValue('');
}
}, [readValue, readOpen, send]);
};
const content = () => {
if (!data) {
@@ -377,7 +369,7 @@ const SystemLog = () => {
) : (
<>
{data.developer_mode && (
<IconButton onClick={sendReadCommand} aria-label={LL.EXECUTE()}>
<IconButton onClick={sendReadCommand} aria-label={LL.RUN()}>
<PlayArrowIcon color="primary" />
</IconButton>
)}
+35 -31
View File
@@ -1,4 +1,4 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import { useRef, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel';
import { Box, Button, Typography } from '@mui/material';
@@ -57,39 +57,31 @@ const SystemMonitor = () => {
void send();
}, 1000); // check every 1 second
const { statusMessage, isUploading, progressValue } = useMemo(() => {
const status = data?.status;
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 statusMessage =
status && status >= SystemStatusCodes.SYSTEM_STATUS_UPLOADING
? LL.WAIT_FIRMWARE()
: status === SystemStatusCodes.SYSTEM_STATUS_PENDING_RESTART
? LL.APPLICATION_RESTARTING()
: status === SystemStatusCodes.SYSTEM_STATUS_NORMAL
? LL.RESTARTING_PRE()
: status === SystemStatusCodes.SYSTEM_STATUS_ERROR_UPLOAD
? 'Upload Failed'
: LL.RESTARTING_POST();
const uploading =
status !== undefined && status >= SystemStatusCodes.SYSTEM_STATUS_UPLOADING;
const progress =
uploading && status
? Math.round(status - SystemStatusCodes.SYSTEM_STATUS_UPLOADING)
: 0;
const isUploading =
status !== undefined && status >= SystemStatusCodes.SYSTEM_STATUS_UPLOADING;
const progressValue =
isUploading && status
? Math.round(status - SystemStatusCodes.SYSTEM_STATUS_UPLOADING)
: 0;
return {
statusMessage: message,
isUploading: uploading,
progressValue: progress
};
}, [data?.status, LL]);
const onCancel = useCallback(async () => {
const onCancel = async () => {
setErrorMessage(undefined);
await setSystemStatus(String(SystemStatusCodes.SYSTEM_STATUS_NORMAL));
document.location.href = '/';
}, [setSystemStatus]);
};
return (
<Box
@@ -153,9 +145,21 @@ const SystemMonitor = () => {
{LL.PLEASE_WAIT()}&hellip;
</Typography>
{isUploading && (
<Box sx={{ width: '100%', pl: 2, pr: 2, py: 2 }}>
<LinearProgressWithLabel value={progressValue} />
</Box>
<>
<Box sx={{ width: '100%', pl: 2, pr: 2, py: 2 }}>
<LinearProgressWithLabel value={progressValue} />
</Box>
<Button
sx={{ ml: 2, mt: 2 }}
startIcon={<CancelIcon />}
variant="outlined"
color="secondary"
onClick={onCancel}
>
{LL.CANCEL()}
</Button>
</>
)}
</>
)}
-931
View File
@@ -1,931 +0,0 @@
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';
import CloseIcon from '@mui/icons-material/Close';
import CheckIcon from '@mui/icons-material/Done';
import DownloadIcon from '@mui/icons-material/GetApp';
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
import WarningIcon from '@mui/icons-material/Warning';
import {
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Grid,
IconButton,
Table,
TableBody,
TableCell,
TableRow,
Typography
} from '@mui/material';
import * as SystemApi from 'api/system';
import { API, callAction } from 'api/app';
import { getDevVersion, getStableVersion } from 'api/system';
import { dialogStyle } from 'CustomTheme';
import { useRequest } from 'alova/client';
import type { APIcall } from 'app/main/types';
import SystemMonitor from 'app/status/SystemMonitor';
import {
FormLoader,
SectionContent,
SingleUpload,
useLayoutTitle
} from 'components';
import { AuthenticatedContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react';
import type { TranslationFunctions } from 'i18n/i18n-types';
import { prettyDateTime } from 'utils/time';
// Constants moved outside component to avoid recreation
const STABLE_URL = 'https://github.com/emsesp/EMS-ESP32/releases/download/';
const STABLE_RELNOTES_URL =
'https://github.com/emsesp/EMS-ESP32/blob/main/CHANGELOG.md';
const DEV_URL = 'https://github.com/emsesp/EMS-ESP32/releases/download/latest/';
const DEV_RELNOTES_URL =
'https://github.com/emsesp/EMS-ESP32/blob/dev/CHANGELOG_LATEST.md';
// Types for better type safety
interface PartitionData {
partition: string;
version: string;
install_date?: string;
size: number;
}
interface VersionData {
emsesp_version: string;
arduino_version: string;
esp_platform: string;
flash_chip_size: number;
psram: boolean;
build_flags?: string;
partition: string;
partitions: PartitionData[];
developer_mode: boolean;
}
interface UpgradeCheckData {
emsesp_version: string;
dev_upgradeable: boolean;
stable_upgradeable: boolean;
}
interface VersionInfo {
name: string;
published_at?: string;
}
// Memoized components for better performance
const VersionInfoDialog = memo(
({
showVersionInfo,
latestVersion,
latestDevVersion,
partitionVersion,
partition,
currentPartition,
size,
locale,
LL,
onClose
}: {
showVersionInfo: number;
latestVersion?: VersionInfo;
latestDevVersion?: VersionInfo;
partitionVersion?: VersionInfo | undefined;
partition: string;
currentPartition: string;
size: number;
locale: string;
LL: TranslationFunctions;
onClose: () => void;
}) => {
if (showVersionInfo === 0) return null;
const isStable = showVersionInfo === 1;
const isDev = showVersionInfo === 2;
const isPartition = showVersionInfo === 3;
const version = isStable
? latestVersion
: isDev
? latestDevVersion
: partitionVersion;
const relNotesUrl = isStable
? STABLE_RELNOTES_URL
: isDev
? DEV_RELNOTES_URL
: '';
return (
<Dialog sx={dialogStyle} open={showVersionInfo !== 0} onClose={onClose}>
<DialogTitle>{LL.FIRMWARE_VERSION_INFO()}</DialogTitle>
<DialogContent dividers>
<Table size="small" sx={{ borderCollapse: 'collapse', minWidth: 0 }}>
<TableBody>
<TableRow sx={{ height: 24, borderBottom: 'none' }}>
<TableCell
component="th"
scope="row"
sx={{
color: 'lightblue',
borderBottom: 'none',
pr: 1,
py: 0.5,
fontSize: 13
}}
>
{LL.VERSION()}
</TableCell>
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
{isPartition
? typeof version === 'string'
? version
: version?.name
: version?.name}
</TableCell>
</TableRow>
<TableRow sx={{ height: 24, borderBottom: 'none' }}>
<TableCell
component="th"
scope="row"
sx={{
color: 'lightblue',
borderBottom: 'none',
pr: 1,
py: 0.5,
fontSize: 13,
width: 140
}}
>
{isPartition ? LL.TYPE(0) : LL.RELEASE_TYPE()}
</TableCell>
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
{partition === currentPartition && LL.ACTIVE() + ' '}
{isStable
? LL.STABLE()
: isDev
? LL.DEVELOPMENT()
: 'Partition ' + LL.VERSION()}
</TableCell>
</TableRow>
{isPartition && (
<TableRow sx={{ height: 24, borderBottom: 'none' }}>
<TableCell
component="th"
scope="row"
sx={{
color: 'lightblue',
borderBottom: 'none',
pr: 1,
py: 0.5,
fontSize: 13
}}
>
Partition
</TableCell>
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
{partition}
</TableCell>
</TableRow>
)}
{isPartition && (
<TableRow sx={{ height: 24, borderBottom: 'none' }}>
<TableCell
component="th"
scope="row"
sx={{
color: 'lightblue',
borderBottom: 'none',
pr: 1,
py: 0.5,
fontSize: 13
}}
>
Size
</TableCell>
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
{size} KB
</TableCell>
</TableRow>
)}
{version?.published_at && (
<TableRow sx={{ height: 24, borderBottom: 'none' }}>
<TableCell
component="th"
scope="row"
sx={{
color: 'lightblue',
borderBottom: 'none',
pr: 1,
py: 0.5,
fontSize: 13
}}
>
{isPartition ? 'Install Date' : 'Build Date'}
</TableCell>
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
{prettyDateTime(locale, new Date(version.published_at))}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</DialogContent>
<DialogActions>
{!isPartition && (
<Button
variant="outlined"
component="a"
href={relNotesUrl}
target="_blank"
color="primary"
>
Changelog
</Button>
)}
<Button variant="outlined" onClick={onClose} color="secondary">
{LL.CLOSE()}
</Button>
</DialogActions>
</Dialog>
);
}
);
const InstallDialog = memo(
({
openInstallDialog,
fetchDevVersion,
latestVersion,
latestDevVersion,
upgradeImportantMessageType,
downloadOnly,
platform,
LL,
onClose,
onInstall
}: {
openInstallDialog: boolean;
fetchDevVersion: boolean;
latestVersion?: VersionInfo;
latestDevVersion?: VersionInfo;
upgradeImportantMessageType: number;
downloadOnly: boolean;
platform: string;
LL: TranslationFunctions;
onClose: () => void;
onInstall: (url: string) => void;
}) => {
const binURL = useMemo(() => {
if (!latestVersion || !latestDevVersion) return '';
const version = fetchDevVersion ? latestDevVersion : latestVersion;
const filename = `EMS-ESP-${version.name.replaceAll('.', '_')}-${platform}.bin`;
return fetchDevVersion
? `${DEV_URL}${filename}`
: `${STABLE_URL}v${version.name}/${filename}`;
}, [fetchDevVersion, latestVersion, latestDevVersion, platform]);
return (
<Dialog sx={dialogStyle} open={openInstallDialog} onClose={onClose}>
<DialogTitle>
{`${LL.INSTALL()} ${fetchDevVersion ? LL.DEVELOPMENT() : LL.STABLE()} Firmware`}
</DialogTitle>
<DialogContent dividers>
<Typography sx={{ mb: 2 }}>
{LL.INSTALL_VERSION(
downloadOnly ? LL.DOWNLOAD(1) : LL.INSTALL(),
fetchDevVersion ? latestDevVersion?.name : latestVersion?.name
)}
</Typography>
{upgradeImportantMessageType === 2 && LL.UPGRADE_IMPORTANT_MESSAGES_2()}
{upgradeImportantMessageType === 1 && (
<>
{LL.UPGRADE_IMPORTANT_MESSAGES_1()}
<Typography sx={{ mt: 2 }}>
<Link to="/settings/downloadUpload" style={{ color: 'lightblue' }}>
{LL.DOWNLOAD_SYSTEM_BACKUP()}
</Link>
</Typography>
</>
)}
<Typography sx={{ mt: 2 }}>
<Link
to="https://docs.emsesp.org/FAQ#upgrading-the-firmware"
target="_blank"
rel="noreferrer"
style={{ color: 'lightblue' }}
>
{LL.ONLINE_HELP()}
</Link>
</Typography>
</DialogContent>
<DialogActions>
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={onClose}
color="secondary"
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<DownloadIcon />}
variant="outlined"
onClick={onClose}
color="primary"
>
<Link
to={binURL}
target="_blank"
rel="noreferrer"
style={{ color: 'lightblue', textDecoration: 'none' }}
>
{LL.DOWNLOAD(0)}
</Link>
</Button>
{!downloadOnly && (
<Button
startIcon={<WarningIcon color="warning" />}
variant="outlined"
onClick={() => onInstall(binURL)}
color="primary"
>
{LL.INSTALL()}
</Button>
)}
</DialogActions>
</Dialog>
);
}
);
const InstallPartitionDialog = memo(
({
openInstallPartitionDialog,
version,
partition,
LL,
onClose,
onInstall
}: {
openInstallPartitionDialog: boolean;
version: string;
partition: string;
LL: TranslationFunctions;
onClose: () => void;
onInstall: (partition: string) => void;
}) => {
return (
<Dialog sx={dialogStyle} open={openInstallPartitionDialog} onClose={onClose}>
<DialogTitle>
{LL.INSTALL()} {LL.STORED_VERSIONS()}
</DialogTitle>
<DialogContent dividers>
<Typography sx={{ mb: 2 }}>
{LL.INSTALL_VERSION(LL.INSTALL(), version)}
</Typography>
</DialogContent>
<DialogActions>
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={onClose}
color="secondary"
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<WarningIcon color="warning" />}
variant="outlined"
onClick={() => onInstall(partition)}
color="primary"
>
{LL.INSTALL()}
</Button>
</DialogActions>
</Dialog>
);
}
);
// Helper function moved outside component
const getPlatform = (data: VersionData): string => {
return `${data.esp_platform}-${data.flash_chip_size >= 16384 ? '16MB' : '4MB'}${data.psram ? '+' : ''}`;
};
const Version = () => {
const { LL, locale } = useI18nContext();
const { me } = useContext(AuthenticatedContext);
// State management
const [restarting, setRestarting] = useState<boolean>(false);
const [openInstallDialog, setOpenInstallDialog] = useState<boolean>(false);
const [partitionVersion, setPartitionVersion] = useState<VersionInfo | undefined>(
undefined
);
const [partition, setPartition] = useState<string>('');
const [openInstallPartitionDialog, setOpenInstallPartitionDialog] =
useState<boolean>(false);
const [usingDevVersion, setUsingDevVersion] = useState<boolean>(false);
const [fetchDevVersion, setFetchDevVersion] = useState<boolean>(false);
const [devUpgradeAvailable, setDevUpgradeAvailable] = useState<boolean>(false);
const [stableUpgradeAvailable, setStableUpgradeAvailable] =
useState<boolean>(false);
const [internetLive, setInternetLive] = useState<boolean>(false);
const [downloadOnly, setDownloadOnly] = useState<boolean>(false);
const [showVersionInfo, setShowVersionInfo] = useState<number>(0); // 1 = stable, 2 = dev, 3 = partition
const [firmwareSize, setFirmwareSize] = useState<number>(0);
const { send: sendCheckUpgrade } = useRequest(
(versions: string) => callAction({ action: 'checkUpgrade', param: versions }),
{ immediate: false }
).onSuccess((event) => {
const data = event.data as UpgradeCheckData;
setDevUpgradeAvailable(data.dev_upgradeable);
setStableUpgradeAvailable(data.stable_upgradeable);
});
const { send: sendSetPartition } = useRequest(
(partition: string) => callAction({ action: 'setPartition', param: partition }),
{ immediate: false }
).onError((error) => {
toast.error(String(error.error?.message || 'An error occurred'));
});
const {
data,
send: loadData,
error
} = useRequest(SystemApi.readSystemStatus).onSuccess((event) => {
const systemData = event.data as VersionData;
if (systemData.arduino_version.startsWith('Tasmota')) {
setDownloadOnly(true);
}
setUsingDevVersion(systemData.emsesp_version.includes('dev'));
});
const { send: sendUploadURL } = useRequest(
(url: string) => callAction({ action: 'uploadURL', param: url }),
{ immediate: false }
);
const { data: latestVersion } = useRequest(getStableVersion);
const { data: latestDevVersion } = useRequest(getDevVersion);
const { send: sendAPI } = useRequest((data: APIcall) => API(data), {
immediate: false
});
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]);
// Memoize filtered partitions to avoid recomputing on every render
const otherPartitions = useMemo(
() => data?.partitions.filter((p) => p.partition !== data.partition) ?? [],
[data]
);
const setPartitionVersionInfo = useCallback(
(partition: string) => {
setShowVersionInfo(3);
// search for the partition in the data.partitions array
const partitionData = data?.partitions.find((p) => p.partition === partition);
if (partitionData) {
setPartitionVersion({
name: partitionData.version,
published_at: partitionData.install_date ?? ''
});
setPartition(partitionData.partition);
setFirmwareSize(partitionData.size);
}
},
[data]
);
const doRestart = useCallback(async () => {
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
(error: Error) => {
toast.error(error.message);
}
);
setRestarting(true);
}, [sendAPI]);
const installFirmwareURL = useCallback(
async (url: string) => {
await sendUploadURL(url).catch((error: Error) => {
toast.error(error.message);
});
await doRestart();
},
[sendUploadURL, doRestart]
);
const installPartitionFirmware = useCallback(
async (partition: string) => {
await sendSetPartition(partition).catch((error: Error) => {
toast.error(error.message);
});
setRestarting(true);
},
[sendSetPartition]
);
const showPartitionDialog = useCallback(
(version: string, partition: string, install_date: string) => {
setOpenInstallPartitionDialog(true);
setPartitionVersion({ name: version, published_at: install_date });
setPartition(partition);
},
[]
);
const showFirmwareDialog = useCallback(
(useDevVersion: boolean) => {
setFetchDevVersion(useDevVersion);
void checkUpgradeImportantMessages(
useDevVersion ? latestDevVersion?.name : latestVersion?.name
);
setOpenInstallDialog(true);
},
[latestDevVersion, latestVersion, fetchDevVersion]
);
const closeInstallDialog = useCallback(() => {
setOpenInstallDialog(false);
}, []);
const closeInstallPartitionDialog = useCallback(() => {
setOpenInstallPartitionDialog(false);
}, []);
const handleVersionInfoClose = useCallback(() => {
setShowVersionInfo(0);
setPartitionVersion(undefined);
setPartition('');
}, []);
// check upgrades - only once when both versions are available
const upgradeCheckedRef = useRef(false);
useEffect(() => {
if (latestVersion && latestDevVersion && !upgradeCheckedRef.current) {
upgradeCheckedRef.current = true;
const versions = `${latestDevVersion.name},${latestVersion.name}`;
sendCheckUpgrade(versions)
.catch((error: Error) => {
toast.error(`Failed to check for upgrades: ${error.message}`);
})
.finally(() => {
setInternetLive(true);
});
}
}, [latestVersion, latestDevVersion, sendCheckUpgrade]);
useLayoutTitle('EMS-ESP Firmware');
// Memoized button rendering logic
const showButtons = useCallback(
(showingDev: boolean) => {
const choice = showingDev
? !usingDevVersion
? LL.SWITCH_RELEASE_TYPE(LL.DEVELOPMENT())
: devUpgradeAvailable
? LL.UPDATE_AVAILABLE()
: undefined
: usingDevVersion
? LL.SWITCH_RELEASE_TYPE(LL.STABLE())
: stableUpgradeAvailable
? LL.UPDATE_AVAILABLE()
: undefined;
if (!choice) {
return (
<>
<CheckIcon
color="success"
sx={{ verticalAlign: 'middle', ml: 0.5, mr: 0.5 }}
/>
<span style={{ color: '#66bb6a', fontSize: '0.8em' }}>
{LL.LATEST_VERSION(usingDevVersion ? LL.DEVELOPMENT() : LL.STABLE())}
</span>
<Button
sx={{ ml: 1 }}
variant="outlined"
size="small"
onClick={() => showFirmwareDialog(showingDev)}
>
{LL.REINSTALL()}
</Button>
</>
);
}
if (!me.admin) return null;
return (
<Button
sx={{ ml: 1 }}
variant="outlined"
color={choice === LL.UPDATE_AVAILABLE() ? 'success' : 'warning'}
size="small"
onClick={() => showFirmwareDialog(showingDev)}
>
{choice}
</Button>
);
},
[
usingDevVersion,
devUpgradeAvailable,
stableUpgradeAvailable,
me.admin,
LL,
showFirmwareDialog
]
);
const content = useMemo(() => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
}
return (
<>
<Box sx={{ p: 2, border: '1px solid #565656', borderRadius: 2 }}>
<Typography sx={{ mb: 1 }} variant="h6" color="primary">
{LL.THIS_VERSION()}
</Typography>
<Grid
container
direction="row"
sx={{
justifyContent: 'flex-start',
alignItems: 'baseline'
}}
>
<Grid size={{ xs: 4, md: 2 }}>
<Typography color="secondary">{LL.VERSION()}</Typography>
</Grid>
<Grid size={{ xs: 8, md: 10 }}>
<Typography>
{data.emsesp_version}
{data.build_flags && (
<Typography variant="caption">
&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>
<Grid size={{ xs: 4, md: 2 }}>
<Typography color="secondary">{LL.PLATFORM()}</Typography>
</Grid>
<Grid size={{ xs: 8, md: 10 }}>
<Typography>
{platform}
<Typography variant="caption">
&nbsp; &#40;
{data.psram ? (
<CheckIcon
color="success"
sx={{
fontSize: '1.5em',
verticalAlign: 'middle'
}}
/>
) : (
<CloseIcon
color="error"
sx={{
fontSize: '1.5em',
verticalAlign: 'middle'
}}
/>
)}
PSRAM&#41;
</Typography>
</Typography>
</Grid>
</Grid>
{internetLive ? (
<>
<Typography sx={{ mt: 4, mb: 1 }} variant="h6" color="primary">
{LL.AVAILABLE_VERSION()}
</Typography>
<Grid
container
direction="row"
rowSpacing={1}
sx={{
justifyContent: 'flex-start',
alignItems: 'baseline'
}}
>
{otherPartitions.length > 0 && data.developer_mode && (
<>
<Grid size={{ xs: 4, md: 2 }}>
<Typography color="secondary">
{LL.STORED_VERSIONS()}
</Typography>
</Grid>
<Grid size={{ xs: 8, md: 10 }}>
{otherPartitions.map((partition) => (
<Typography key={partition.partition} sx={{ mb: 1 }}>
{partition.version}
<IconButton
onClick={() =>
setPartitionVersionInfo(partition.partition)
}
aria-label={LL.FIRMWARE_VERSION_INFO()}
>
<InfoOutlinedIcon
color="primary"
sx={{ fontSize: 18 }}
/>
</IconButton>
<Button
sx={{ ml: 0 }}
variant="outlined"
size="small"
onClick={() =>
showPartitionDialog(
partition.version,
partition.partition,
partition.install_date ?? ''
)
}
>
{LL.INSTALL()}
</Button>
</Typography>
))}
</Grid>
</>
)}
<Grid size={{ xs: 4, md: 2 }}>
<Typography color="secondary">{LL.STABLE()}</Typography>
</Grid>
<Grid size={{ xs: 8, md: 10 }}>
<Typography>
{latestVersion?.name}
<IconButton
onClick={() => setShowVersionInfo(1)}
aria-label={LL.FIRMWARE_VERSION_INFO()}
>
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
</IconButton>
{showButtons(false)}
</Typography>
</Grid>
<Grid size={{ xs: 4, md: 2 }}>
<Typography color="secondary">{LL.DEVELOPMENT()}</Typography>
</Grid>
<Grid size={{ xs: 8, md: 10 }}>
<Typography>
{latestDevVersion?.name}
<IconButton
onClick={() => setShowVersionInfo(2)}
aria-label={LL.FIRMWARE_VERSION_INFO()}
>
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
</IconButton>
{showButtons(true)}
</Typography>
</Grid>
</Grid>
</>
) : (
<Typography sx={{ mt: 2 }} color="warning">
<WarningIcon color="warning" sx={{ verticalAlign: 'middle', mr: 2 }} />
{LL.INTERNET_CONNECTION_REQUIRED()}
</Typography>
)}
{me.admin && (
<>
<VersionInfoDialog
showVersionInfo={showVersionInfo}
latestVersion={latestVersion}
latestDevVersion={latestDevVersion}
partitionVersion={partitionVersion}
locale={locale}
partition={partition}
currentPartition={data?.partition ?? ''}
size={firmwareSize}
LL={LL}
onClose={handleVersionInfoClose}
/>
<InstallDialog
openInstallDialog={openInstallDialog}
fetchDevVersion={fetchDevVersion}
latestVersion={latestVersion}
latestDevVersion={latestDevVersion}
upgradeImportantMessageType={upgradeImportantMessageType}
downloadOnly={downloadOnly}
platform={platform}
LL={LL}
onClose={closeInstallDialog}
onInstall={installFirmwareURL}
/>
<InstallPartitionDialog
openInstallPartitionDialog={openInstallPartitionDialog}
version={partitionVersion?.name || ''}
partition={partition}
LL={LL}
onClose={closeInstallPartitionDialog}
onInstall={installPartitionFirmware}
/>
<Typography sx={{ pt: 2, pb: 2 }} variant="h6" color="primary">
{LL.UPLOAD()}
</Typography>
<SingleUpload doRestart={doRestart} />
</>
)}
</Box>
</>
);
}, [
data,
error,
loadData,
LL,
platform,
internetLive,
latestVersion,
latestDevVersion,
showVersionInfo,
locale,
openInstallDialog,
fetchDevVersion,
downloadOnly,
me.admin,
showButtons,
handleVersionInfoClose,
closeInstallDialog,
installFirmwareURL,
doRestart,
otherPartitions,
setPartitionVersionInfo,
showPartitionDialog,
partitionVersion,
partition,
firmwareSize,
closeInstallPartitionDialog,
installPartitionFirmware
]);
return restarting ? <SystemMonitor /> : <SectionContent>{content}</SectionContent>;
};
export default memo(Version);
+12 -13
View File
@@ -1,4 +1,4 @@
import { type FC, type PropsWithChildren, memo, useMemo } from 'react';
import { type FC, type PropsWithChildren, memo } from 'react';
import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined';
import ErrorIcon from '@mui/icons-material/Error';
@@ -38,18 +38,17 @@ const MessageBox: FC<PropsWithChildren<MessageBoxProps>> = ({
}) => {
const theme = useTheme();
const { Icon, backgroundColor } = useMemo(() => {
const Icon = LEVEL_ICONS[level];
const palettePath = LEVEL_PALETTE_PATHS[level];
const [key, shade] = palettePath.split('.') as [
keyof typeof theme.palette,
string
];
const paletteKey = theme.palette[key] as unknown as Record<string, string>;
const backgroundColor = paletteKey[shade];
return { Icon, backgroundColor };
}, [level, theme]);
const Icon = LEVEL_ICONS[level];
const palettePath = LEVEL_PALETTE_PATHS[level];
const [paletteKeyName, shade] = palettePath.split('.') as [
keyof typeof theme.palette,
string
];
const paletteKey = theme.palette[paletteKeyName] as unknown as Record<
string,
string
>;
const backgroundColor = paletteKey[shade];
return (
<Box
@@ -1,10 +1,10 @@
import { memo, useCallback, useContext, useMemo } from 'react';
import { memo, useContext } from 'react';
import type { ChangeEventHandler } from 'react';
import type { CSSProperties } from 'react';
import { MenuItem, TextField } from '@mui/material';
import CZflag from 'i18n/CZ.svg';
import CZflag from 'i18n/CS.svg';
import DEflag from 'i18n/DE.svg';
import FRflag from 'i18n/FR.svg';
import GBflag from 'i18n/GB.svg';
@@ -44,27 +44,14 @@ const LANGUAGE_OPTIONS: LanguageOption[] = [
const LanguageSelector = () => {
const { setLocale, locale, LL } = useContext(I18nContext);
const onLocaleSelected: ChangeEventHandler<HTMLInputElement> = useCallback(
async ({ target }) => {
const loc = target.value as Locales;
localStorage.setItem('lang', loc);
await loadLocaleAsync(loc);
setLocale(loc);
},
[setLocale]
);
// Memoize menu items to prevent recreation on every render
const menuItems = useMemo(
() =>
LANGUAGE_OPTIONS.map(({ key, flag, label }) => (
<MenuItem key={key} value={key}>
<img src={flag} style={flagStyle} alt={label} />
&nbsp;{label}
</MenuItem>
)),
[]
);
const onLocaleSelected: ChangeEventHandler<HTMLInputElement> = async ({
target
}) => {
const loc = target.value as Locales;
localStorage.setItem('lang', loc);
await loadLocaleAsync(loc);
setLocale(loc);
};
return (
<TextField
@@ -76,7 +63,12 @@ const LanguageSelector = () => {
size="small"
select
>
{menuItems}
{LANGUAGE_OPTIONS.map(({ key, flag, label }) => (
<MenuItem key={key} value={key}>
<img src={flag} style={flagStyle} alt={label} />
&nbsp;{label}
</MenuItem>
))}
</TextField>
);
};
@@ -1,4 +1,4 @@
import { memo, useCallback, useState } from 'react';
import { memo, useState } from 'react';
import type { FC } from 'react';
import VisibilityIcon from '@mui/icons-material/Visibility';
@@ -13,9 +13,9 @@ type ValidatedPasswordFieldProps = Omit<ValidatedTextFieldProps, 'type'>;
const ValidatedPasswordField: FC<ValidatedPasswordFieldProps> = ({ ...props }) => {
const [showPassword, setShowPassword] = useState<boolean>(false);
const togglePasswordVisibility = useCallback(() => {
const togglePasswordVisibility = () => {
setShowPassword((prev) => !prev);
}, []);
};
return (
<ValidatedTextField
@@ -4,7 +4,7 @@ import type { FC } from 'react';
import { FormHelperText, TextField } from '@mui/material';
import type { TextFieldProps } from '@mui/material';
import type { ValidateFieldsError } from 'async-validator';
import type { ValidateFieldsError } from 'validators/schema';
interface ValidatedFieldProps {
fieldErrors?: ValidateFieldsError;
@@ -18,7 +18,6 @@ const LayoutComponent: FC<RequiredChildrenProps> = ({ children }) => {
const [title, setTitle] = useState(PROJECT_NAME);
const { pathname } = useLocation();
// Memoize drawer toggle handler to prevent unnecessary re-renders
const handleDrawerToggle = useCallback(() => {
setMobileOpen((prev) => !prev);
}, []);
@@ -28,7 +27,6 @@ const LayoutComponent: FC<RequiredChildrenProps> = ({ children }) => {
setMobileOpen(false);
}, [pathname]);
// Memoize context value to prevent unnecessary re-renders
const contextValue = useMemo(() => ({ title, setTitle }), [title]);
return (
@@ -1,4 +1,4 @@
import { memo, useCallback, useMemo } from 'react';
import { memo } from 'react';
import { Link, useLocation, useNavigate } from 'react-router';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
@@ -39,14 +39,11 @@ const LayoutAppBarComponent = ({ title, onToggleDrawer }: LayoutAppBarProps) =>
const navigate = useNavigate();
const location = useLocation();
const pathnames = useMemo(
() => location.pathname.split('/').filter((x) => x),
[location.pathname]
);
const pathnames = location.pathname.split('/').filter((x) => x);
const handleBackClick = useCallback(() => {
const handleBackClick = () => {
void navigate('/' + pathnames[0]);
}, [navigate, pathnames]);
};
return (
<AppBar position="fixed" sx={appBarStyles}>
@@ -1,7 +1,8 @@
import { memo, useMemo } from 'react';
import { memo, useContext } from 'react';
import { Box, Divider, Drawer, Toolbar, Typography, styled } from '@mui/material';
import { AuthenticatedContext } from 'contexts/authentication';
import { PROJECT_NAME } from 'env';
import { DRAWER_WIDTH } from './Layout';
@@ -24,22 +25,33 @@ interface LayoutDrawerProps {
}
const LayoutDrawerComponent = ({ mobileOpen, onClose }: LayoutDrawerProps) => {
// Memoize drawer content to prevent unnecessary re-renders
const drawer = useMemo(
() => (
<>
<Toolbar disableGutters>
<Box sx={{ display: 'flex', alignItems: 'center', p: 2 }}>
<LayoutDrawerLogo src="/app/icon.png" alt={PROJECT_NAME} />
<Typography variant="h6">{PROJECT_NAME}</Typography>
const { systemName } = useContext(AuthenticatedContext);
const drawer = (
<>
<Toolbar disableGutters>
<Box sx={{ display: 'flex', alignItems: 'center', p: 2 }}>
<LayoutDrawerLogo src="/app/icon.png" alt={PROJECT_NAME} />
<Box
sx={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'center'
}}
>
<Typography>{PROJECT_NAME}</Typography>
{systemName && (
<Typography color="secondary" variant="body2">
{systemName}
</Typography>
)}
</Box>
<Divider absolute />
</Toolbar>
<Divider />
<LayoutMenu />
</>
),
[]
</Box>
<Divider absolute />
</Toolbar>
<Divider />
<LayoutMenu />
</>
);
return (
+14 -4
View File
@@ -1,4 +1,4 @@
import { memo, useCallback, useContext, useState } from 'react';
import { memo, useContext, useState } from 'react';
import AccountCircleIcon from '@mui/icons-material/AccountCircle';
import AssessmentIcon from '@mui/icons-material/Assessment';
@@ -8,6 +8,7 @@ import KeyboardArrowDown from '@mui/icons-material/KeyboardArrowDown';
import LiveHelpIcon from '@mui/icons-material/LiveHelp';
import MoreTimeIcon from '@mui/icons-material/MoreTime';
import PlaylistAddIcon from '@mui/icons-material/PlaylistAdd';
import PlaylistPlayIcon from '@mui/icons-material/PlaylistPlay';
import SensorsIcon from '@mui/icons-material/Sensors';
import SettingsIcon from '@mui/icons-material/Settings';
import StarIcon from '@mui/icons-material/Star';
@@ -18,13 +19,15 @@ import { AuthenticatedContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react';
const LayoutMenuComponent = () => {
const { me } = useContext(AuthenticatedContext);
const { me, versions } = useContext(AuthenticatedContext);
const { LL } = useI18nContext();
const [menuOpen, setMenuOpen] = useState(true);
const handleMenuToggle = useCallback(() => {
const upgradeAvailable = versions?.current?.upgradeable ?? false;
const handleMenuToggle = () => {
setMenuOpen((prev) => !prev);
}, []);
};
return (
<>
@@ -78,6 +81,12 @@ const LayoutMenuComponent = () => {
disabled={!me.admin}
to={`/customizations`}
/>
<LayoutMenuItem
icon={PlaylistPlayIcon}
label={LL.COMMANDS()}
disabled={!me.admin}
to={`/commands`}
/>
<LayoutMenuItem
icon={MoreTimeIcon}
label={LL.SCHEDULER()}
@@ -105,6 +114,7 @@ const LayoutMenuComponent = () => {
label={LL.SETTINGS(0)}
disabled={!me.admin}
to="/settings"
badge={upgradeAvailable}
/>
<LayoutMenuItem icon={LiveHelpIcon} label={LL.HELP()} to={`/help`} />
<Divider />
@@ -1,7 +1,7 @@
import { memo, useMemo } from 'react';
import { memo } from 'react';
import { Link, useLocation } from 'react-router';
import { ListItemButton, ListItemIcon, ListItemText } from '@mui/material';
import { Box, ListItemButton, ListItemIcon, ListItemText } from '@mui/material';
import type { SvgIconProps, SxProps, Theme } from '@mui/material';
import { routeMatches } from 'utils';
@@ -11,60 +11,52 @@ interface LayoutMenuItemProps {
label: string;
to: string;
disabled?: boolean;
badge?: boolean;
}
const LayoutMenuItemComponent = ({
icon: Icon,
label,
to,
disabled
disabled,
badge
}: LayoutMenuItemProps) => {
const { pathname } = useLocation();
const selected = useMemo(() => routeMatches(to, pathname), [to, pathname]);
const selected = routeMatches(to, pathname);
// Memoize dynamic styles based on selected state
const buttonStyles: SxProps<Theme> = useMemo(
() => ({
transition: 'all 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)',
backgroundColor: selected ? 'rgba(144, 202, 249, 0.1)' : 'transparent',
borderRadius: '8px',
margin: '2px 8px',
'&:hover': {
backgroundColor: 'rgba(68, 82, 211, 0.39)'
},
'&::before': {
content: '""',
position: 'absolute',
left: 0,
top: 0,
bottom: 0,
width: selected ? '3px' : '0px',
backgroundColor: '#90caf9',
transition: 'width 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)'
}
}),
[selected]
);
const buttonStyles: SxProps<Theme> = {
transition: 'all 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)',
backgroundColor: selected ? 'rgba(144, 202, 249, 0.1)' : 'transparent',
borderRadius: '8px',
margin: '2px 8px',
'&:hover': {
backgroundColor: 'rgba(68, 82, 211, 0.39)'
},
'&::before': {
content: '""',
position: 'absolute',
left: 0,
top: 0,
bottom: 0,
width: selected ? '3px' : '0px',
backgroundColor: '#90caf9',
transition: 'width 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)'
}
};
const iconStyles: SxProps<Theme> = useMemo(
() => ({
color: selected ? '#90caf9' : '#9e9e9e',
transition: 'color 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)',
transform: selected ? 'scale(1.1)' : 'scale(1)',
transitionProperty: 'color, transform'
}),
[selected]
);
const iconStyles: SxProps<Theme> = {
color: selected ? '#90caf9' : '#9e9e9e',
transition: 'color 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)',
transform: selected ? 'scale(1.1)' : 'scale(1)',
transitionProperty: 'color, transform'
};
const textStyles: SxProps<Theme> = useMemo(
() => ({
color: selected ? '#90caf9' : '#f5f5f5',
transition: 'color 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)',
transitionProperty: 'color, font-weight'
}),
[selected]
);
const textStyles: SxProps<Theme> = {
color: selected ? '#90caf9' : '#f5f5f5',
transition: 'color 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)',
transitionProperty: 'color, font-weight'
};
return (
<ListItemButton
@@ -78,6 +70,20 @@ const LayoutMenuItemComponent = ({
<Icon />
</ListItemIcon>
<ListItemText sx={textStyles}>{label}</ListItemText>
{badge && (
<Box
aria-label="update available"
sx={{
width: 8,
height: 8,
ml: 1,
borderRadius: '50%',
backgroundColor: '#ffeb3b',
boxShadow: '0 0 6px rgba(255, 235, 59, 0.8)',
flexShrink: 0
}}
/>
)}
</ListItemButton>
);
};
@@ -5,6 +5,7 @@ import { Link } from 'react-router';
import NavigateNextIcon from '@mui/icons-material/NavigateNext';
import {
Avatar,
Box,
ListItem,
ListItemAvatar,
ListItemButton,
@@ -20,6 +21,7 @@ interface ListMenuItemProps {
text: string;
to?: string;
disabled?: boolean;
badge?: boolean;
}
const iconStyles: CSSProperties = {
@@ -28,15 +30,40 @@ const iconStyles: CSSProperties = {
verticalAlign: 'middle'
};
const Badge = () => (
<Box
component="span"
aria-label="update available"
sx={{
display: 'inline-block',
width: 8,
height: 8,
ml: 1,
verticalAlign: 'middle',
borderRadius: '50%',
backgroundColor: '#ffeb3b',
boxShadow: '0 0 6px rgba(255, 235, 59, 0.8)'
}}
/>
);
const RenderIcon = memo(
({ icon: Icon, bgcolor, label, text }: ListMenuItemProps) => (
({ icon: Icon, bgcolor, label, text, badge }: ListMenuItemProps) => (
<>
<ListItemAvatar>
<Avatar sx={{ bgcolor, color: 'white' }}>
<Icon />
</Avatar>
</ListItemAvatar>
<ListItemText primary={label} secondary={text} />
<ListItemText
primary={
<>
{label}
{badge && <Badge />}
</>
}
secondary={text}
/>
</>
)
);
@@ -47,7 +74,8 @@ const LayoutMenuItem = ({
label,
text,
to,
disabled
disabled,
badge
}: ListMenuItemProps) => (
<>
{to && !disabled ? (
@@ -65,6 +93,7 @@ const LayoutMenuItem = ({
{...(bgcolor && { bgcolor })}
label={label}
text={text}
{...(badge && { badge })}
/>
</ListItemButton>
</ListItem>
@@ -75,6 +104,7 @@ const LayoutMenuItem = ({
{...(bgcolor && { bgcolor })}
label={label}
text={text}
{...(badge && { badge })}
/>
</ListItem>
)}
@@ -1,4 +1,4 @@
import { memo, useCallback } from 'react';
import { memo } from 'react';
import type { Blocker } from 'react-router';
import {
@@ -15,13 +15,13 @@ import { useI18nContext } from 'i18n/i18n-react';
const BlockNavigation = ({ blocker }: { blocker: Blocker }) => {
const { LL } = useI18nContext();
const handleReset = useCallback(() => {
const handleReset = () => {
blocker.reset?.();
}, [blocker]);
};
const handleProceed = useCallback(() => {
const handleProceed = () => {
blocker.proceed?.();
}, [blocker]);
};
return (
<Dialog sx={dialogStyle} open={blocker.state === 'blocked'}>
@@ -1,17 +1,14 @@
import { memo, useContext } from 'react';
import type { FC } from 'react';
import { Navigate } from 'react-router';
import { Navigate, Outlet } from 'react-router';
import { AuthenticatedContext } from 'contexts/authentication';
import type { RequiredChildrenProps } from 'utils';
const RequireAdmin: FC<RequiredChildrenProps> = ({ children }) => {
const authenticatedContext = useContext(AuthenticatedContext);
return authenticatedContext.me.admin ? (
<>{children}</>
) : (
<Navigate replace to="/" />
);
// Layout-route guard: renders nested admin routes only for admins, otherwise
// redirects home. Must be used inside the authenticated route subtree so that
// AuthenticatedContext (and `me`) is available.
const RequireAdmin = () => {
const { me } = useContext(AuthenticatedContext);
return me.admin ? <Outlet /> : <Navigate replace to="/" />;
};
export default memo(RequireAdmin);
@@ -0,0 +1,35 @@
import { memo, useContext, useEffect, useRef } from 'react';
import { Navigate } from 'react-router';
import { toast } from 'components/toast';
import { AuthenticationContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react';
type RootRedirectKind = 'unauthorized' | 'fileUpdated';
// Shows a one-shot toast and bounces back to "/". Used by the /unauthorized and
// /fileUpdated routes. Resolves its own i18n message so it can be used directly
// as a static route element.
const RootRedirect = ({ kind }: { kind: RootRedirectKind }) => {
const { LL } = useI18nContext();
const { signOut } = useContext(AuthenticationContext);
const hasShownToast = useRef(false);
useEffect(() => {
// Guard against StrictMode double-invoke / re-renders.
if (hasShownToast.current) return;
hasShownToast.current = true;
if (kind === 'unauthorized') {
signOut(false);
toast.success(LL.PLEASE_SIGNIN());
} else {
toast.success(LL.UPLOAD_SUCCESSFUL());
}
// Run once on mount.
}, []);
return <Navigate to="/" replace />;
};
export default memo(RootRedirect);
@@ -1,4 +1,4 @@
import { memo, useCallback } from 'react';
import { memo } from 'react';
import type { FC } from 'react';
import { useNavigate } from 'react-router';
@@ -16,12 +16,9 @@ const RouterTabs: FC<RouterTabsProps> = ({ value, children }) => {
const theme = useTheme();
const smallDown = useMediaQuery(theme.breakpoints.down('sm'));
const handleTabChange = useCallback(
(_event: unknown, path: string) => {
void navigate(path);
},
[navigate]
);
const handleTabChange = (_event: unknown, path: string) => {
void navigate(path);
};
return (
<Tabs
@@ -2,3 +2,4 @@ export { default as RouterTabs } from './RouterTabs';
export { default as RequireAdmin } from './RequireAdmin';
export { default as RequireAuthenticated } from './RequireAuthenticated';
export { default as RequireUnauthenticated } from './RequireUnauthenticated';
export { default as RootRedirect } from './RootRedirect';
+140
View File
@@ -0,0 +1,140 @@
import { memo, useEffect, useRef, useState, useSyncExternalStore } from 'react';
import CloseIcon from '@mui/icons-material/Close';
import Alert from '@mui/material/Alert';
import Grow from '@mui/material/Grow';
import IconButton from '@mui/material/IconButton';
import LinearProgress from '@mui/material/LinearProgress';
import Stack from '@mui/material/Stack';
import {
type ToastItem,
type ToastSeverity,
getSnapshot,
removeToast,
subscribe
} from './toastStore';
const AUTO_CLOSE_MS = 3000;
const TICK_MS = 50;
// Semantic accent colors users already expect:
// success → green, error → red, warning → amber, info → blue.
const ACCENT: Record<ToastSeverity, string> = {
success: '#16a34a',
error: '#dc2626',
warning: '#f59e0b',
info: '#2563eb'
};
// Single toast row: owns its auto-dismiss timer + countdown progress bar, pauses
// while the window is unfocused (matching react-toastify's pauseOnFocusLoss).
const ToastRow = memo(({ item }: { item: ToastItem }) => {
const [open, setOpen] = useState(true);
const [remaining, setRemaining] = useState(AUTO_CLOSE_MS);
const remainingRef = useRef(AUTO_CLOSE_MS);
useEffect(() => {
let paused = document.hidden;
const onVisibility = () => {
paused = document.hidden;
};
document.addEventListener('visibilitychange', onVisibility);
const timer = setInterval(() => {
if (paused) return;
remainingRef.current = Math.max(0, remainingRef.current - TICK_MS);
setRemaining(remainingRef.current);
if (remainingRef.current === 0) setOpen(false);
}, TICK_MS);
return () => {
clearInterval(timer);
document.removeEventListener('visibilitychange', onVisibility);
};
}, []);
const accent = ACCENT[item.severity];
return (
<Grow in={open} onExited={() => removeToast(item.id)}>
<Alert
severity={item.severity}
variant="standard"
onClick={() => setOpen(false)}
action={
<IconButton
size="small"
aria-label="close"
onClick={(e) => {
e.stopPropagation();
setOpen(false);
}}
sx={{ color: '#9ca3af', '&:hover': { color: '#374151' } }}
>
<CloseIcon fontSize="small" />
</IconButton>
}
sx={{
position: 'relative',
width: 'fit-content',
minWidth: 300,
maxWidth: 360,
cursor: 'pointer',
overflow: 'hidden',
borderRadius: '8px',
bgcolor: '#f3f4f6',
color: '#1f2937',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.18)',
borderLeft: `4px solid ${accent}`,
alignItems: 'center',
'& .MuiAlert-icon': { color: accent, alignItems: 'center' },
// '& .MuiAlert-message': {
// py: '8px',
// color: '#1f2937'
// },
'& .MuiAlert-action': { alignItems: 'center', pt: 0, mr: '-4px' }
}}
>
{item.message}
<LinearProgress
variant="determinate"
value={(remaining / AUTO_CLOSE_MS) * 100}
sx={{
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
height: 3,
backgroundColor: 'transparent',
'& .MuiLinearProgress-bar': { backgroundColor: accent, opacity: 0.55 }
}}
/>
</Alert>
</Grow>
);
});
const Toaster = memo(() => {
const toasts = useSyncExternalStore(subscribe, getSnapshot);
return (
<Stack
spacing={1}
sx={{
position: 'fixed',
bottom: 16,
left: 16,
zIndex: (theme) => theme.zIndex.snackbar,
pointerEvents: 'none',
'& > *': { pointerEvents: 'auto' }
}}
>
{toasts.map((item) => (
<ToastRow key={item.id} item={item} />
))}
</Stack>
);
});
export default Toaster;
+3
View File
@@ -0,0 +1,3 @@
export { default as Toaster } from './Toaster';
export { toast } from './toastStore';
export type { ToastSeverity } from './toastStore';
@@ -0,0 +1,47 @@
export type ToastSeverity = 'success' | 'error' | 'info' | 'warning';
export interface ToastItem {
id: number;
severity: ToastSeverity;
message: string;
}
let toasts: ToastItem[] = [];
let nextId = 1;
const listeners = new Set<() => void>();
const emit = () => {
for (const listener of listeners) listener();
};
export const subscribe = (listener: () => void): (() => void) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
};
export const getSnapshot = (): ToastItem[] => toasts;
const add = (severity: ToastSeverity, message: string): number => {
const id = nextId++;
toasts = [...toasts, { id, severity, message }];
emit();
return id;
};
export const removeToast = (id: number): void => {
const next = toasts.filter((t) => t.id !== id);
if (next.length !== toasts.length) {
toasts = next;
emit();
}
};
// Imperative API mirroring the subset of react-toastify used across the app.
export const toast = {
success: (message: string) => add('success', message),
error: (message: string) => add('error', message),
info: (message: string) => add('info', message),
warning: (message: string) => add('warning', message)
};
@@ -7,7 +7,6 @@ import {
useState
} from 'react';
import { Link } from 'react-router';
import { toast } from 'react-toastify';
import CancelIcon from '@mui/icons-material/Cancel';
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
@@ -28,6 +27,7 @@ import { callAction } from 'api/app';
import { dialogStyle } from '@/CustomTheme';
import { useRequest } from 'alova/client';
import { toast } from 'components/toast';
import { useI18nContext } from 'i18n/i18n-react';
const DocumentUploader = styled(Box)<{ active?: boolean }>(({ theme, active }) => ({
@@ -91,7 +91,9 @@ const DragNdrop = ({ text, onFileSelected }: DragNdropProps) => {
).upgradeImportantMessageType;
setUpgradeImportantMessageType(upgradeImportantMessageType_n);
if (upgradeImportantMessageType_n === 0) {
onFileSelected(file);
if (file) {
onFileSelected(file);
}
}
})
.onError((error) => {
@@ -1,5 +1,4 @@
import { useEffect, useState } from 'react';
import { toast } from 'react-toastify';
import CancelIcon from '@mui/icons-material/Cancel';
import { Box, Button, Typography } from '@mui/material';
@@ -7,6 +6,7 @@ import { Box, Button, Typography } from '@mui/material';
import * as SystemApi from 'api/system';
import { useRequest } from 'alova/client';
import { toast } from 'components/toast';
import { useI18nContext } from 'i18n/i18n-react';
import DragNdrop from './DragNdrop';
@@ -14,9 +14,10 @@ import { LinearProgressWithLabel } from './LinearProgressWithLabel';
interface SingleUploadProps {
doRestart: () => void;
text: string;
}
const SingleUpload = ({ doRestart }: SingleUploadProps) => {
const SingleUpload = ({ doRestart, text }: SingleUploadProps) => {
const [md5, setMd5] = useState<string>();
const [file, setFile] = useState<File>();
const { LL } = useI18nContext();
@@ -29,11 +30,22 @@ const SingleUpload = ({ doRestart }: SingleUploadProps) => {
} = useRequest(SystemApi.uploadFile, {
immediate: false
}).onSuccess(({ data }) => {
if (data && typeof data === 'object' && 'md5' in data) {
setMd5((data as { md5: string }).md5);
toast.success(LL.UPLOAD() + ' MD5 ' + LL.SUCCESSFUL());
let payload = data;
if (typeof payload === 'string' && payload.length > 0) {
try {
payload = JSON.parse(payload);
} catch {
payload = data;
}
}
if (payload && typeof payload === 'object' && 'md5' in payload) {
setMd5((payload as { md5: string }).md5);
toast.success(LL.UPLOAD_MD5_RECEIVED());
setFile(undefined);
} else {
if (payload && typeof payload === 'object' && 'md5_ok' in payload) {
toast.success(LL.UPLOAD_MD5_MATCHED());
}
doRestart();
}
});
@@ -80,12 +92,17 @@ const SingleUpload = ({ doRestart }: SingleUploadProps) => {
</Button>
</>
) : (
<DragNdrop text={LL.UPLOAD_DROP_TEXT()} onFileSelected={setFile} />
<DragNdrop
text={(md5 ? LL.UPLOAD_MD5_RECEIVED() : text) + '...'}
onFileSelected={setFile}
/>
)}
{md5 && (
<Box sx={{ mt: 2 }}>
<Typography variant="body2">{'MD5: ' + md5}</Typography>
<Typography variant="body2" color="success">
{'MD5: ' + md5}
</Typography>
</Box>
)}
</>
@@ -1,36 +1,58 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import type { FC } from 'react';
import { redirect } from 'react-router';
import { toast } from 'react-toastify';
import { useNavigate } from 'react-router';
import { callAction } from 'api/app';
import { ACCESS_TOKEN } from 'api/endpoints';
import * as AuthenticationApi from 'components/routing/authentication';
import { useRequest } from 'alova/client';
import { LoadingSpinner } from 'components';
import { verifyAuthorization } from 'components/routing/authentication';
import { toast } from 'components/toast';
import { useI18nContext } from 'i18n/i18n-react';
import type { Me } from 'types';
import type { Me, VersionsResponse } from 'types';
import type { RequiredChildrenProps } from 'utils';
import { AuthenticationContext } from './context';
const Authentication: FC<RequiredChildrenProps> = ({ children }) => {
const { LL } = useI18nContext();
const navigate = useNavigate();
const [initialized, setInitialized] = useState<boolean>(false);
const [me, setMe] = useState<Me>();
const [versions, setVersions] = useState<VersionsResponse>();
const [systemName, setSystemName] = useState<string>();
const { send: sendVerifyAuthorization } = useRequest(verifyAuthorization(), {
immediate: false
});
const { send: sendGetVersions } = useRequest(
() => callAction({ action: 'getVersions' }),
{ immediate: false }
)
.onSuccess((event) => {
const response = event.data as VersionsResponse;
setVersions(response);
setSystemName(response.system_name);
})
.onError(() => {
setVersions(undefined);
});
const refreshVersions = useCallback(async () => {
await sendGetVersions().catch(() => undefined);
}, []);
const signIn = (accessToken: string) => {
try {
AuthenticationApi.getStorage().setItem(ACCESS_TOKEN, accessToken);
const decodedMe = AuthenticationApi.decodeMeJWT(accessToken);
setMe(decodedMe);
toast.success(LL.LOGGED_IN({ name: decodedMe.username }));
void refreshVersions();
} catch {
setMe(undefined);
throw new Error('Failed to parse JWT');
@@ -40,8 +62,10 @@ const Authentication: FC<RequiredChildrenProps> = ({ children }) => {
const signOut = (doRedirect: boolean) => {
AuthenticationApi.clearAccessToken();
setMe(undefined);
setVersions(undefined);
setSystemName(undefined);
if (doRedirect) {
redirect('/');
void navigate('/', { replace: true });
}
};
@@ -49,8 +73,9 @@ const Authentication: FC<RequiredChildrenProps> = ({ children }) => {
const accessToken = AuthenticationApi.getStorage().getItem(ACCESS_TOKEN);
if (accessToken) {
await sendVerifyAuthorization()
.then(() => {
.then(async () => {
setMe(AuthenticationApi.decodeMeJWT(accessToken));
await refreshVersions();
setInitialized(true);
})
.catch(() => {
@@ -67,20 +92,20 @@ const Authentication: FC<RequiredChildrenProps> = ({ children }) => {
void refresh();
}, [refresh]);
// cache object to prevent re-renders
const obj = useMemo(
() => ({
signIn,
signOut,
refresh,
...(me && { me })
}),
[signIn, signOut, me, refresh]
);
if (initialized) {
return (
<AuthenticationContext.Provider value={obj}>
<AuthenticationContext.Provider
value={{
signIn,
signOut,
refresh,
refreshVersions,
setSystemName,
...(me && { me }),
...(versions && { versions }),
...(systemName !== undefined && { systemName })
}}
>
{children}
</AuthenticationContext.Provider>
);
@@ -1,12 +1,16 @@
import { createContext } from 'react';
import type { Me } from 'types';
import type { Me, VersionsResponse } from 'types';
export interface AuthenticationContextValue {
refresh: () => Promise<void>;
signIn: (accessToken: string) => void;
signOut: (redirect: boolean) => void;
me?: Me;
versions?: VersionsResponse;
refreshVersions: () => Promise<void>;
systemName?: string;
setSystemName: (systemName: string) => void;
}
const AuthenticationContextDefaultValue = {} as AuthenticationContextValue;

Before

Width:  |  Height:  |  Size: 207 B

After

Width:  |  Height:  |  Size: 207 B

+18 -32
View File
@@ -3,7 +3,6 @@ import type { Translation } from '../i18n-types';
const cs: Translation = {
LANGUAGE: 'Jazyk',
RETRY: 'Zkusit znovu',
LOADING: 'Načítání',
IS_REQUIRED: '{0} je vyžadováno',
SIGN_IN: 'Přihlásit se',
SIGN_OUT: 'Odhlásit se',
@@ -31,19 +30,17 @@ const cs: Translation = {
ID_OF: '{0} ID',
DEVICE: 'Zařízení',
PRODUCT: 'Produkt',
VERSION: 'Verze',
BRAND: 'Značka',
VERSION: 'Verze',
ENTITY_NAME: 'Název entity',
VALUE: '{{value|Value}}',
DEVICES: 'Zařízení',
SENSORS: 'Senzory',
RUN_COMMAND: 'Zavolat příkaz',
CHANGE_VALUE: 'Změnit hodnotu',
CANCEL: 'Zrušit',
REMOVE_ALL: 'Odebrat vše',
APPLY_CHANGES: 'Použít změny ({0})',
UPDATE: 'Aktualizovat',
EXECUTE: 'Provést',
REMOVE: 'Odebrat',
PROBLEM_UPDATING: 'Problém s aktualizací',
PROBLEM_LOADING: 'Problém s načítáním',
@@ -52,8 +49,6 @@ const cs: Translation = {
SETTINGS: 'Nastavení',
UPDATED_OF: '{0} Aktualizováno',
UPDATE_OF: '{0} Aktualizace',
REMOVED_OF: '{0} Odebráno',
DELETION_OF: '{0} Smazání',
OFFSET: 'Offset',
FACTOR: 'Faktor',
FREQ: 'Frekvence',
@@ -66,17 +61,14 @@ const cs: Translation = {
TEMP_SENSORS: 'Teplotní senzory',
WRITE_CMD_SENT: 'Zapisovací příkaz odeslán',
EMS_BUS_WARNING: 'EMS sběrnice je odpojena. Pokud toto varování přetrvává i po několika sekundách, zkontrolujte nastavení a profil desky',
EMS_BUS_SCANNING: 'Prohledávání zařízení na EMS sběrnici...',
CONNECTED: 'Připojeno',
TX_ISSUES: 'Problémy s Tx - zkontrolujte režim Tx',
DISCONNECTED: 'Odpojeno',
EMS_SCAN: 'Opravdu chcete zahájit úplné skenování zařízení na EMS sběrnici?',
DATA_TRAFFIC: 'Datový provoz',
EMS_DEVICE: 'EMS zařízení',
SUCCESS: 'ÚSPĚCH',
FAIL: 'SELHÁNÍ',
QUALITY: 'KVALITA',
SCAN: 'Skenovat',
STATUS_NAMES: [
'Přijaté EMS telegramy (Rx)',
'Čtení EMS (Tx)',
@@ -102,8 +94,8 @@ const cs: Translation = {
PHY_TYPE: 'Typ Eth PHY',
DISABLED: 'zakázáno',
TX_MODE: 'EMS Tx režim',
EMS_BUS: '{{Bus|EMS Bus}}',
HARDWARE: 'Hardware',
EMS_BUS: '{{BUS|EMS BUS}}',
GENERAL_OPTIONS: 'Obecné možnosti',
LANGUAGE_ENTITIES: 'Jazyk (pro entity zařízení)',
HIDE_LED: 'Skrýt LED',
@@ -127,7 +119,6 @@ const cs: Translation = {
ENUM_FORMAT: 'Formát enum v API/MQTT',
INDEX: 'Index',
ENABLE_PARASITE: 'Povolit 1-Wire parazitní napájení',
LOGGING: 'Protokolování',
LOG_HEX: 'Protokolovat EMS telegramy v hexadecimálním formátu',
ENABLE_SYSLOG: 'Povolit Syslog',
LOG_LEVEL: 'Úroveň protokolování',
@@ -172,7 +163,6 @@ const cs: Translation = {
CLOSE: 'Zavřít',
USE: 'Použít',
FACTORY_RESET: 'Obnovení továrního nastavení',
SYSTEM_FACTORY_TEXT: 'Zařízení bylo obnoveno do továrního nastavení a nyní se restartuje',
SYSTEM_FACTORY_TEXT_DIALOG: 'Opravdu chcete resetovat zařízení EMS-ESP do továrního nastavení?',
AVAILABLE_VERSION: 'Nejnovější dostupné verze',
STABLE: 'Stabilní',
@@ -188,8 +178,6 @@ const cs: Translation = {
DOWNLOAD_SETTINGS_TEXT: 'Vytvořte zálohu svého nastavení a konfigurace',
DOWNLOAD_SETTINGS_TEXT2: 'Exportovat všechny hodnoty',
DOWNLOAD_SYSTEM_BACKUP: 'Systémová záloha',
UPLOAD_TEXT: 'Nahrajte nový soubor firmwaru (.bin) nebo záložní soubor (.json)',
UPLOAD_DROP_TEXT: 'Přetáhněte soubor sem nebo klikněte pro výběr',
ERROR: 'Neočekávaná chyba, zkuste to prosím znovu',
TIME_SET: 'Čas nastaven',
MANAGE_USERS: 'Spravovat uživatele',
@@ -247,8 +235,7 @@ const cs: Translation = {
TIME_ZONE: 'Časová zóna',
ACCESS_POINT: 'Přístupový bod',
AP_PROVIDE: 'Povolit přístupový bod',
AP_PROVIDE_TEXT_1: 'Vždy',
AP_PROVIDE_TEXT_2: 'Když je WiFi odpojena',
AP_PROVIDE_TEXT_2: 'Když je síťové připojení stratené',
AP_PROVIDE_TEXT_3: 'Nikdy',
AP_PREFERRED_CHANNEL: 'Preferovaný kanál',
AP_HIDE_SSID: 'Skrýt SSID',
@@ -258,11 +245,8 @@ const cs: Translation = {
NETWORK_SCAN: 'Skenovat WiFi sítě',
IDLE: 'Nečinný',
LOST: 'Ztraceno',
SCANNING: 'Skenování',
SCAN_AGAIN: 'Skenovat znovu',
NETWORK_SCANNER: 'Síťový skener',
NETWORK_NO_WIFI: 'Nenalezeny žádné WiFi sítě',
NETWORK_BLANK_SSID: 'ponechte prázdné pro deaktivaci WiFi a povolení ETH',
NETWORK_BLANK_BSSID: 'ponechte prázdné pokud použijete jen SSID',
TX_POWER: 'Vysílací výkon',
HOSTNAME: 'Název hostitele',
@@ -288,18 +272,14 @@ const cs: Translation = {
STAY: 'Zůstat',
LEAVE: 'Odejít',
SCHEDULER: 'Plánovač',
SCHEDULER_HELP_1: 'Automatizujte příkazy přidáním naplánovaných událostí níže. Nastavte jedinečný název pro povolení/zakázání aktivace přes API/MQTT',
SCHEDULER_HELP_2: 'Použijte 00:00 pro spuštění při startu',
SCHEDULER_HELP_1: 'Automatizujte příkazy přidáním naplánovaných událostí níže',
SCHEDULER_HELP_2: 'při startu',
SCHEDULE: 'Harmonogram',
TIME: 'Čas',
TIMER: 'Časovač',
ONCHANGE: 'Při změně',
CONDITION: 'Podmínka',
IMMEDIATE: 'Ihned',
SCHEDULE_UPDATED: 'Harmonogram aktualizován',
SCHEDULE_TIMER_1: 'při startu',
SCHEDULE_TIMER_2: 'každou minutu',
SCHEDULE_TIMER_3: 'každou hodinu',
CUSTOM_ENTITIES: 'Vlastní entity',
ENTITIES_HELP_1: 'Definujte vlastní EMS entity nebo dynamické uživatelské proměnné',
ENTITIES_UPDATED: 'Entity aktualizovány',
@@ -315,9 +295,7 @@ const cs: Translation = {
ACTIVELOW: 'Aktivní nízká',
UNCHANGED: 'Beze změny',
ALWAYS: 'Vždy',
ACTIVITY: 'Aktivita',
CONFIGURE: '{0} Konfigurovat',
SYSTEM_MEMORY: 'Paměť systému',
APPLICATION_SETTINGS_1: 'Upravit nastavení aplikace EMS-ESP',
SECURITY_1: 'Přidat nebo odebrat uživatele',
DOWNLOAD_UPLOAD_1: 'Stáhnout a nahrát nastavení a firmware',
@@ -329,7 +307,6 @@ const cs: Translation = {
RENAME: 'Přejmenovat',
ENABLE_MODBUS: 'Povolit Modbus',
VIEW_LOG: 'Zobrazit záznam pro diagnostiku problémů',
UPLOAD_DRAG: 'přetáhněte soubor sem nebo klikněte pro výběr',
SERVICES: 'Služby',
ALLVALUES: 'Všechny hodnoty',
SPECIAL_FUNCTIONS: 'Speciální funkce',
@@ -343,6 +320,7 @@ const cs: Translation = {
AUTO_SCROLL: 'Automatické rolování',
DASHBOARD: 'Dashboard',
DEVELOPER_MODE: 'Režim vývojáře',
DISABLE_RESET: 'Zakázat obnovení továrního nastavení',
BYTES: 'Bajty',
BITMASK: 'Bit Mask',
DUPLICATE: 'Duplikát',
@@ -354,18 +332,26 @@ const cs: Translation = {
THIS_VERSION: 'Tato verze',
PLATFORM: 'Platforma',
RELEASE_TYPE: 'Typ sestavení',
INTERNET_CONNECTION_REQUIRED: 'Pro automatickou kontrolu a instalaci aktualizací je třeba internetové připojení',
SWITCH_RELEASE_TYPE: 'Přepnout na {0} verzi',
FIRMWARE_VERSION_INFO: 'Informace o verzi firmwaru',
NO_DATA: 'žádné údaje',
USER_PROFILE: 'Uživatelský profil',
STORED_VERSIONS: 'Uložené verze',
ONLINE_HELP: 'online nápověda',
UPGRADE_IMPORTANT_MESSAGES: 'Aktualizovat důležité zprávy',
UPGRADE_IMPORTANT_MESSAGES_1: 'Tato aktualizace vyžaduje obnovení továrního nastavení. Ujistěte se, že nejprve stáhnete systémovou zálohu před pokračováním a poté nahrajte tento soubor po instalaci nové verze.',
UPGRADE_IMPORTANT_MESSAGES_2: 'Aktualizujete se na novou hlavní verzi. Ujistěte se, že jste přečetli ChangeLog pro jakékoliv závažné změny.',
WARNING_SYSTEM_BACKUP: 'Toto vytvoří zálohu vašich celých systémových konfigurací a nastavení. Všechna hesla budou v zálohovém souboru čitelná. Buďte opatrní při sdílení! Opravdu chcete pokračovat?'
WARNING_SYSTEM_BACKUP: 'Toto vytvoří zálohu vašich celých systémových konfigurací a nastavení. Všechna hesla budou v zálohovém souboru čitelná. Buďte opatrní při sdílení! Opravdu chcete pokračovat?',
TEST_EMAIL_SUCCESSFUL: 'Test email byl úspěšně odeslán',
SYSTEM_NAME: 'Název systému',
COMMANDS: 'Příkazy',
COMMANDS_HELP_1: 'Definujte vlastní příkazy pro magistrali EMS',
COMMAND_EXECUTED: 'Příkaz úspěšně spuštěn',
RUN: 'Spustit příkaz',
INTERNET_CONNECTION_CHECK: 'Kontrola online pro dostupné verze',
UPLOAD_BACKUP_TEXT: 'Klikněte, abyste vybrali nebo přetáhli a pustili soubor zálohy (.json) sem',
UPLOAD_FIRMWARE_TEXT: 'Klikněte, abyste vybrali nebo přetáhli a pustili soubor firmware .bin/.md5 sem',
UPLOAD_MD5_RECEIVED: 'Digest přijat, nyní nahrajte firmware .bin',
UPLOAD_MD5_MATCHED: 'MD5 souhlasí, firmware se instaluje...'
};
export default cs;

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