Compare commits

...
526 Commits
Author SHA1 Message Date
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
279 changed files with 17817 additions and 15485 deletions
+20
View File
@@ -77,3 +77,23 @@ jobs:
files: | files: |
CHANGELOG_LATEST.md CHANGELOG_LATEST.md
./build/firmware/*.* ./build/firmware/*.*
- name: Update version in Cloudflare KV store
if: github.repository == 'emsesp/EMS-ESP32'
env:
CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}
CF_NAMESPACE_ID: ${{ secrets.CF_NAMESPACE_ID }}
CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
VERSION: ${{ steps.build_info.outputs.VERSION }}
run: |
JSON_DATA=$(jq -n \
--arg version "$VERSION" \
--arg date "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
'{version: $version, date: $date}')
echo "JSON_DATA: $JSON_DATA"
curl -sS --fail-with-body \
-X PUT "https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/storage/kv/namespaces/${CF_NAMESPACE_ID}/values/dev" \
-H "Authorization: Bearer ${CF_API_TOKEN}" \
-H "Content-Type: application/json" \
-d "$JSON_DATA"
echo
+28 -1
View File
@@ -27,10 +27,17 @@ jobs:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v6 uses: actions/checkout@v6
- name: Enable Corepack - name: Enable Corepack
run: corepack enable pnpm run: corepack enable pnpm
- name: Get the EMS-ESP version
id: build_info
run: |
version=`grep -E '^#define EMSESP_APP_VERSION' ./src/emsesp_version.h | awk -F'"' '{print $2}'`
echo "VERSION=$version" >> $GITHUB_OUTPUT
- name: Install PlatformIO - name: Install PlatformIO
run: | run: |
python -m pip install --upgrade pip python -m pip install --upgrade pip
@@ -61,3 +68,23 @@ jobs:
files: | files: |
CHANGELOG.md CHANGELOG.md
./build/firmware/*.* ./build/firmware/*.*
- name: Update version in Cloudflare KV store
if: github.repository == 'emsesp/EMS-ESP32'
env:
CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}
CF_NAMESPACE_ID: ${{ secrets.CF_NAMESPACE_ID }}
CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
VERSION: ${{ steps.build_info.outputs.VERSION }}
run: |
JSON_DATA=$(jq -n \
--arg version "$VERSION" \
--arg date "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
'{version: $version, date: $date}')
echo "JSON_DATA: $JSON_DATA"
curl -sS --fail-with-body \
-X PUT "https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/storage/kv/namespaces/${CF_NAMESPACE_ID}/values/stable" \
-H "Authorization: Bearer ${CF_API_TOKEN}" \
-H "Content-Type: application/json" \
-d "$JSON_DATA"
echo
+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/c_cpp_properties.json
.vscode/extensions.json .vscode/extensions.json
.vscode/launch.json .vscode/launch.json
.vscode/settings.json
# c++ compiling # c++ compiling
.clang_complete .clang_complete
@@ -63,7 +64,7 @@ words-found-verbose.txt
# sonarlint # sonarlint
compile_commands.json compile_commands.json
# pioarduino + hybrid # other files
managed_components managed_components
dependencies.lock dependencies.lock
CMakeLists.txt CMakeLists.txt
@@ -75,3 +76,6 @@ pnpm-lock.yaml
.cache/ .cache/
interface/.tsbuildinfo interface/.tsbuildinfo
test/test_api/package-lock.json 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/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [3.8.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 ## [3.8.2] 12 May 2026
## Added ## 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 - some names of mqtt-tags like in v2.2.1
- new ESP32 partition side to allow for smoother OTA and fallback - new ESP32 partition side to allow for smoother OTA and fallback
- Network Gateway IP is optional (#682)emsesp/EMS-ESP - 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>
+16 -20
View File
@@ -2,32 +2,28 @@
For more details go to [emsesp.org](https://emsesp.org/). 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 ## 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
## Fixed ## Fixed
- signed value for solarInfuence [#3077](https://github.com/emsesp/EMS-ESP32/issues/3077) - shunting yard show json
- 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 ## Changed
- call compute value direct, enlarge TCP stack [#3127](https://github.com/emsesp/EMS-ESP32/discussions/3127) - various memory optimizations [#3083](https://github.com/emsesp/EMS-ESP32/issues/3083)
- Dewtemperature for Easycontrol calculated by ems-esp [#3135](https://github.com/emsesp/EMS-ESP32/issues/3135) - Scheduler name is now mandatory
- add option for sync thermostat to ntp, allow different time zones [#3148](https://github.com/emsesp/EMS-ESP32/discussions/3148), **defaults to sync**. - network fallback to AP only after start [#3090](https://github.com/emsesp/EMS-ESP32/issues/3090)
- block too many GET requests [mentioned in #3104](https://github.com/emsesp/EMS-ESP32/issues/3104) - replaced Web async-validator with custom validator and toast with native snackbar to reduce bundle size
- set 10 sec timeout for http requests [#3104](https://github.com/emsesp/EMS-ESP32/issues/3104) - Gateway and Connect devices are shown in the Devices page, but disabled [3126](https://github.com/emsesp/EMS-ESP32/discussions/3126)
- language code "cs" [#3161](https://github.com/emsesp/EMS-ESP32/issues/3161) - 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
+3 -4
View File
@@ -47,7 +47,7 @@ MAKEFLAGS += -j$(JOBS) -l$(shell echo $$(($(JOBS) * 2)))
#---------------------------------------------------------------------- #----------------------------------------------------------------------
TARGET := emsesp TARGET := emsesp
BUILD := build BUILD := build
SOURCES := src/core src/devices src/web src/test lib_standalone lib/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 INCLUDES := src/core src/devices src/web src/test lib_standalone lib/* lib/espMqttClient/src lib/espMqttClient/src/Transport lib/ArduinoJson/src lib/uuid-common/src lib/uuid-console/src lib/uuid-log/src lib/uuid-telnet/src lib/uuid-syslog/src
LIBRARIES := LIBRARIES :=
@@ -65,7 +65,6 @@ CXX_STANDARD := -std=gnu++20
#---------------------------------------------------------------------- #----------------------------------------------------------------------
DEFINES += -DARDUINOJSON_ENABLE -DARDUINOJSON_ENABLE_ARDUINO_STRING -DARDUINOJSON_USE_DOUBLE=0 DEFINES += -DARDUINOJSON_ENABLE -DARDUINOJSON_ENABLE_ARDUINO_STRING -DARDUINOJSON_USE_DOUBLE=0
DEFINES += -DEMSESP_STANDALONE -DEMSESP_TEST -DEMSESP_DEBUG -DEMC_RX_BUFFER_SIZE=1500 DEFINES += -DEMSESP_STANDALONE -DEMSESP_TEST -DEMSESP_DEBUG -DEMC_RX_BUFFER_SIZE=1500
DEFINES += -DNO_TLS_SUPPORT
DEFINES += $(ARGS) DEFINES += $(ARGS)
DEFAULTS = -DEMSESP_DEFAULT_LOCALE=\"en\" -DEMSESP_DEFAULT_BOARD_PROFILE=\"S32S3\" DEFAULTS = -DEMSESP_DEFAULT_LOCALE=\"en\" -DEMSESP_DEFAULT_BOARD_PROFILE=\"S32S3\"
@@ -113,8 +112,8 @@ CXX := /usr/bin/g++
CPPFLAGS += $(DEFINES) $(DEFAULTS) $(INCLUDE) CPPFLAGS += $(DEFINES) $(DEFAULTS) $(INCLUDE)
CPPFLAGS += -ggdb -g3 -MMD CPPFLAGS += -ggdb -g3 -MMD
CPPFLAGS += -flto=auto CPPFLAGS += -flto=auto
CPPFLAGS += -Wall -Wextra -Werror -Wswitch-enum CPPFLAGS += -Wall -Wextra -Werror
CPPFLAGS += -Wno-unused-parameter -Wno-missing-braces -Wno-vla-cxx-extension CPPFLAGS += -Wno-unused-parameter -Wno-missing-braces -Wno-vla-cxx-extension -Wno-switch-enum -Wno-unused-lambda-capture
CPPFLAGS += -ffunction-sections -fdata-sections -fno-exceptions -fno-rtti -fno-threadsafe-statics CPPFLAGS += -ffunction-sections -fdata-sections -fno-exceptions -fno-rtti -fno-threadsafe-statics
CPPFLAGS += -Os -DNDEBUG CPPFLAGS += -Os -DNDEBUG
-1
View File
@@ -5,7 +5,6 @@
}, },
"core": "esp32", "core": "esp32",
"extra_flags": [ "extra_flags": [
"-DTASMOTA_SDK",
"-DARDUINO_LOLIN_C3_MINI", "-DARDUINO_LOLIN_C3_MINI",
"-DARDUINO_USB_MODE=1", "-DARDUINO_USB_MODE=1",
"-DARDUINO_USB_CDC_ON_BOOT=1" "-DARDUINO_USB_CDC_ON_BOOT=1"
-1
View File
@@ -6,7 +6,6 @@
"core": "esp32", "core": "esp32",
"extra_flags": [ "extra_flags": [
"-DBOARD_HAS_PSRAM", "-DBOARD_HAS_PSRAM",
"-DTASMOTA_SDK",
"-DARDUINO_USB_CDC_ON_BOOT=1", "-DARDUINO_USB_CDC_ON_BOOT=1",
"-DARDUINO_USB_MODE=0" "-DARDUINO_USB_MODE=0"
], ],
+1 -1
View File
@@ -21,7 +21,7 @@
"arduino", "arduino",
"espidf" "espidf"
], ],
"name": "Espressif ESP32-S3 32M Flash OPI PSRAM, 4608KB Code/OTA, 2MB FS", "name": "Tasmota ESP32-S3 32M Flash OPI PSRAM, 4608KB Code/OTA, 2MB FS",
"upload": { "upload": {
"flash_size": "32MB", "flash_size": "32MB",
"maximum_ram_size": 327680, "maximum_ram_size": 327680,
+1 -2
View File
@@ -1,7 +1,6 @@
{ {
"build": { "build": {
"core": "esp32", "core": "esp32",
"extra_flags": "-DTASMOTA_SDK",
"f_cpu": "240000000L", "f_cpu": "240000000L",
"f_flash": "40000000L", "f_flash": "40000000L",
"flash_mode": "dio", "flash_mode": "dio",
@@ -19,7 +18,7 @@
"arduino", "arduino",
"espidf" "espidf"
], ],
"name": "Espressif ESP32 16M Flash, 4608KB Code/OTA, 2MB FS", "name": "Tasmota ESP32 16M Flash, 4608KB Code/OTA, 2MB FS",
"upload": { "upload": {
"flash_size": "16MB", "flash_size": "16MB",
"maximum_ram_size": 327680, "maximum_ram_size": 327680,
+1 -1
View File
@@ -19,7 +19,7 @@
"arduino", "arduino",
"espidf" "espidf"
], ],
"name": "Espressif ESP32 16M Flash DIO PSRAM, 4608KB Code/OTA, 2MB FS", "name": "Tasmota ESP32 16M Flash DIO PSRAM, 4608KB Code/OTA, 2MB FS",
"upload": { "upload": {
"flash_size": "16MB", "flash_size": "16MB",
"maximum_ram_size": 327680, "maximum_ram_size": 327680,
-1
View File
@@ -1,7 +1,6 @@
{ {
"build": { "build": {
"core": "esp32", "core": "esp32",
"extra_flags": "-DTASMOTA_SDK",
"f_cpu": "240000000L", "f_cpu": "240000000L",
"f_flash": "40000000L", "f_flash": "40000000L",
"flash_mode": "dio", "flash_mode": "dio",
+159 -25
View File
@@ -1,6 +1,7 @@
{ {
"type": "systembackup", "type": "systembackup",
"version": "3.8.2", "version": "3.9.0",
"date": "2026-06-14T10:59:16",
"systembackup": [ "systembackup": [
{ {
"type": "settings", "type": "settings",
@@ -18,7 +19,7 @@
"tx_power": 0 "tx_power": 0
}, },
"AP": { "AP": {
"provision_mode": 1, "provision_mode": 2,
"ssid": "ems-esp", "ssid": "ems-esp",
"password": "ems-esp-neo", "password": "ems-esp-neo",
"channel": 1, "channel": 1,
@@ -31,13 +32,13 @@
"MQTT": { "MQTT": {
"enableTLS": false, "enableTLS": false,
"rootCA": "", "rootCA": "",
"enabled": false, "enabled": true,
"host": "", "host": "192.168.1.2",
"port": 1883, "port": 1883,
"base": "ems-esp", "base": "ems-esp",
"username": "", "username": "username",
"password": "", "password": "password",
"client_id": "esp32-b8ffc9ec", "client_id": "esp32",
"keep_alive": 60, "keep_alive": 60,
"clean_session": false, "clean_session": false,
"entity_format": 1, "entity_format": 1,
@@ -51,17 +52,17 @@
"publish_time_heartbeat": 60, "publish_time_heartbeat": 60,
"mqtt_qos": 0, "mqtt_qos": 0,
"mqtt_retain": false, "mqtt_retain": false,
"ha_enabled": false, "ha_enabled": true,
"nested_format": 1, "nested_format": 1,
"discovery_prefix": "homeassistant", "discovery_prefix": "homeassistant",
"discovery_type": 0, "discovery_type": 0,
"ha_number_mode": 0, "ha_number_mode": 1,
"publish_single": false, "publish_single": false,
"publish_single2cmd": false, "publish_single2cmd": false,
"send_response": false "send_response": false
}, },
"NTP": { "NTP": {
"enabled": false, "enabled": true,
"server": "time.google.com", "server": "time.google.com",
"tz_label": "Europe/Amsterdam", "tz_label": "Europe/Amsterdam",
"tz_format": "CET-1CEST,M3.5.0,M10.5.0/3" "tz_format": "CET-1CEST,M3.5.0,M10.5.0/3"
@@ -82,11 +83,12 @@
] ]
}, },
"Settings": { "Settings": {
"version": "3.8.2-dev.22", "version": "3.9.0",
"board_profile": "E32V2_2", "board_profile": "E32V2_2",
"platform": "ESP32", "platform": "ESP32",
"system_name": "prod",
"locale": "en", "locale": "en",
"tx_mode": 5, "tx_mode": 1,
"ems_bus_id": 73, "ems_bus_id": 73,
"syslog_enabled": false, "syslog_enabled": false,
"syslog_level": 3, "syslog_level": 3,
@@ -97,7 +99,7 @@
"boiler_heatingoff": false, "boiler_heatingoff": false,
"remote_timeout": 24, "remote_timeout": 24,
"remote_timeout_en": false, "remote_timeout_en": false,
"shower_timer": false, "shower_timer": true,
"shower_alert": false, "shower_alert": false,
"shower_alert_coldshot": 10, "shower_alert_coldshot": 10,
"shower_alert_trigger": 7, "shower_alert_trigger": 7,
@@ -107,7 +109,7 @@
"dallas_gpio": 14, "dallas_gpio": 14,
"dallas_parasite": false, "dallas_parasite": false,
"led_gpio": 32, "led_gpio": 32,
"hide_led": false, "hide_led": true,
"led_type": 1, "led_type": 1,
"low_clock": false, "low_clock": false,
"telnet_enabled": true, "telnet_enabled": true,
@@ -131,26 +133,74 @@
"modbus_port": 502, "modbus_port": 502,
"modbus_max_clients": 10, "modbus_max_clients": 10,
"modbus_timeout": 300, "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", "type": "schedule",
"Schedule": { "Schedule": {
"schedule": [] "schedule": [
{
"id": 1,
"flags": 128,
"active": false,
"time": "",
"cmd_name": "sendmail",
"name": "sendmailtest"
}
]
} }
}, },
{ {
"type": "customizations", "type": "customizations",
"Customizations": { "Customizations": {
"ts": [
{
"id": "28_1767_7B13_2502",
"name": "gateway_temperature",
"offset": 0,
"is_system": true
}
],
"as": [ "as": [
{ {
"gpio": 39, "gpio": 39,
@@ -186,7 +236,36 @@
{ {
"type": "entities", "type": "entities",
"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 +277,65 @@
{ {
"type": "nvs", "type": "nvs",
"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, "type": 1,
"key": "fresh_firmware", "key": "fresh_firmware",
"value": 0 "value": 0
},
{
"type": 4,
"key": "d_app0",
"value": 1781363944
},
{
"type": 66,
"key": "nrgww",
"value": 41852.79107
} }
] ]
} }
-14
View File
@@ -5966,7 +5966,6 @@ uint8
| hc1.roomtempdiff | room temp difference | uint8 (&gt;=0&lt;=0) | K | true | HC | 42 | 1 | 1 | | hc1.roomtempdiff | room temp difference | uint8 (&gt;=0&lt;=0) | K | true | HC | 42 | 1 | 1 |
uint8 uint8
| hc1.hpminflowtemp | HP min. flow temp. | uint8 (&gt;=0&lt;=0) | C | true | HC | 43 | 1 | 1 | | hc1.hpminflowtemp | HP min. flow temp. | uint8 (&gt;=0&lt;=0) | C | true | HC | 43 | 1 | 1 |
| hc1.control | control device | enum | | true | HC | 44 | 1 | 1 |
uint8 uint8
| hc1.heatondelay | heat-on delay | uint8 (&gt;=1&lt;=48) | hours | true | HC | 47 | 1 | 1 | | hc1.heatondelay | heat-on delay | uint8 (&gt;=1&lt;=48) | hours | true | HC | 47 | 1 | 1 |
uint8 uint8
@@ -6116,7 +6115,6 @@ uint8
| hc1.roomtempdiff | room temp difference | uint8 (&gt;=0&lt;=0) | K | true | HC | 42 | 1 | 1 | | hc1.roomtempdiff | room temp difference | uint8 (&gt;=0&lt;=0) | K | true | HC | 42 | 1 | 1 |
uint8 uint8
| hc1.hpminflowtemp | HP min. flow temp. | uint8 (&gt;=0&lt;=0) | C | true | HC | 43 | 1 | 1 | | hc1.hpminflowtemp | HP min. flow temp. | uint8 (&gt;=0&lt;=0) | C | true | HC | 43 | 1 | 1 |
| hc1.control | control device | enum | | true | HC | 44 | 1 | 1 |
| hc1.remotetemp | room temperature from remote | cmd | C | true | HC | 45 | 1 | 1/10 | | hc1.remotetemp | room temperature from remote | cmd | C | true | HC | 45 | 1 | 1/10 |
| hc1.remotehum | room humidity from remote | cmd | % | true | HC | 46 | 1 | 1 | | hc1.remotehum | room humidity from remote | cmd | % | true | HC | 46 | 1 | 1 |
uint8 uint8
@@ -6283,7 +6281,6 @@ uint8
| hc1.roomtempdiff | room temp difference | uint8 (&gt;=0&lt;=0) | K | true | HC | 42 | 1 | 1 | | hc1.roomtempdiff | room temp difference | uint8 (&gt;=0&lt;=0) | K | true | HC | 42 | 1 | 1 |
uint8 uint8
| hc1.hpminflowtemp | HP min. flow temp. | uint8 (&gt;=0&lt;=0) | C | true | HC | 43 | 1 | 1 | | hc1.hpminflowtemp | HP min. flow temp. | uint8 (&gt;=0&lt;=0) | C | true | HC | 43 | 1 | 1 |
| hc1.control | control device | enum | | true | HC | 44 | 1 | 1 |
| hc1.remotetemp | room temperature from remote | cmd | C | true | HC | 45 | 1 | 1/10 | | hc1.remotetemp | room temperature from remote | cmd | C | true | HC | 45 | 1 | 1/10 |
| hc1.remotehum | room humidity from remote | cmd | % | true | HC | 46 | 1 | 1 | | hc1.remotehum | room humidity from remote | cmd | % | true | HC | 46 | 1 | 1 |
uint8 uint8
@@ -6806,7 +6803,6 @@ uint8
| hc1.ecotemp | eco temperature | uint8 (&gt;=0&lt;=0) | C | true | HC | 5 | 1 | 1/2 | | hc1.ecotemp | eco temperature | uint8 (&gt;=0&lt;=0) | C | true | HC | 5 | 1 | 1/2 |
int8 int8
| hc1.nofrosttemp | nofrost temperature | int8 (&gt;=0&lt;=0) | C | true | HC | 18 | 1 | 1/2 | | hc1.nofrosttemp | nofrost temperature | int8 (&gt;=0&lt;=0) | C | true | HC | 18 | 1 | 1/2 |
| hc1.control | control device | enum | | true | HC | 44 | 1 | 1 |
| hc1.program | program | enum | | true | HC | 27 | 1 | 1 | | hc1.program | program | enum | | true | HC | 27 | 1 | 1 |
| hc1.remotetemp | room temperature from remote | cmd | C | true | HC | 45 | 1 | 1/10 | | hc1.remotetemp | room temperature from remote | cmd | C | true | HC | 45 | 1 | 1/10 |
| hc1.targetflowtemp | target flow temperature | uint8 | C | false | HC | 19 | 1 | 1 | | hc1.targetflowtemp | target flow temperature | uint8 | C | false | HC | 19 | 1 | 1 |
@@ -6867,7 +6863,6 @@ uint8
| hc1.ecotemp | eco temperature | uint8 (&gt;=0&lt;=0) | C | true | HC | 5 | 1 | 1/2 | | hc1.ecotemp | eco temperature | uint8 (&gt;=0&lt;=0) | C | true | HC | 5 | 1 | 1/2 |
int8 int8
| hc1.nofrosttemp | nofrost temperature | int8 (&gt;=0&lt;=0) | C | true | HC | 18 | 1 | 1/2 | | hc1.nofrosttemp | nofrost temperature | int8 (&gt;=0&lt;=0) | C | true | HC | 18 | 1 | 1/2 |
| hc1.control | control device | enum | | true | HC | 44 | 1 | 1 |
| hc1.program | program | enum | | true | HC | 27 | 1 | 1 | | hc1.program | program | enum | | true | HC | 27 | 1 | 1 |
| hc1.remotetemp | room temperature from remote | cmd | C | true | HC | 45 | 1 | 1/10 | | hc1.remotetemp | room temperature from remote | cmd | C | true | HC | 45 | 1 | 1/10 |
| hc1.targetflowtemp | target flow temperature | uint8 | C | false | HC | 19 | 1 | 1 | | hc1.targetflowtemp | target flow temperature | uint8 | C | false | HC | 19 | 1 | 1 |
@@ -6926,7 +6921,6 @@ uint8
| hc1.ecotemp | eco temperature | uint8 (&gt;=0&lt;=0) | C | true | HC | 5 | 1 | 1/2 | | hc1.ecotemp | eco temperature | uint8 (&gt;=0&lt;=0) | C | true | HC | 5 | 1 | 1/2 |
int8 int8
| hc1.nofrosttemp | nofrost temperature | int8 (&gt;=0&lt;=0) | C | true | HC | 18 | 1 | 1/2 | | hc1.nofrosttemp | nofrost temperature | int8 (&gt;=0&lt;=0) | C | true | HC | 18 | 1 | 1/2 |
| hc1.control | control device | enum | | true | HC | 44 | 1 | 1 |
| hc1.program | program | enum | | true | HC | 27 | 1 | 1 | | hc1.program | program | enum | | true | HC | 27 | 1 | 1 |
| hc1.remotetemp | room temperature from remote | cmd | C | true | HC | 45 | 1 | 1/10 | | hc1.remotetemp | room temperature from remote | cmd | C | true | HC | 45 | 1 | 1/10 |
| hc1.targetflowtemp | target flow temperature | uint8 | C | false | HC | 19 | 1 | 1 | | hc1.targetflowtemp | target flow temperature | uint8 | C | false | HC | 19 | 1 | 1 |
@@ -6985,7 +6979,6 @@ uint8
| hc1.ecotemp | eco temperature | uint8 (&gt;=0&lt;=0) | C | true | HC | 5 | 1 | 1/2 | | hc1.ecotemp | eco temperature | uint8 (&gt;=0&lt;=0) | C | true | HC | 5 | 1 | 1/2 |
int8 int8
| hc1.nofrosttemp | nofrost temperature | int8 (&gt;=0&lt;=0) | C | true | HC | 18 | 1 | 1/2 | | hc1.nofrosttemp | nofrost temperature | int8 (&gt;=0&lt;=0) | C | true | HC | 18 | 1 | 1/2 |
| hc1.control | control device | enum | | true | HC | 44 | 1 | 1 |
| hc1.program | program | enum | | true | HC | 27 | 1 | 1 | | hc1.program | program | enum | | true | HC | 27 | 1 | 1 |
| hc1.remotetemp | room temperature from remote | cmd | C | true | HC | 45 | 1 | 1/10 | | hc1.remotetemp | room temperature from remote | cmd | C | true | HC | 45 | 1 | 1/10 |
| hc1.targetflowtemp | target flow temperature | uint8 | C | false | HC | 19 | 1 | 1 | | hc1.targetflowtemp | target flow temperature | uint8 | C | false | HC | 19 | 1 | 1 |
@@ -7046,7 +7039,6 @@ uint8
| hc1.ecotemp | eco temperature | uint8 (&gt;=0&lt;=0) | C | true | HC | 5 | 1 | 1/2 | | hc1.ecotemp | eco temperature | uint8 (&gt;=0&lt;=0) | C | true | HC | 5 | 1 | 1/2 |
int8 int8
| hc1.nofrosttemp | nofrost temperature | int8 (&gt;=0&lt;=0) | C | true | HC | 18 | 1 | 1/2 | | hc1.nofrosttemp | nofrost temperature | int8 (&gt;=0&lt;=0) | C | true | HC | 18 | 1 | 1/2 |
| hc1.control | control device | enum | | true | HC | 44 | 1 | 1 |
| hc1.program | program | enum | | true | HC | 27 | 1 | 1 | | hc1.program | program | enum | | true | HC | 27 | 1 | 1 |
| hc1.remotetemp | room temperature from remote | cmd | C | true | HC | 45 | 1 | 1/10 | | hc1.remotetemp | room temperature from remote | cmd | C | true | HC | 45 | 1 | 1/10 |
| hc1.targetflowtemp | target flow temperature | uint8 | C | false | HC | 19 | 1 | 1 | | hc1.targetflowtemp | target flow temperature | uint8 | C | false | HC | 19 | 1 | 1 |
@@ -7107,7 +7099,6 @@ uint8
| hc1.ecotemp | eco temperature | uint8 (&gt;=0&lt;=0) | C | true | HC | 5 | 1 | 1/2 | | hc1.ecotemp | eco temperature | uint8 (&gt;=0&lt;=0) | C | true | HC | 5 | 1 | 1/2 |
int8 int8
| hc1.nofrosttemp | nofrost temperature | int8 (&gt;=0&lt;=0) | C | true | HC | 18 | 1 | 1/2 | | hc1.nofrosttemp | nofrost temperature | int8 (&gt;=0&lt;=0) | C | true | HC | 18 | 1 | 1/2 |
| hc1.control | control device | enum | | true | HC | 44 | 1 | 1 |
| hc1.program | program | enum | | true | HC | 27 | 1 | 1 | | hc1.program | program | enum | | true | HC | 27 | 1 | 1 |
| hc1.remotetemp | room temperature from remote | cmd | C | true | HC | 45 | 1 | 1/10 | | hc1.remotetemp | room temperature from remote | cmd | C | true | HC | 45 | 1 | 1/10 |
| hc1.targetflowtemp | target flow temperature | uint8 | C | false | HC | 19 | 1 | 1 | | hc1.targetflowtemp | target flow temperature | uint8 | C | false | HC | 19 | 1 | 1 |
@@ -7166,7 +7157,6 @@ uint8
| hc1.ecotemp | eco temperature | uint8 (&gt;=0&lt;=0) | C | true | HC | 5 | 1 | 1/2 | | hc1.ecotemp | eco temperature | uint8 (&gt;=0&lt;=0) | C | true | HC | 5 | 1 | 1/2 |
int8 int8
| hc1.nofrosttemp | nofrost temperature | int8 (&gt;=0&lt;=0) | C | true | HC | 18 | 1 | 1/2 | | hc1.nofrosttemp | nofrost temperature | int8 (&gt;=0&lt;=0) | C | true | HC | 18 | 1 | 1/2 |
| hc1.control | control device | enum | | true | HC | 44 | 1 | 1 |
| hc1.program | program | enum | | true | HC | 27 | 1 | 1 | | hc1.program | program | enum | | true | HC | 27 | 1 | 1 |
| hc1.remotetemp | room temperature from remote | cmd | C | true | HC | 45 | 1 | 1/10 | | hc1.remotetemp | room temperature from remote | cmd | C | true | HC | 45 | 1 | 1/10 |
| hc1.targetflowtemp | target flow temperature | uint8 | C | false | HC | 19 | 1 | 1 | | hc1.targetflowtemp | target flow temperature | uint8 | C | false | HC | 19 | 1 | 1 |
@@ -7227,7 +7217,6 @@ uint8
| hc1.ecotemp | eco temperature | uint8 (&gt;=0&lt;=0) | C | true | HC | 5 | 1 | 1/2 | | hc1.ecotemp | eco temperature | uint8 (&gt;=0&lt;=0) | C | true | HC | 5 | 1 | 1/2 |
int8 int8
| hc1.nofrosttemp | nofrost temperature | int8 (&gt;=0&lt;=0) | C | true | HC | 18 | 1 | 1/2 | | hc1.nofrosttemp | nofrost temperature | int8 (&gt;=0&lt;=0) | C | true | HC | 18 | 1 | 1/2 |
| hc1.control | control device | enum | | true | HC | 44 | 1 | 1 |
| hc1.program | program | enum | | true | HC | 27 | 1 | 1 | | hc1.program | program | enum | | true | HC | 27 | 1 | 1 |
| hc1.remotetemp | room temperature from remote | cmd | C | true | HC | 45 | 1 | 1/10 | | hc1.remotetemp | room temperature from remote | cmd | C | true | HC | 45 | 1 | 1/10 |
| hc1.targetflowtemp | target flow temperature | uint8 | C | false | HC | 19 | 1 | 1 | | hc1.targetflowtemp | target flow temperature | uint8 | C | false | HC | 19 | 1 | 1 |
@@ -7286,7 +7275,6 @@ uint8
| hc1.ecotemp | eco temperature | uint8 (&gt;=0&lt;=0) | C | true | HC | 5 | 1 | 1/2 | | hc1.ecotemp | eco temperature | uint8 (&gt;=0&lt;=0) | C | true | HC | 5 | 1 | 1/2 |
int8 int8
| hc1.nofrosttemp | nofrost temperature | int8 (&gt;=0&lt;=0) | C | true | HC | 18 | 1 | 1/2 | | hc1.nofrosttemp | nofrost temperature | int8 (&gt;=0&lt;=0) | C | true | HC | 18 | 1 | 1/2 |
| hc1.control | control device | enum | | true | HC | 44 | 1 | 1 |
| hc1.program | program | enum | | true | HC | 27 | 1 | 1 | | hc1.program | program | enum | | true | HC | 27 | 1 | 1 |
| hc1.remotetemp | room temperature from remote | cmd | C | true | HC | 45 | 1 | 1/10 | | hc1.remotetemp | room temperature from remote | cmd | C | true | HC | 45 | 1 | 1/10 |
| hc1.targetflowtemp | target flow temperature | uint8 | C | false | HC | 19 | 1 | 1 | | hc1.targetflowtemp | target flow temperature | uint8 | C | false | HC | 19 | 1 | 1 |
@@ -7345,7 +7333,6 @@ uint8
| hc1.ecotemp | eco temperature | uint8 (&gt;=0&lt;=0) | C | true | HC | 5 | 1 | 1/2 | | hc1.ecotemp | eco temperature | uint8 (&gt;=0&lt;=0) | C | true | HC | 5 | 1 | 1/2 |
int8 int8
| hc1.nofrosttemp | nofrost temperature | int8 (&gt;=0&lt;=0) | C | true | HC | 18 | 1 | 1/2 | | hc1.nofrosttemp | nofrost temperature | int8 (&gt;=0&lt;=0) | C | true | HC | 18 | 1 | 1/2 |
| hc1.control | control device | enum | | true | HC | 44 | 1 | 1 |
| hc1.program | program | enum | | true | HC | 27 | 1 | 1 | | hc1.program | program | enum | | true | HC | 27 | 1 | 1 |
| hc1.remotetemp | room temperature from remote | cmd | C | true | HC | 45 | 1 | 1/10 | | hc1.remotetemp | room temperature from remote | cmd | C | true | HC | 45 | 1 | 1/10 |
| hc1.targetflowtemp | target flow temperature | uint8 | C | false | HC | 19 | 1 | 1 | | hc1.targetflowtemp | target flow temperature | uint8 | C | false | HC | 19 | 1 | 1 |
@@ -7406,7 +7393,6 @@ uint8
| hc1.ecotemp | eco temperature | uint8 (&gt;=0&lt;=0) | C | true | HC | 5 | 1 | 1/2 | | hc1.ecotemp | eco temperature | uint8 (&gt;=0&lt;=0) | C | true | HC | 5 | 1 | 1/2 |
int8 int8
| hc1.nofrosttemp | nofrost temperature | int8 (&gt;=0&lt;=0) | C | true | HC | 18 | 1 | 1/2 | | hc1.nofrosttemp | nofrost temperature | int8 (&gt;=0&lt;=0) | C | true | HC | 18 | 1 | 1/2 |
| hc1.control | control device | enum | | true | HC | 44 | 1 | 1 |
| hc1.program | program | enum | | true | HC | 27 | 1 | 1 | | hc1.program | program | enum | | true | HC | 27 | 1 | 1 |
| hc1.remotetemp | room temperature from remote | cmd | C | true | HC | 45 | 1 | 1/10 | | hc1.remotetemp | room temperature from remote | cmd | C | true | HC | 45 | 1 | 1/10 |
| hc1.targetflowtemp | target flow temperature | uint8 | C | false | HC | 19 | 1 | 1 | | hc1.targetflowtemp | target flow temperature | uint8 | C | false | HC | 19 | 1 | 1 |
+5984 -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 ; WiFi settings
-D FACTORY_WIFI_SSID=\"\" -D FACTORY_WIFI_SSID=\"\"
-D FACTORY_WIFI_PASSWORD=\"\" -D FACTORY_WIFI_PASSWORD=\"\"
-D FACTORY_WIFI_HOSTNAME=\"ems-esp\" -D FACTORY_NETWORK_HOSTNAME=\"ems-esp\"
; Access point settings ; Access point settings
-D FACTORY_AP_PROVISION_MODE=AP_MODE_DISCONNECTED -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_LABEL=\"Europe/Amsterdam\"
-D FACTORY_NTP_TIME_ZONE_FORMAT=\"CET-1CEST,M3.5.0,M10.5.0/3\" -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_SERVER=\"time.google.com\"
-D FACTORY_NTP_THERMOSTAT_SYNC=0 -D FACTORY_NTP_THERMOSTAT_SYNC=1
; MQTT settings ; MQTT settings
-D FACTORY_MQTT_ENABLED=false -D FACTORY_MQTT_ENABLED=false
+22 -31
View File
@@ -1,9 +1,9 @@
{ {
"name": "EMS-ESP", "name": "EMS-ESP",
"version": "3.8.2", "version": "3.9.0",
"description": "EMS-ESP WebUI", "description": "EMS-ESP WebUI",
"homepage": "https://emsesp.org", "homepage": "https://emsesp.org",
"author": "proddy, emsesp.org", "author": "emsesp.org",
"license": "MIT", "license": "MIT",
"private": true, "private": true,
"type": "module", "type": "module",
@@ -28,44 +28,35 @@
"@emotion/styled": "^11.14.1", "@emotion/styled": "^11.14.1",
"@mui/icons-material": "^9.2.0", "@mui/icons-material": "^9.2.0",
"@mui/material": "^9.2.0", "@mui/material": "^9.2.0",
"@preact/compat": "^18.3.2",
"@table-library/react-table-library": "4.1.15", "@table-library/react-table-library": "4.1.15",
"alova": "3.5.1", "alova": "^3.5.3",
"async-validator": "^4.2.5",
"etag": "^1.8.1",
"formidable": "^3.5.4",
"jwt-decode": "^4.0.0", "jwt-decode": "^4.0.0",
"magic-string": "^0.30.21", "preact": "^10.29.8",
"mime-types": "^3.0.2", "react": "^19.2.8",
"preact": "^10.29.7", "react-dom": "^19.2.8",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-icons": "^5.7.0", "react-icons": "^5.7.0",
"react-router": "^7.18.1", "react-router": "^8.3.0",
"react-toastify": "^11.1.0", "typesafe-i18n": "^5.27.1"
"typesafe-i18n": "^5.27.1",
"typescript": "^6.0.3"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.29.7",
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@preact/compat": "^18.3.2", "@preact/preset-vite": "^2.10.6",
"@preact/preset-vite": "^2.10.5",
"@trivago/prettier-plugin-sort-imports": "^6.0.2", "@trivago/prettier-plugin-sort-imports": "^6.0.2",
"@types/node": "^25.9.5", "@types/node": "^26.1.2",
"@types/react": "^19.2.17", "@types/react": "^19.2.18",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.4",
"axe-core": "^4.12.1", "concurrently": "^10.0.4",
"concurrently": "^9.2.4", "eslint": "^10.8.0",
"eslint": "^10.7.0",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"prettier": "^3.9.5", "etag": "^1.8.1",
"mime-types": "^3.0.2",
"prettier": "^3.9.6",
"rollup-plugin-visualizer": "^7.0.1", "rollup-plugin-visualizer": "^7.0.1",
"terser": "^5.49.0", "terser": "^5.49.0",
"typescript-eslint": "^8.63.0", "typescript": "^6.0.3",
"vite": "^8.1.4", "typescript-eslint": "^8.65.0",
"vite-plugin-imagemin": "^0.6.1", "vite": "^8.2.0",
"vite-tsconfig-paths": "^6.1.1" "vite-plugin-imagemin": "^0.6.1"
}, },
"packageManager": "pnpm@10.33.4+sha512.1c67b3b359b2d408119ba1ed289f34b8fc3c6873412bec6fd264fbdc82489e510fcbecb9ce9d22dae7f3b76269d8441046014bdca53b9979cd7a561ad631b800" "packageManager": "pnpm@11.18.0+sha512.33d83c77da82f49fba836925c6f1b841181ec3132b670639bd012f7075f5c7cf634c5f870147c19aae7478fac01df09d8892e880454896edd23ee9b33757563c"
} }
+626 -1319
View File
File diff suppressed because it is too large Load Diff
+7 -32
View File
@@ -1,33 +1,8 @@
allowBuilds: allowBuilds:
cwebp-bin: set this to true or false cwebp-bin: false
esbuild: set this to true or false esbuild: false
gifsicle: set this to true or false gifsicle: false
jpegtran-bin: set this to true or false jpegtran-bin: false
mozjpeg: set this to true or false mozjpeg: false
optipng-bin: set this to true or false optipng-bin: false
pngquant-bin: set this to true or false pngquant-bin: 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
+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]); 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 = []) => { const getFilesSync = (dir, files = []) => {
readdirSync(dir, { withFileTypes: true }).forEach((entry) => { readdirSync(dir, { withFileTypes: true }).forEach((entry) => {
const entryPath = resolve(dir, entry.name); const entryPath = resolve(dir, entry.name);
@@ -116,7 +148,12 @@ writeStream.write(ARDUINO_INCLUDES);
const buildPath = resolve(sourcePath); const buildPath = resolve(sourcePath);
for (const filePath of getFilesSync(buildPath)) { 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()); 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 { memo, useEffect, useState } from 'react';
import { ToastContainer, Zoom } from 'react-toastify'; import { Outlet } from 'react-router';
import AppRouting from 'AppRouting';
import CustomTheme from 'CustomTheme'; import CustomTheme from 'CustomTheme';
import { Toaster } from 'components/toast';
import { Authentication } from 'contexts/authentication';
import TypesafeI18n from 'i18n/i18n-react'; import TypesafeI18n from 'i18n/i18n-react';
import type { Locales } from 'i18n/i18n-types'; import type { Locales } from 'i18n/i18n-types';
import { loadLocaleAsync } from 'i18n/i18n-util.async'; import { loadLocaleAsync } from 'i18n/i18n-util.async';
import { detectLocale, navigatorDetector } from 'typesafe-i18n/detectors'; import { detectLocale, navigatorDetector } from 'typesafe-i18n/detectors';
const AVAILABLE_LOCALES = [ const ALL_LOCALES = [
'de', 'de',
'en', 'en',
'it', 'it',
@@ -22,51 +23,50 @@ const AVAILABLE_LOCALES = [
'cs' 'cs'
] as Locales[]; ] as Locales[];
// Static toast configuration - no need to recreate on every render // Optional build-time allow-list (e.g. VITE_APP_LOCALES="en,de,nl"). When unset,
const TOAST_CONTAINER_PROPS = { // every locale is available. `en` is always kept as the fallback locale, and the
position: 'bottom-left' as const, // progmem generator embeds the matching subset into firmware flash.
autoClose: 3000, const localeAllowList = (import.meta.env.VITE_APP_LOCALES ?? '')
hideProgressBar: false, .split(',')
newestOnTop: false, .map((locale) => locale.trim())
closeOnClick: true, .filter(Boolean);
rtl: false,
pauseOnFocusLoss: true, const AVAILABLE_LOCALES: Locales[] = localeAllowList.length
draggable: false, ? ALL_LOCALES.filter(
pauseOnHover: false, (locale) => locale === 'en' || localeAllowList.includes(locale)
transition: Zoom, )
closeButton: false, : ALL_LOCALES;
theme: 'dark' as const,
toastStyle: {
border: '1px solid #177ac9',
width: 'fit-content'
}
};
const App = memo(() => { const App = memo(() => {
const [wasLoaded, setWasLoaded] = useState(false); const [wasLoaded, setWasLoaded] = useState(false);
const [locale, setLocale] = useState<Locales>('en'); const [locale, setLocale] = useState<Locales>('en');
// Memoize locale initialization to prevent unnecessary re-runs
const initializeLocale = useCallback(async () => {
const browserLocale = detectLocale('en', AVAILABLE_LOCALES, navigatorDetector);
const newLocale = (localStorage.getItem('lang') || browserLocale) as Locales;
localStorage.setItem('lang', newLocale);
setLocale(newLocale);
await loadLocaleAsync(newLocale);
setWasLoaded(true);
}, []);
useEffect(() => { useEffect(() => {
const initializeLocale = async () => {
const browserLocale = detectLocale('en', AVAILABLE_LOCALES, navigatorDetector);
const 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(); void initializeLocale();
}, [initializeLocale]); }, []);
if (!wasLoaded) return null; if (!wasLoaded) return null;
return ( return (
<TypesafeI18n locale={locale}> <TypesafeI18n locale={locale}>
<CustomTheme> <CustomTheme>
<AppRouting /> <Authentication>
<ToastContainer {...TOAST_CONTAINER_PROPS} /> <Outlet />
</Authentication>
<Toaster />
</CustomTheme> </CustomTheme>
</TypesafeI18n> </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;
+4 -5
View File
@@ -1,5 +1,4 @@
import { memo, useCallback, useContext, useEffect, useMemo, useState } from 'react'; import { memo, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { toast } from 'react-toastify';
import ForwardIcon from '@mui/icons-material/Forward'; import ForwardIcon from '@mui/icons-material/Forward';
import { Box, Button, Paper, Typography } from '@mui/material'; 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 * as AuthenticationApi from 'components/routing/authentication';
import { useRequest } from 'alova/client'; import { useRequest } from 'alova/client';
import type { ValidateFieldsError } from 'async-validator';
import { import {
LanguageSelector, LanguageSelector,
ValidatedPasswordField, ValidatedPasswordField,
ValidatedTextField ValidatedTextField
} from 'components'; } from 'components';
import { toast } from 'components/toast';
import { AuthenticationContext } from 'contexts/authentication'; import { AuthenticationContext } from 'contexts/authentication';
import { PROJECT_NAME } from 'env'; import { PROJECT_NAME } from 'env';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import type { SignInRequest } from 'types'; import type { SignInRequest } from 'types';
import { onEnterCallback, updateValue } from 'utils'; import { onEnterCallback, updateValue } from 'utils';
import { SIGN_IN_REQUEST_VALIDATOR, ValidationError, validate } from 'validators'; import { SIGN_IN_REQUEST_VALIDATOR, ValidationError, validate } from 'validators';
import type { ValidateFieldsError } from 'validators/schema';
const SignIn = memo(() => { const SignIn = memo(() => {
const authenticationContext = useContext(AuthenticationContext); const authenticationContext = useContext(AuthenticationContext);
@@ -43,7 +43,6 @@ const SignIn = memo(() => {
} }
}); });
// Memoize callback to prevent recreation on every render
const updateLoginRequestValue = useMemo( const updateLoginRequestValue = useMemo(
() => () =>
updateValue((updater) => updateValue((updater) =>
@@ -65,7 +64,7 @@ const SignIn = memo(() => {
}); });
}, [callSignIn, signInRequest, LL]); }, [callSignIn, signInRequest, LL]);
const validateAndSignIn = useCallback(async () => { const validateAndSignIn = async () => {
setProcessing(true); setProcessing(true);
SIGN_IN_REQUEST_VALIDATOR.messages({ SIGN_IN_REQUEST_VALIDATOR.messages({
required: LL.IS_REQUIRED('%s') required: LL.IS_REQUIRED('%s')
@@ -77,7 +76,7 @@ const SignIn = memo(() => {
setFieldErrors((error as ValidationError).fieldErrors); setFieldErrors((error as ValidationError).fieldErrors);
setProcessing(false); setProcessing(false);
} }
}, [signInRequest, signIn, LL]); };
const submitOnEnter = useMemo(() => onEnterCallback(signIn), [signIn]); const submitOnEnter = useMemo(() => onEnterCallback(signIn), [signIn]);
+21 -2
View File
@@ -4,6 +4,8 @@ import type {
APIcall, APIcall,
Action, Action,
Activity, Activity,
CommandItem,
Commands,
CoreData, CoreData,
DashboardData, DashboardData,
DeviceData, DeviceData,
@@ -102,8 +104,7 @@ export const readSchedule = () =>
o_deleted: si.deleted, o_deleted: si.deleted,
o_flags: si.flags, o_flags: si.flags,
o_time: si.time, o_time: si.time,
o_cmd: si.cmd, o_cmd_name: si.cmd_name,
o_value: si.value,
o_name: si.name o_name: si.name
})); }));
} }
@@ -111,6 +112,24 @@ export const readSchedule = () =>
export const writeSchedule = (data: Schedule) => export const writeSchedule = (data: Schedule) =>
alovaInstance.Post('/rest/schedule', data); 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 // Modules
export const readModules = () => export const readModules = () =>
alovaInstance.Get<ModuleItem[]>('/rest/modules', { alovaInstance.Get<ModuleItem[]>('/rest/modules', {
-9
View File
@@ -57,12 +57,3 @@ export const alovaInstance = createAlova({
onSuccess: handleResponse onSuccess: handleResponse
} }
}); });
export const alovaInstanceGH = createAlova({
baseURL:
process.env.NODE_ENV === 'development'
? '/gh'
: 'https://api.github.com/repos/emsesp/EMS-ESP32/releases',
statesHook: ReactHook,
requestAdapter: xhrRequestAdapter()
});
+1 -24
View File
@@ -1,6 +1,6 @@
import type { LogSettings, SystemStatus } from 'types'; import type { LogSettings, SystemStatus } from 'types';
import { alovaInstance, alovaInstanceGH } from './endpoints'; import { alovaInstance } from './endpoints';
// systemStatus - also used to ping in System Monitor for pinging // systemStatus - also used to ping in System Monitor for pinging
export const readSystemStatus = () => export const readSystemStatus = () =>
@@ -13,29 +13,6 @@ export const updateLogSettings = (data: LogSettings) =>
alovaInstance.Post('/rest/logSettings', data); alovaInstance.Post('/rest/logSettings', data);
export const fetchLogES = () => alovaInstance.Get('/es/log'); export const fetchLogES = () => alovaInstance.Get('/es/log');
// Get versions from GitHub
// 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 const UPLOAD_TIMEOUT = 60000; // 1 minute
export const uploadFile = (file: File) => { export const uploadFile = (file: File) => {
+312
View File
@@ -0,0 +1,312 @@
import { useState } from 'react';
import { useBlocker } from 'react-router';
import AddIcon from '@mui/icons-material/Add';
import CancelIcon from '@mui/icons-material/Cancel';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import WarningIcon from '@mui/icons-material/Warning';
import { Box, Button, IconButton, Typography } from '@mui/material';
import { callAction } from '@/api/app';
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 32px;
`,
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 { send: executeCommand } = useRequest(
(id: string) => callAction({ action: 'executeCommand', param: id }),
{ immediate: false }
)
.onSuccess(() => {
toast.success(LL.EXECUTE_COMMAND_SENT());
})
.onError((error) => {
toast.error(String(error.error?.message || 'An error occurred'));
});
const execute = async (name: string) => {
await executeCommand(name);
};
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>
<HeaderCell></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>
<Cell>
{numChanges === 0 && (
<IconButton
onClick={() => execute(ci.name)}
style={{ backgroundColor: 'transparent' }}
>
<PlayArrowIcon color="primary" sx={{ fontSize: 16 }} />
</IconButton>
)}
</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.EXECUTE_COMMAND_SENT());
})
.onError((error) => {
toast.error(String(error.error?.message || 'An error occurred'));
});
const execute = async () => {
await executeCommand(editItem.name);
};
const remove = () => {
onSave({ ...editItem, deleted: true });
};
const handleClose = (
_event: React.SyntheticEvent,
reason: 'backdropClick' | 'escapeKeyDown'
) => {
if (reason !== 'backdropClick') {
onClose();
}
};
return (
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
<DialogTitle>
{creating ? `${LL.ADD(1)} ${LL.NEW(0)}` : LL.EDIT()}&nbsp;
{LL.COMMAND(1)}
</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.EXECUTE()}
</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 { useBlocker } from 'react-router';
import { toast } from 'react-toastify';
import AddIcon from '@mui/icons-material/Add'; import AddIcon from '@mui/icons-material/Add';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
@@ -26,6 +25,7 @@ import {
SectionContent, SectionContent,
useLayoutTitle useLayoutTitle
} from 'components'; } from 'components';
import { toast } from 'components/toast';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import { useInterval } from 'utils'; import { useInterval } from 'utils';
@@ -57,20 +57,18 @@ const CustomEntities = () => {
initialData: [] initialData: []
}); });
const intervalCallback = useCallback(() => { useInterval(() => {
if (!dialogOpen && !numChanges) { if (!dialogOpen && !numChanges) {
void fetchEntities(); void fetchEntities();
} }
}, [dialogOpen, numChanges, fetchEntities]); });
useInterval(intervalCallback);
const { send: writeEntities } = useRequest( const { send: writeEntities } = useRequest(
(data: Entities) => writeCustomEntities(data), (data: Entities) => writeCustomEntities(data),
{ immediate: false } { immediate: false }
); );
const hasEntityChanged = useCallback((ei: EntityItem) => { const hasEntityChanged = (ei: EntityItem) => {
return ( return (
ei.id !== ei.o_id || ei.id !== ei.o_id ||
ei.ram !== ei.o_ram || ei.ram !== ei.o_ram ||
@@ -86,21 +84,19 @@ const CustomEntities = () => {
ei.deleted !== ei.o_deleted || ei.deleted !== ei.o_deleted ||
(ei.value || '') !== (ei.o_value || '') (ei.value || '') !== (ei.o_value || '')
); );
}, []); };
const entity_theme = useMemo( const entity_theme = useTheme({
() => Table: `
useTheme({
Table: `
--data-table-library_grid-template-columns: repeat(1, minmax(60px, 1fr)) minmax(80px, auto) 80px 80px 80px 120px; --data-table-library_grid-template-columns: repeat(1, minmax(60px, 1fr)) minmax(80px, auto) 80px 80px 80px 120px;
`, `,
BaseRow: ` BaseRow: `
font-size: 14px; font-size: 14px;
.td { .td {
height: 32px; height: 32px;
} }
`, `,
BaseCell: ` BaseCell: `
&:nth-of-type(1) { &:nth-of-type(1) {
padding: 8px; padding: 8px;
} }
@@ -120,7 +116,7 @@ const CustomEntities = () => {
text-align: center; text-align: center;
} }
`, `,
HeaderRow: ` HeaderRow: `
text-transform: uppercase; text-transform: uppercase;
background-color: black; background-color: black;
color: #90CAF9; color: #90CAF9;
@@ -129,7 +125,7 @@ const CustomEntities = () => {
height: 36px; height: 36px;
} }
`, `,
Row: ` Row: `
background-color: #1e1e1e; background-color: #1e1e1e;
position: relative; position: relative;
cursor: pointer; cursor: pointer;
@@ -140,11 +136,9 @@ const CustomEntities = () => {
background-color: #177ac9; background-color: #177ac9;
} }
` `
}), });
[]
);
const saveEntities = useCallback(async () => { const saveEntities = async () => {
await writeEntities({ await writeEntities({
entities: entities entities: entities
.filter((ei: EntityItem) => !ei.deleted) .filter((ei: EntityItem) => !ei.deleted)
@@ -173,44 +167,41 @@ const CustomEntities = () => {
await fetchEntities(); await fetchEntities();
setNumChanges(0); setNumChanges(0);
}); });
}, [entities, writeEntities, LL, fetchEntities]); };
const editEntityItem = useCallback((ei: EntityItem) => { const editEntityItem = (ei: EntityItem) => {
setCreating(false); setCreating(false);
setSelectedEntityItem(ei); setSelectedEntityItem(ei);
setDialogOpen(true); setDialogOpen(true);
}, []); };
const onDialogClose = useCallback(() => { const onDialogClose = () => {
setDialogOpen(false); setDialogOpen(false);
}, []); };
const onDialogCancel = useCallback(async () => { const onDialogCancel = async () => {
await fetchEntities().then(() => { await fetchEntities().then(() => {
setNumChanges(0); setNumChanges(0);
}); });
}, [fetchEntities]); };
const onDialogSave = useCallback( const onDialogSave = (updatedItem: EntityItem) => {
(updatedItem: EntityItem) => { setDialogOpen(false);
setDialogOpen(false); void updateState(readCustomEntities(), (data: EntityItem[]) => {
void updateState(readCustomEntities(), (data: EntityItem[]) => { const new_data = creating
const new_data = creating ? [
? [ ...data.filter((ei) => creating || ei.o_id !== updatedItem.o_id),
...data.filter((ei) => creating || ei.o_id !== updatedItem.o_id), updatedItem
updatedItem ]
] : data.map((ei) =>
: data.map((ei) => ei.id === updatedItem.id ? { ...ei, ...updatedItem } : ei
ei.id === updatedItem.id ? { ...ei, ...updatedItem } : ei );
); setNumChanges(new_data.filter((ei) => hasEntityChanged(ei)).length);
setNumChanges(new_data.filter((ei) => hasEntityChanged(ei)).length); return new_data;
return new_data; });
}); };
},
[creating, hasEntityChanged]
);
const onDialogDup = useCallback((item: EntityItem) => { const onDialogDup = (item: EntityItem) => {
setCreating(true); setCreating(true);
setSelectedEntityItem({ setSelectedEntityItem({
id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID), id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID),
@@ -228,9 +219,9 @@ const CustomEntities = () => {
value: item.value value: item.value
}); });
setDialogOpen(true); setDialogOpen(true);
}, []); };
const addEntityItem = useCallback(() => { const addEntityItem = () => {
setCreating(true); setCreating(true);
setSelectedEntityItem({ setSelectedEntityItem({
id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID), id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID),
@@ -248,30 +239,27 @@ const CustomEntities = () => {
value: '' value: ''
}); });
setDialogOpen(true); setDialogOpen(true);
}, []); };
const formatValue = useCallback((value: unknown, uom: number) => { const formatValue = (value: unknown, uom: number) => {
return value === undefined return value === undefined
? '' ? ''
: typeof value === 'number' : typeof value === 'number'
? new Intl.NumberFormat().format(value) + ? new Intl.NumberFormat().format(value) +
(uom === 0 ? '' : ` ${DeviceValueUOM_s[uom]}`) (uom === 0 ? '' : ` ${DeviceValueUOM_s[uom]}`)
: `${value as string}${uom === 0 ? '' : ` ${DeviceValueUOM_s[uom]}`}`; : `${value as string}${uom === 0 ? '' : ` ${DeviceValueUOM_s[uom]}`}`;
}, []); };
const showHex = useCallback((value: number, digit: number) => { const showHex = (value: number, digit: number) => {
return `0x${value.toString(16).toUpperCase().padStart(digit, '0')}`; return `0x${value.toString(16).toUpperCase().padStart(digit, '0')}`;
}, []); };
const filteredAndSortedEntities = useMemo( const filteredAndSortedEntities =
() => entities
entities ?.filter((ei: EntityItem) => !ei.deleted)
?.filter((ei: EntityItem) => !ei.deleted) .sort((a: EntityItem, b: EntityItem) => a.name.localeCompare(b.name)) ?? [];
.sort((a: EntityItem, b: EntityItem) => a.name.localeCompare(b.name)) ?? [],
[entities]
);
const renderEntity = useCallback(() => { const renderEntity = () => {
if (!entities) { if (!entities) {
return ( return (
<FormLoader onRetry={fetchEntities} errorMessage={error?.message || ''} /> <FormLoader onRetry={fetchEntities} errorMessage={error?.message || ''} />
@@ -328,17 +316,7 @@ const CustomEntities = () => {
)} )}
</Table> </Table>
); );
}, [ };
entities,
error,
fetchEntities,
entity_theme,
editEntityItem,
LL,
filteredAndSortedEntities,
showHex,
formatValue
]);
return ( return (
<SectionContent> <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 AddIcon from '@mui/icons-material/Add';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
@@ -23,12 +23,12 @@ import {
} from '@mui/material'; } from '@mui/material';
import { dialogStyle } from 'CustomTheme'; import { dialogStyle } from 'CustomTheme';
import type Schema from 'async-validator';
import type { ValidateFieldsError } from 'async-validator';
import { BlockFormControlLabel, ValidatedTextField } from 'components'; import { BlockFormControlLabel, ValidatedTextField } from 'components';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import { numberValue, updateValue } from 'utils'; import { numberValue, updateValue } from 'utils';
import { ValidationError, validate } from 'validators'; import { ValidationError, validate } from 'validators';
import type Schema from 'validators/schema';
import type { ValidateFieldsError } from 'validators/schema';
import { DeviceValueType, DeviceValueTypeNames, DeviceValueUOM_s } from './types'; import { DeviceValueType, DeviceValueTypeNames, DeviceValueUOM_s } from './types';
import type { EntityItem } from './types'; import type { EntityItem } from './types';
@@ -68,14 +68,10 @@ const CustomEntitiesDialog = ({
const { LL } = useI18nContext(); const { LL } = useI18nContext();
const [editItem, setEditItem] = useState<EntityItem>(selectedItem); const [editItem, setEditItem] = useState<EntityItem>(selectedItem);
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>(); const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const updateFormValue = useMemo( const updateFormValue = updateValue(
() => setEditItem as unknown as React.Dispatch<
updateValue( React.SetStateAction<Record<string, unknown>>
setEditItem as unknown as React.Dispatch< >
React.SetStateAction<Record<string, unknown>>
>
),
[]
); );
useEffect(() => { useEffect(() => {
@@ -105,16 +101,16 @@ const CustomEntitiesDialog = ({
} }
}, [open, selectedItem]); }, [open, selectedItem]);
const handleClose = useCallback( const handleClose = (
(_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => { _event: React.SyntheticEvent,
if (reason !== 'backdropClick') { reason: 'backdropClick' | 'escapeKeyDown'
onClose(); ) => {
} if (reason !== 'backdropClick') {
}, onClose();
[onClose] }
); };
const save = useCallback(async () => { const save = async () => {
try { try {
setFieldErrors(undefined); setFieldErrors(undefined);
await validate(validator, editItem); await validate(validator, editItem);
@@ -138,27 +134,21 @@ const CustomEntitiesDialog = ({
} catch (error) { } catch (error) {
setFieldErrors((error as ValidationError).fieldErrors); setFieldErrors((error as ValidationError).fieldErrors);
} }
}, [validator, editItem, onSave]); };
const remove = useCallback(() => { const remove = () => {
const itemWithDeleted = { ...editItem, deleted: true }; onSave({ ...editItem, deleted: true });
onSave(itemWithDeleted); };
}, [editItem, onSave]);
const dup = useCallback(() => { const dup = () => {
onDup(editItem); onDup(editItem);
}, [editItem, onDup]); };
// Memoize UOM menu items to avoid recreating on every render const uomMenuItems = DeviceValueUOM_s.map((val, i) => (
const uomMenuItems = useMemo( <MenuItem key={val} value={i}>
() => {val}
DeviceValueUOM_s.map((val, i) => ( </MenuItem>
<MenuItem key={val} value={i}> ));
{val}
</MenuItem>
)),
[]
);
return ( return (
<Dialog sx={dialogStyle} open={open} onClose={handleClose}> <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 { useBlocker, useLocation } from 'react-router';
import { toast } from 'react-toastify';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
import EditIcon from '@mui/icons-material/Edit'; import EditIcon from '@mui/icons-material/Edit';
@@ -46,6 +45,7 @@ import {
SectionContent, SectionContent,
useLayoutTitle useLayoutTitle
} from 'components'; } from 'components';
import { toast } from 'components/toast';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import { import {
@@ -171,19 +171,17 @@ const Customizations = () => {
); );
}; };
const entities_theme = useMemo( const entities_theme = useTheme({
() => Table: `
useTheme({
Table: `
--data-table-library_grid-template-columns: 156px repeat(1, minmax(80px, 1fr)) 45px minmax(45px, auto) minmax(120px, auto); --data-table-library_grid-template-columns: 156px repeat(1, minmax(80px, 1fr)) 45px minmax(45px, auto) minmax(120px, auto);
`, `,
BaseRow: ` BaseRow: `
font-size: 14px; font-size: 14px;
.td { .td {
height: 32px; height: 32px;
} }
`, `,
BaseCell: ` BaseCell: `
&:nth-of-type(3) { &:nth-of-type(3) {
text-align: right; text-align: right;
} }
@@ -194,7 +192,7 @@ const Customizations = () => {
text-align: right; text-align: right;
} }
`, `,
HeaderRow: ` HeaderRow: `
text-transform: uppercase; text-transform: uppercase;
background-color: black; background-color: black;
color: #90CAF9; color: #90CAF9;
@@ -206,7 +204,7 @@ const Customizations = () => {
text-align: center; text-align: center;
} }
`, `,
Row: ` Row: `
background-color: #1e1e1e; background-color: #1e1e1e;
position: relative; position: relative;
cursor: pointer; cursor: pointer;
@@ -222,7 +220,7 @@ const Customizations = () => {
background-color: #177ac9; background-color: #177ac9;
} }
`, `,
Cell: ` Cell: `
&:nth-of-type(2) { &:nth-of-type(2) {
padding: 8px; padding: 8px;
} }
@@ -236,9 +234,7 @@ const Customizations = () => {
padding-right: 8px; padding-right: 8px;
} }
` `
}), });
[]
);
function hasEntityChanged(de: DeviceEntity) { function hasEntityChanged(de: DeviceEntity) {
return ( return (
@@ -287,26 +283,23 @@ const Customizations = () => {
return value as string; return value as string;
} }
const isCommand = useCallback((de: DeviceEntity) => { const isCommand = (de: DeviceEntity) => {
return de.n && de.n[0] === '!'; return de.n && de.n[0] === '!';
}, []); };
const formatName = useCallback( const formatName = (de: DeviceEntity, withShortname: boolean) => {
(de: DeviceEntity, withShortname: boolean) => { let name: string;
let name: string; if (isCommand(de)) {
if (isCommand(de)) { name = de.t
name = de.t ? `${LL.COMMAND(1)}: ${de.t} ${de.n?.slice(1)}`
? `${LL.COMMAND(1)}: ${de.t} ${de.n?.slice(1)}` : `${LL.COMMAND(1)}: ${de.n?.slice(1)}`;
: `${LL.COMMAND(1)}: ${de.n?.slice(1)}`; } else if (de.cn && de.cn !== '') {
} else if (de.cn && de.cn !== '') { name = de.t ? `${de.t} ${de.cn}` : de.cn;
name = de.t ? `${de.t} ${de.cn}` : de.cn; } else {
} else { name = de.t ? `${de.t} ${de.n}` : de.n || '';
name = de.t ? `${de.t} ${de.n}` : de.n || ''; }
} return withShortname ? `${name} ${de.id}` : name;
return withShortname ? `${name} ${de.id}` : name; };
},
[LL]
);
const getMaskNumber = (newMask: string[]) => { const getMaskNumber = (newMask: string[]) => {
let new_mask = 0; let new_mask = 0;
@@ -336,33 +329,27 @@ const Customizations = () => {
return new_masks; return new_masks;
}; };
const filter_entity = useCallback( const filter_entity = (de: DeviceEntity) =>
(de: DeviceEntity) => (de.m & selectedFilters || !selectedFilters) &&
(de.m & selectedFilters || !selectedFilters) && formatName(de, true).toLowerCase().includes(search.toLowerCase());
formatName(de, true).toLowerCase().includes(search.toLowerCase()),
[selectedFilters, search, formatName]
);
const maskDisabled = useCallback( const maskDisabled = (set: boolean) => {
(set: boolean) => { setDeviceEntities((prev) =>
setDeviceEntities((prev) => prev.map((de) => {
prev.map((de) => { if (filter_entity(de)) {
if (filter_entity(de)) { const excludeMask =
const excludeMask = DeviceEntityMask.DV_API_MQTT_EXCLUDE | DeviceEntityMask.DV_WEB_EXCLUDE;
DeviceEntityMask.DV_API_MQTT_EXCLUDE | DeviceEntityMask.DV_WEB_EXCLUDE; return {
return { ...de,
...de, m: set ? de.m | excludeMask : de.m & ~excludeMask
m: set ? de.m | excludeMask : de.m & ~excludeMask };
}; }
} return de;
return de; })
}) );
); };
},
[filter_entity]
);
const resetCustomization = useCallback(async () => { const resetCustomization = async () => {
try { try {
await sendResetCustomizations(); await sendResetCustomizations();
toast.info(LL.CUSTOMIZATIONS_RESTART()); toast.info(LL.CUSTOMIZATIONS_RESTART());
@@ -372,30 +359,27 @@ const Customizations = () => {
setConfirmReset(false); setConfirmReset(false);
setRestarting(true); setRestarting(true);
} }
}, [sendResetCustomizations, LL]); };
const onDialogClose = () => { const onDialogClose = () => {
setDialogOpen(false); setDialogOpen(false);
}; };
const updateDeviceEntity = useCallback((updatedItem: DeviceEntity) => { const updateDeviceEntity = (updatedItem: DeviceEntity) => {
setDeviceEntities( setDeviceEntities(
(prev) => (prev) =>
prev?.map((de) => prev?.map((de) =>
de.id === updatedItem.id ? { ...de, ...updatedItem } : de de.id === updatedItem.id ? { ...de, ...updatedItem } : de
) ?? [] ) ?? []
); );
}, []); };
const onDialogSave = useCallback( const onDialogSave = (updatedItem: DeviceEntity) => {
(updatedItem: DeviceEntity) => { setDialogOpen(false);
setDialogOpen(false); updateDeviceEntity(updatedItem);
updateDeviceEntity(updatedItem); };
},
[updateDeviceEntity]
);
const editDeviceEntity = useCallback((de: DeviceEntity) => { const editDeviceEntity = (de: DeviceEntity) => {
if (de.n === undefined || (de.n && de.n[0] === '!')) { if (de.n === undefined || (de.n && de.n[0] === '!')) {
return; return;
} }
@@ -406,9 +390,9 @@ const Customizations = () => {
setSelectedDeviceEntity(de); setSelectedDeviceEntity(de);
setDialogOpen(true); setDialogOpen(true);
}, []); };
const saveCustomization = useCallback(async () => { const saveCustomization = async () => {
if (!devices || !deviceEntities || selectedDevice === -1) { if (!devices || !deviceEntities || selectedDevice === -1) {
return; return;
} }
@@ -441,9 +425,9 @@ const Customizations = () => {
.finally(() => { .finally(() => {
setOriginalSettings(deviceEntities); setOriginalSettings(deviceEntities);
}); });
}, [devices, deviceEntities, selectedDevice, sendCustomizationEntities, LL]); };
const renameDevice = useCallback(async () => { const renameDevice = async () => {
await sendDeviceName({ await sendDeviceName({
id: selectedDevice, id: selectedDevice,
name: selectedDeviceName, name: selectedDeviceName,
@@ -459,14 +443,7 @@ const Customizations = () => {
setRename(false); setRename(false);
await fetchCoreData(); await fetchCoreData();
}); });
}, [ };
selectedDevice,
selectedDeviceName,
selectedDeviceBrand,
sendDeviceName,
LL,
fetchCoreData
]);
const renderDeviceList = () => ( const renderDeviceList = () => (
<> <>
@@ -512,7 +489,8 @@ const Customizations = () => {
</MenuItem> </MenuItem>
{devices.devices.map( {devices.devices.map(
(device: Device) => (device: Device) =>
device.id < 90 && ( device.id < 90 &&
device.e !== 0 && (
<MenuItem key={device.id} value={device.id}> <MenuItem key={device.id} value={device.id}>
{device.n}&nbsp;({device.tn}) {device.n}&nbsp;({device.tn})
</MenuItem> </MenuItem>
@@ -562,10 +540,7 @@ const Customizations = () => {
</> </>
); );
const filteredEntities = useMemo( const filteredEntities = deviceEntities.filter((de) => filter_entity(de));
() => deviceEntities.filter((de) => filter_entity(de)),
[deviceEntities, filter_entity]
);
const renderDeviceData = () => { const renderDeviceData = () => {
return ( 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 CancelIcon from '@mui/icons-material/Cancel';
import CloseIcon from '@mui/icons-material/Close'; import CloseIcon from '@mui/icons-material/Close';
@@ -57,23 +57,16 @@ const CustomizationsDialog = ({
const [editItem, setEditItem] = useState<DeviceEntity>(selectedItem); const [editItem, setEditItem] = useState<DeviceEntity>(selectedItem);
const [error, setError] = useState<boolean>(false); const [error, setError] = useState<boolean>(false);
const updateFormValue = useMemo( const updateFormValue = updateValue(
() => setEditItem as unknown as React.Dispatch<
updateValue( React.SetStateAction<Record<string, unknown>>
setEditItem as unknown as React.Dispatch< >
React.SetStateAction<Record<string, unknown>>
>
),
[]
); );
const isWriteableNumber = useMemo( const isWriteableNumber =
() => typeof editItem.v === 'number' &&
typeof editItem.v === 'number' && editItem.w &&
editItem.w && !(editItem.m & DeviceEntityMask.DV_READONLY);
!(editItem.m & DeviceEntityMask.DV_READONLY),
[editItem.v, editItem.w, editItem.m]
);
useEffect(() => { useEffect(() => {
if (open) { if (open) {
@@ -82,16 +75,16 @@ const CustomizationsDialog = ({
} }
}, [open, selectedItem]); }, [open, selectedItem]);
const handleClose = useCallback( const handleClose = (
(_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => { _event: React.SyntheticEvent,
if (reason !== 'backdropClick') { reason: 'backdropClick' | 'escapeKeyDown'
onClose(); ) => {
} if (reason !== 'backdropClick') {
}, onClose();
[onClose] }
); };
const save = useCallback(() => { const save = () => {
if ( if (
isWriteableNumber && isWriteableNumber &&
editItem.mi && editItem.mi &&
@@ -102,34 +95,31 @@ const CustomizationsDialog = ({
} else { } else {
onSave(editItem); onSave(editItem);
} }
}, [isWriteableNumber, editItem, onSave]); };
const updateDeviceEntity = useCallback((updatedItem: DeviceEntity) => { const updateDeviceEntity = (updatedItem: DeviceEntity) => {
setEditItem((prev) => ({ ...prev, m: updatedItem.m })); setEditItem((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 ( return (
<Dialog sx={dialogStyle} open={open} onClose={handleClose}> <Dialog sx={dialogStyle} open={open} onClose={handleClose}>
<DialogTitle>{dialogTitle}</DialogTitle> <DialogTitle>{`${LL.EDIT()} ${LL.ENTITY()}`}</DialogTitle>
<DialogContent dividers> <DialogContent dividers>
<LabelValue label={LL.ID_OF(LL.ENTITY())} value={editItem.id} /> <LabelValue label={LL.ID_OF(LL.ENTITY())} value={editItem.id} />
<LabelValue <LabelValue
label={`${LL.DEFAULT(1)} ${LL.ENTITY_NAME(1)}`} label={`${LL.DEFAULT(1)} ${LL.ENTITY_NAME(1)}`}
value={editItem.n} 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 }}> <Box sx={{ mt: 1, mb: 2 }}>
<EntityMaskToggle onUpdate={updateDeviceEntity} de={editItem} /> <EntityMaskToggle onUpdate={updateDeviceEntity} de={editItem} />
+100 -100
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 { IconContext } from 'react-icons/lib';
import { Link } from 'react-router'; import { Link } from 'react-router';
import { toast } from 'react-toastify';
import ChevronRightIcon from '@mui/icons-material/ChevronRight'; import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import EditIcon from '@mui/icons-material/Edit'; import EditIcon from '@mui/icons-material/Edit';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import HelpOutlineIcon from '@mui/icons-material/HelpOutlined'; import HelpOutlineIcon from '@mui/icons-material/HelpOutlined';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import UnfoldLessIcon from '@mui/icons-material/UnfoldLess'; import UnfoldLessIcon from '@mui/icons-material/UnfoldLess';
import UnfoldMoreIcon from '@mui/icons-material/UnfoldMore'; import UnfoldMoreIcon from '@mui/icons-material/UnfoldMore';
import { import {
@@ -29,6 +29,7 @@ import {
SectionContent, SectionContent,
useLayoutTitle useLayoutTitle
} from 'components'; } from 'components';
import { toast } from 'components/toast';
import { AuthenticatedContext } from 'contexts/authentication'; import { AuthenticatedContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import { useInterval, usePersistState } from 'utils'; import { useInterval, usePersistState } from 'utils';
@@ -75,42 +76,38 @@ const Dashboard = memo(() => {
{ {
immediate: false immediate: false
} }
); )
.onSuccess(() => {
toast.success(LL.WRITE_CMD_SENT());
})
.onError((error) => {
toast.error(String(error.error?.message || 'An error occurred'));
});
const deviceValueDialogSave = useCallback( const deviceValueDialogSave = async (devicevalue: DeviceValue) => {
async (devicevalue: DeviceValue) => { if (!selectedDashboardItem) {
if (!selectedDashboardItem) { return;
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 const id = selectedDashboardItem.parentNode.id; // this is the parent ID
await sendDeviceValue({ id, c: devicevalue.c ?? '', v: devicevalue.v }) await sendDeviceValue({ id, c: devicevalue.c ?? '', v: devicevalue.v });
.then(() => { }
toast.success(LL.WRITE_CMD_SENT()); setDeviceValueDialogOpen(false);
}) setSelectedDashboardItem(undefined);
.catch((error: Error) => { };
toast.error(error.message);
})
.finally(() => {
setDeviceValueDialogOpen(false);
setSelectedDashboardItem(undefined);
});
},
[selectedDashboardItem, sendDeviceValue, LL]
);
const dashboard_theme = useMemo( const dashboard_theme = useTheme({
() => Table: `
useTheme({
Table: `
--data-table-library_grid-template-columns: minmax(80px, auto) 120px 32px; --data-table-library_grid-template-columns: minmax(80px, auto) 120px 32px;
`, `,
BaseRow: ` BaseRow: `
font-size: 14px; font-size: 14px;
.td { .td {
height: 28px; height: 28px;
} }
`, `,
Row: ` Row: `
cursor: pointer; cursor: pointer;
background-color: #1e1e1e; background-color: #1e1e1e;
&:nth-of-type(odd) .td { &:nth-of-type(odd) .td {
@@ -120,7 +117,7 @@ const Dashboard = memo(() => {
background-color: #177ac9; background-color: #177ac9;
}, },
`, `,
BaseCell: ` BaseCell: `
&:nth-of-type(2) { &:nth-of-type(2) {
text-align: right; text-align: right;
} }
@@ -128,9 +125,7 @@ const Dashboard = memo(() => {
text-align: right; text-align: right;
} }
` `
}), });
[]
);
const tree = useTree( const tree = useTree(
{ nodes: [...data.nodes] }, { nodes: [...data.nodes] },
@@ -164,79 +159,71 @@ const Dashboard = memo(() => {
} }
}); });
const nodeIds = useMemo(
() => data.nodes.map((item: DashboardItem) => item.id),
[data.nodes]
);
useEffect(() => { useEffect(() => {
const nodeIds = data.nodes.map((item: DashboardItem) => item.id);
showAll showAll
? tree.fns.onAddAll(nodeIds) // expand tree ? tree.fns.onAddAll(nodeIds) // expand tree
: tree.fns.onRemoveAll(); // collapse tree : tree.fns.onRemoveAll(); // collapse tree
}, [parentNodes]); }, [parentNodes]);
const showType = useCallback( const showType = (n?: string, t?: number) => {
(n?: string, t?: number) => { // if we have a name show it
// if we have a name show it if (n) {
if (n) { return n;
return n; }
if (t) {
// otherwise pick translation based on type
switch (t) {
case DeviceType.CUSTOM:
return LL.CUSTOM_ENTITIES(0);
case DeviceType.ANALOGSENSOR:
return LL.ANALOG_SENSORS();
case DeviceType.TEMPERATURESENSOR:
return LL.TEMP_SENSORS();
case DeviceType.COMMAND:
return LL.COMMANDS();
case DeviceType.SCHEDULER:
return LL.SCHEDULER();
default:
break;
} }
if (t) { }
// otherwise pick translation based on type return '';
switch (t) { };
case DeviceType.CUSTOM:
return LL.CUSTOM_ENTITIES(0);
case DeviceType.ANALOGSENSOR:
return LL.ANALOG_SENSORS();
case DeviceType.TEMPERATURESENSOR:
return LL.TEMP_SENSORS();
case DeviceType.SCHEDULER:
return LL.SCHEDULER();
default:
break;
}
}
return '';
},
[LL]
);
const showName = useCallback( const showName = (di: DashboardItem) => {
(di: DashboardItem) => { if (di.id < 100) {
if (di.id < 100) { // if its a device (parent node) and has entities
// if its a device (parent node) and has entities if (di.nodes?.length) {
if (di.nodes?.length) { return (
return ( <span style={{ fontSize: '15px' }}>
<span style={{ fontSize: '15px' }}> <DeviceIcon type_id={di.t ?? 0} />
<DeviceIcon type_id={di.t ?? 0} /> &nbsp;&nbsp;{showType(di.n, di.t)}
&nbsp;&nbsp;{showType(di.n, di.t)} <span style={{ color: 'lightblue' }}>&nbsp;({di.nodes?.length})</span>
<span style={{ color: 'lightblue' }}>&nbsp;({di.nodes?.length})</span> </span>
</span> );
);
}
} }
if (di.dv) { }
return <span>{di.dv.id.slice(2)}</span>; if (di.dv) {
} return <span>{di.dv.id.slice(2)}</span>;
return null; }
}, return null;
[showType] };
);
const hasMask = useCallback( const hasMask = (id: string, mask: number) =>
(id: string, mask: number) => (parseInt(id.slice(0, 2), 16) & mask) === mask, (parseInt(id.slice(0, 2), 16) & mask) === mask;
[]
);
const editDashboardValue = useCallback( const editDashboardValue = (di: DashboardItem) => {
(di: DashboardItem) => { // don't execute on parent nodes
if (me.admin && di.dv?.c) { if (!me.admin || di.id <= 99) {
setSelectedDashboardItem(di); return;
setDeviceValueDialogOpen(true); }
}
}, if (di.dv?.c) {
[me.admin] setSelectedDashboardItem(di);
); setDeviceValueDialogOpen(true);
}
};
const handleShowAll = ( const handleShowAll = (
_event: React.MouseEvent<HTMLElement>, _event: React.MouseEvent<HTMLElement>,
@@ -248,10 +235,9 @@ const Dashboard = memo(() => {
} }
}; };
const hasFavEntities = useMemo( const hasFavEntities = data.nodes.filter(
() => data.nodes.filter((item: DashboardItem) => item.id <= 90).length, (item: DashboardItem) => item.id <= 90
[data.nodes] ).length;
);
const renderContent = () => { const renderContent = () => {
if (!data) { if (!data) {
@@ -344,20 +330,34 @@ const Dashboard = memo(() => {
<Cell> <Cell>
{me.admin && {me.admin &&
di.dv?.c && 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_COMMAND()}
onClick={() => editDashboardValue(di)}
style={{ backgroundColor: 'transparent' }}
>
<PlayArrowIcon
color="primary"
sx={{ fontSize: 16 }}
/>
</IconButton>
) : (
<IconButton <IconButton
size="small" size="small"
aria-label={ aria-label={
LL.CHANGE_VALUE() + ' ' + LL.VALUE(0) LL.CHANGE_VALUE() + ' ' + LL.VALUE(0)
} }
onClick={() => editDashboardValue(di)} onClick={() => editDashboardValue(di)}
style={{ backgroundColor: 'transparent' }}
> >
<EditIcon <EditIcon
color="primary" color="primary"
sx={{ fontSize: 16 }} sx={{ fontSize: 16 }}
/> />
</IconButton> </IconButton>
)} ))}
</Cell> </Cell>
</> </>
) : ( ) : (
+1
View File
@@ -38,6 +38,7 @@ const deviceIconLookup: Record<DeviceType, IconType | null> = {
[DeviceType.CUSTOM]: MdPlaylistAdd, [DeviceType.CUSTOM]: MdPlaylistAdd,
[DeviceType.UNKNOWN]: MdOutlineSensors, [DeviceType.UNKNOWN]: MdOutlineSensors,
[DeviceType.SYSTEM]: null, [DeviceType.SYSTEM]: null,
[DeviceType.COMMAND]: MdPlaylistAdd,
[DeviceType.SCHEDULER]: MdMoreTime, [DeviceType.SCHEDULER]: MdMoreTime,
[DeviceType.GENERIC]: MdOutlineSensors, [DeviceType.GENERIC]: MdOutlineSensors,
[DeviceType.VENTILATION]: PiFan [DeviceType.VENTILATION]: PiFan
+112 -122
View File
@@ -4,12 +4,10 @@ import {
useContext, useContext,
useEffect, useEffect,
useLayoutEffect, useLayoutEffect,
useMemo,
useState useState
} from 'react'; } from 'react';
import { IconContext } from 'react-icons'; import { IconContext } from 'react-icons';
import { Link, useNavigate } from 'react-router'; import { Link, useNavigate } from 'react-router';
import { toast } from 'react-toastify';
import CommentsDisabledOutlinedIcon from '@mui/icons-material/CommentsDisabledOutlined'; import CommentsDisabledOutlinedIcon from '@mui/icons-material/CommentsDisabledOutlined';
import ConstructionIcon from '@mui/icons-material/Construction'; import ConstructionIcon from '@mui/icons-material/Construction';
@@ -65,6 +63,7 @@ import {
SectionContent, SectionContent,
useLayoutTitle useLayoutTitle
} from 'components'; } from 'components';
import { toast } from 'components/toast';
import { AuthenticatedContext } from 'contexts/authentication'; import { AuthenticatedContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import { useInterval } from 'utils'; import { useInterval } from 'utils';
@@ -133,21 +132,19 @@ const Devices = memo(() => {
}; };
}, []); }, []);
const leftOffset = useCallback(() => { const leftOffset = () => {
const devicesWindow = document.getElementById('devices-window'); const devicesWindow = document.getElementById('devices-window');
if (!devicesWindow) return 0; if (!devicesWindow) return 0;
const { left, right } = devicesWindow.getBoundingClientRect(); const { left, right } = devicesWindow.getBoundingClientRect();
if (!left || !right) return 0; if (!left || !right) return 0;
return left + (right - left < 400 ? 0 : 200); return left + (right - left < 400 ? 0 : 200);
}, []); };
const common_theme = useMemo( const common_theme = useTheme({
() => BaseRow: `
useTheme({
BaseRow: `
font-size: 14px; font-size: 14px;
`, `,
HeaderRow: ` HeaderRow: `
text-transform: uppercase; text-transform: uppercase;
background-color: black; background-color: black;
color: #90CAF9; color: #90CAF9;
@@ -155,7 +152,7 @@ const Devices = memo(() => {
border-bottom: 1px solid #565656; border-bottom: 1px solid #565656;
} }
`, `,
Row: ` Row: `
cursor: pointer; cursor: pointer;
background-color: #1E1E1E; background-color: #1E1E1E;
.td { .td {
@@ -165,88 +162,78 @@ const Devices = memo(() => {
background-color: #177ac9; background-color: #177ac9;
} }
` `
}), });
[]
);
const device_theme = useMemo( const device_theme = useTheme([
() => common_theme,
useTheme([ {
common_theme, BaseRow: `
{ font-size: 15px;
BaseRow: ` .td {
font-size: 15px; height: 28px;
.td {
height: 28px;
}
`,
Table: `
--data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) 130px;
`,
HeaderRow: `
.th {
padding: 8px;
`,
Row: `
&:nth-of-type(odd) .td {
background-color: #303030;
},
&:hover .td {
background-color: #177ac9;
},
`
}
]),
[common_theme]
);
const data_theme = useMemo(
() =>
useTheme([
common_theme,
{
Table: `
--data-table-library_grid-template-columns: minmax(200px, auto) minmax(150px, auto) 40px;
height: auto;
max-height: 100%;
overflow-y: scroll;
::-webkit-scrollbar {
display:none;
} }
`, `,
BaseRow: ` Table: `
.td { --data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) 130px;
height: 32px; `,
} HeaderRow: `
`, .th {
BaseCell: ` padding: 8px;
&:nth-of-type(1) { `,
border-left: 1px solid #177ac9; Row: `
}, &:nth-of-type(odd) .td {
&:nth-of-type(2) {
text-align: right;
},
&:nth-of-type(3) {
border-right: 1px solid #177ac9;
}
`,
HeaderRow: `
.th {
border-top: 1px solid #565656;
}
`,
Row: `
&:nth-of-type(odd) .td {
background-color: #303030; background-color: #303030;
}, },
&:hover .td { &:hover .td {
background-color: #177ac9; background-color: #177ac9;
},
`
}
]);
const data_theme = useTheme([
common_theme,
{
Table: `
--data-table-library_grid-template-columns: minmax(200px, auto) minmax(150px, auto) 40px;
height: auto;
max-height: 100%;
overflow-y: scroll;
::-webkit-scrollbar {
display:none;
} }
` `,
} BaseRow: `
]), .td {
[common_theme] height: 32px;
); }
`,
BaseCell: `
&:nth-of-type(1) {
border-left: 1px solid #177ac9;
},
&:nth-of-type(2) {
text-align: right;
},
&:nth-of-type(3) {
border-right: 1px solid #177ac9;
}
`,
HeaderRow: `
.th {
border-top: 1px solid #565656;
}
`,
Row: `
&:nth-of-type(odd) .td {
background-color: #303030;
},
&:hover .td {
background-color: #177ac9;
}
`
}
]);
const getSortIcon = (state: State, sortKey: unknown) => { const getSortIcon = (state: State, sortKey: unknown) => {
if (state.sortKey === sortKey && state.reverse) { if (state.sortKey === sortKey && state.reverse) {
@@ -345,10 +332,8 @@ const Devices = memo(() => {
return sc; return sc;
}; };
const hasMask = useCallback( const hasMask = (id: string, mask: number) =>
(id: string, mask: number) => (parseInt(id.slice(0, 2), 16) & mask) === mask, (parseInt(id.slice(0, 2), 16) & mask) === mask;
[]
);
const handleDownloadCsv = () => { const handleDownloadCsv = () => {
const deviceIndex = coreData.devices.findIndex( const deviceIndex = coreData.devices.findIndex(
@@ -573,16 +558,26 @@ const Devices = memo(() => {
<CircularProgress sx={{ margin: 1 }} size={18} /> <CircularProgress sx={{ margin: 1 }} size={18} />
)} )}
{tableList.map((device: Device) => ( {tableList.map((device: Device) => (
<Row key={device.id} item={device}> <Row key={device.id} item={device} disabled={device.e === 0}>
<Cell> <Cell>
<DeviceIcon type_id={device.t} /> <DeviceIcon type_id={device.t} />
&nbsp;&nbsp; &nbsp;&nbsp;
{device.n} <span style={{ color: device.e === 0 ? 'grey' : 'white' }}>
<span style={{ color: 'lightblue' }}> {device.n}
&nbsp;&nbsp;({device.e})
</span> </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>
<Cell stiff>{device.tn}</Cell>
</Row> </Row>
))} ))}
</Body> </Body>
@@ -607,41 +602,35 @@ const Devices = memo(() => {
return; return;
} }
const showDeviceValue = useCallback((dv: DeviceValue) => { const showDeviceValue = (dv: DeviceValue) => {
setSelectedDeviceValue(dv); setSelectedDeviceValue(dv);
setDeviceValueDialogOpen(true); setDeviceValueDialogOpen(true);
}, []); };
const renderNameCell = useCallback( const renderNameCell = (dv: DeviceValue) => (
(dv: DeviceValue) => ( <>
<> {dv.id.slice(2)}&nbsp;
{dv.id.slice(2)}&nbsp; {hasMask(dv.id, DeviceEntityMask.DV_FAVORITE) && (
{hasMask(dv.id, DeviceEntityMask.DV_FAVORITE) && ( <StarIcon color="primary" sx={{ fontSize: 12 }} />
<StarIcon color="primary" sx={{ fontSize: 12 }} /> )}
)} {hasMask(dv.id, DeviceEntityMask.DV_READONLY) && (
{hasMask(dv.id, DeviceEntityMask.DV_READONLY) && ( <EditOffOutlinedIcon color="primary" sx={{ fontSize: 12 }} />
<EditOffOutlinedIcon color="primary" sx={{ fontSize: 12 }} /> )}
)} {hasMask(dv.id, DeviceEntityMask.DV_API_MQTT_EXCLUDE) && (
{hasMask(dv.id, DeviceEntityMask.DV_API_MQTT_EXCLUDE) && ( <CommentsDisabledOutlinedIcon color="primary" sx={{ fontSize: 12 }} />
<CommentsDisabledOutlinedIcon color="primary" sx={{ fontSize: 12 }} /> )}
)} </>
</>
),
[hasMask]
); );
const shown_data = useMemo(() => { const shown_data = onlyFav
if (onlyFav) { ? deviceData.nodes.filter(
return deviceData.nodes.filter(
(dv: DeviceValue) => (dv: DeviceValue) =>
hasMask(dv.id, DeviceEntityMask.DV_FAVORITE) && hasMask(dv.id, DeviceEntityMask.DV_FAVORITE) &&
dv.id.slice(2).toLowerCase().includes(search.toLowerCase()) dv.id.slice(2).toLowerCase().includes(search.toLowerCase())
)
: deviceData.nodes.filter((dv: DeviceValue) =>
dv.id.slice(2).toLowerCase().includes(search.toLowerCase())
); );
}
return deviceData.nodes.filter((dv: DeviceValue) =>
dv.id.slice(2).toLowerCase().includes(search.toLowerCase())
);
}, [deviceData.nodes, onlyFav, search]);
const deviceIndex = coreData.devices.findIndex( const deviceIndex = coreData.devices.findIndex(
(d: Device) => d.id === device_select.state.id (d: Device) => d.id === device_select.state.id
@@ -798,6 +787,7 @@ const Devices = memo(() => {
<IconButton <IconButton
size="small" size="small"
onClick={() => showDeviceValue(dv)} onClick={() => showDeviceValue(dv)}
style={{ backgroundColor: 'transparent' }}
> >
{dv.v === '' ? ( {dv.v === '' ? (
<PlayArrowIcon color="primary" sx={{ fontSize: 16 }} /> <PlayArrowIcon color="primary" sx={{ fontSize: 16 }} />
+122 -107
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 CancelIcon from '@mui/icons-material/Cancel';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import WarningIcon from '@mui/icons-material/Warning'; import WarningIcon from '@mui/icons-material/Warning';
import { import {
Box, Box,
@@ -18,13 +19,16 @@ import {
Typography Typography
} from '@mui/material'; } from '@mui/material';
import { callAction } from '@/api/app';
import { dialogStyle } from 'CustomTheme'; import { dialogStyle } from 'CustomTheme';
import type Schema from 'async-validator'; import { useRequest } from 'alova/client';
import type { ValidateFieldsError } from 'async-validator';
import { ValidatedTextField } from 'components'; import { ValidatedTextField } from 'components';
import { toast } from 'components/toast';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import { numberValue, updateValue } from 'utils'; import { numberValue, updateValue } from 'utils';
import { ValidationError, validate } from 'validators'; import { ValidationError, validate } from 'validators';
import type Schema from 'validators/schema';
import type { ValidateFieldsError } from 'validators/schema';
import { DeviceValueUOM, DeviceValueUOM_s } from './types'; import { DeviceValueUOM, DeviceValueUOM_s } from './types';
import type { DeviceValue } from './types'; import type { DeviceValue } from './types';
@@ -52,7 +56,7 @@ const DevicesDialog = ({
const [editItem, setEditItem] = useState<DeviceValue>(selectedItem); const [editItem, setEditItem] = useState<DeviceValue>(selectedItem);
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>(); const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const updateFormValue = useMemo(() => updateValue(setEditItem), [setEditItem]); const updateFormValue = updateValue(setEditItem);
useEffect(() => { useEffect(() => {
if (open) { if (open) {
@@ -61,36 +65,48 @@ const DevicesDialog = ({
} }
}, [open, selectedItem]); }, [open, selectedItem]);
const save = useCallback(async () => { const { send: executeCommand } = useRequest(
(id: string) => callAction({ action: 'executeCommand', param: id }),
{ immediate: false }
)
.onSuccess(() => {
toast.success(LL.EXECUTE_COMMAND_SENT());
})
.onError((error) => {
toast.error(String(error.error?.message || 'An error occurred'));
});
const doAction = async () => {
try { try {
setFieldErrors(undefined); setFieldErrors(undefined);
await validate(validator, editItem); if (editItem.v === undefined && editItem.c !== undefined) {
await executeCommand(editItem.c);
} else {
await validate(validator, editItem);
}
onSave(editItem); onSave(editItem);
} catch (error) { } catch (error) {
setFieldErrors((error as ValidationError).fieldErrors); setFieldErrors((error as ValidationError).fieldErrors);
} }
}, [validator, editItem, onSave]); };
const setUom = useCallback( const setUom = (uom?: DeviceValueUOM) => {
(uom?: DeviceValueUOM) => { if (uom === undefined) {
if (uom === undefined) { return;
return; }
} switch (uom) {
switch (uom) { case DeviceValueUOM.HOURS:
case DeviceValueUOM.HOURS: return LL.HOURS();
return LL.HOURS(); case DeviceValueUOM.MINUTES:
case DeviceValueUOM.MINUTES: return LL.MINUTES();
return LL.MINUTES(); case DeviceValueUOM.SECONDS:
case DeviceValueUOM.SECONDS: return LL.SECONDS();
return LL.SECONDS(); default:
default: return DeviceValueUOM_s[uom];
return DeviceValueUOM_s[uom]; }
} };
},
[LL]
);
const showHelperText = useCallback((dv: DeviceValue) => { const showHelperText = (dv: DeviceValue) => {
if (dv.h) return dv.h; if (dv.h) return dv.h;
if (dv.l) return dv.l.join(' | '); if (dv.l) return dv.l.join(' | ');
if (dv.m !== undefined && dv.x !== undefined) { if (dv.m !== undefined && dv.x !== undefined) {
@@ -101,26 +117,21 @@ const DevicesDialog = ({
); );
} }
return undefined; return undefined;
}, []); };
const isCommand = useMemo( const isCommand =
() => selectedItem.v === '' && selectedItem.c, (selectedItem.v === '' || selectedItem.v === undefined) &&
[selectedItem.v, selectedItem.c] Boolean(selectedItem.c);
); const isSchedulerImmediate = selectedItem.v === undefined;
const dialogTitle = isCommand
const dialogTitle = useMemo(() => { ? isSchedulerImmediate
if (isCommand) return LL.RUN_COMMAND(); ? LL.EXECUTE() + ' ' + LL.SCHEDULE(0)
return writeable ? LL.CHANGE_VALUE() : LL.VALUE(0); : LL.RUN_COMMAND()
}, [isCommand, writeable, LL]); : writeable
? LL.CHANGE_VALUE()
const buttonLabel = useMemo(() => { : LL.VALUE(0);
return isCommand ? LL.EXECUTE() : LL.UPDATE(); const buttonLabel = isCommand ? LL.EXECUTE() : LL.UPDATE();
}, [isCommand, LL]); const helperText = showHelperText(editItem);
const helperText = useMemo(
() => showHelperText(editItem),
[editItem, showHelperText]
);
const valueLabel = LL.VALUE(0); const valueLabel = LL.VALUE(0);
@@ -131,67 +142,69 @@ const DevicesDialog = ({
<Typography sx={{ mb: 2 }} color="warning" variant="body2"> <Typography sx={{ mb: 2 }} color="warning" variant="body2">
{editItem.id.slice(2)} {editItem.id.slice(2)}
</Typography> </Typography>
<Grid container> {!isSchedulerImmediate && (
<Grid size={12}> <Grid container>
{editItem.l ? ( <Grid size={12}>
<TextField {editItem.l ? (
name="v" <TextField
value={editItem.v} name="v"
aria-label={valueLabel} value={editItem.v}
disabled={!writeable} aria-label={valueLabel}
sx={{ width: '30ch' }} disabled={!writeable}
select sx={{ width: '30ch' }}
onChange={updateFormValue} select
> onChange={updateFormValue}
{editItem.l.map((val) => ( >
<MenuItem value={val} key={val}> {editItem.l.map((val) => (
{val} <MenuItem value={val} key={val}>
</MenuItem> {val}
))} </MenuItem>
</TextField> ))}
) : editItem.s || editItem.u !== DeviceValueUOM.NONE ? ( </TextField>
<ValidatedTextField ) : editItem.s || editItem.u !== DeviceValueUOM.NONE ? (
fieldErrors={fieldErrors || {}} <ValidatedTextField
name="v" fieldErrors={fieldErrors || {}}
label={valueLabel} name="v"
value={numberValue(Math.round((editItem.v as number) * 10) / 10)} label={valueLabel}
autoFocus value={numberValue(Math.round((editItem.v as number) * 10) / 10)}
disabled={!writeable} autoFocus
type="number" disabled={!writeable}
sx={{ width: '30ch' }} type="number"
onChange={updateFormValue} sx={{ width: '30ch' }}
slotProps={{ onChange={updateFormValue}
htmlInput: editItem.s slotProps={{
? { min: editItem.m, max: editItem.x, step: editItem.s } htmlInput: editItem.s
: {}, ? { min: editItem.m, max: editItem.x, step: editItem.s }
input: { : {},
startAdornment: ( input: {
<InputAdornment position="start"> startAdornment: (
{setUom(editItem.u)} <InputAdornment position="start">
</InputAdornment> {setUom(editItem.u)}
) </InputAdornment>
} )
}} }
/> }}
) : ( />
<ValidatedTextField ) : (
fieldErrors={fieldErrors || {}} <ValidatedTextField
name="v" fieldErrors={fieldErrors || {}}
label={valueLabel} name="v"
value={editItem.v} label={valueLabel}
disabled={!writeable} value={editItem.v}
sx={{ width: '30ch' }} disabled={!writeable}
multiline={!editItem.u} sx={{ width: '30ch' }}
onChange={updateFormValue} multiline={!editItem.u}
/> onChange={updateFormValue}
/>
)}
</Grid>
{writeable && helperText && (
<Grid>
<FormHelperText>{helperText}</FormHelperText>
</Grid>
)} )}
</Grid> </Grid>
{writeable && helperText && ( )}
<Grid>
<FormHelperText>{helperText}</FormHelperText>
</Grid>
)}
</Grid>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
@@ -213,10 +226,12 @@ const DevicesDialog = ({
{LL.CANCEL()} {LL.CANCEL()}
</Button> </Button>
<Button <Button
startIcon={<WarningIcon color="warning" />} startIcon={
isCommand ? <PlayArrowIcon /> : <WarningIcon color="warning" />
}
variant="outlined" variant="outlined"
onClick={save} onClick={doAction}
color="primary" color={isCommand ? 'success' : 'primary'}
> >
{buttonLabel} {buttonLabel}
</Button> </Button>
+67 -98
View File
@@ -1,5 +1,3 @@
import { useCallback, useMemo } from 'react';
import { ToggleButton, ToggleButtonGroup } from '@mui/material'; import { ToggleButton, ToggleButtonGroup } from '@mui/material';
import OptionIcon from './OptionIcon'; import OptionIcon from './OptionIcon';
@@ -11,7 +9,6 @@ interface EntityMaskToggleProps {
de: DeviceEntity; de: DeviceEntity;
} }
// Available mask values
const MASK_VALUES = [ const MASK_VALUES = [
DeviceEntityMask.DV_WEB_EXCLUDE, // 1 DeviceEntityMask.DV_WEB_EXCLUDE, // 1
DeviceEntityMask.DV_API_MQTT_EXCLUDE, // 2 DeviceEntityMask.DV_API_MQTT_EXCLUDE, // 2
@@ -20,123 +17,95 @@ const MASK_VALUES = [
DeviceEntityMask.DV_DELETED // 128 DeviceEntityMask.DV_DELETED // 128
]; ];
/** const getMaskNumber = (newMask: string[]): number =>
* Converts an array of mask strings to a bitmask number newMask.reduce((mask, entry) => mask | Number(entry), 0);
*/
const getMaskNumber = (newMask: string[]): number => {
return newMask.reduce((mask, entry) => mask | Number(entry), 0);
};
/** const getMaskString = (mask: number): string[] =>
* Converts a bitmask number to an array of mask strings MASK_VALUES.filter((value) => (mask & value) === value).map((value) =>
*/
const getMaskString = (mask: number): string[] => {
return MASK_VALUES.filter((value) => (mask & value) === value).map((value) =>
String(value) String(value)
); );
};
/**
* Checks if a specific mask bit is set
*/
const hasMask = (mask: number, flag: number): boolean => (mask & flag) === flag; const hasMask = (mask: number, flag: number): boolean => (mask & flag) === flag;
const EntityMaskToggle = ({ onUpdate, de }: EntityMaskToggleProps) => { const EntityMaskToggle = ({ onUpdate, de }: EntityMaskToggleProps) => {
const handleChange = useCallback( const handleChange = (_event: unknown, mask: string[]) => {
(_event: unknown, mask: string[]) => { const newMask = getMaskNumber(mask);
// Convert selected masks to a number const updatedDe = { ...de };
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 entity has no name and is set to readonly, also exclude from web if (updatedDe.n === '' && hasMask(newMask, DeviceEntityMask.DV_READONLY)) {
if (updatedDe.n === '' && hasMask(newMask, DeviceEntityMask.DV_READONLY)) { updatedDe.m = newMask | DeviceEntityMask.DV_WEB_EXCLUDE;
updatedDe.m = newMask | DeviceEntityMask.DV_WEB_EXCLUDE; } else {
} else { updatedDe.m = newMask;
updatedDe.m = newMask; }
}
// If excluded from web, cannot be favorite // If excluded from web, cannot be favorite
if (hasMask(updatedDe.m, DeviceEntityMask.DV_WEB_EXCLUDE)) { if (hasMask(updatedDe.m, DeviceEntityMask.DV_WEB_EXCLUDE)) {
updatedDe.m = updatedDe.m & ~DeviceEntityMask.DV_FAVORITE; updatedDe.m = updatedDe.m & ~DeviceEntityMask.DV_FAVORITE;
} }
onUpdate(updatedDe); onUpdate(updatedDe);
}, };
[de, onUpdate]
);
// Memoize mask string value
const maskStringValue = useMemo(() => getMaskString(de.m), [de.m]);
// Memoize disabled states
const isFavoriteDisabled = useMemo(
() =>
hasMask(de.m, DeviceEntityMask.DV_WEB_EXCLUDE | DeviceEntityMask.DV_DELETED) ||
de.n === undefined,
[de.m, de.n]
);
const isReadonlyDisabled = useMemo(
() =>
!de.w ||
hasMask(de.m, DeviceEntityMask.DV_WEB_EXCLUDE | DeviceEntityMask.DV_FAVORITE),
[de.w, de.m]
);
const isApiMqttExcludeDisabled = useMemo(
() => de.n === '' || hasMask(de.m, DeviceEntityMask.DV_DELETED),
[de.n, de.m]
);
const isWebExcludeDisabled = useMemo(
() => de.n === undefined || hasMask(de.m, DeviceEntityMask.DV_DELETED),
[de.n, de.m]
);
// Memoize mask flag checks
const isFavoriteSet = useMemo(
() => hasMask(de.m, DeviceEntityMask.DV_FAVORITE),
[de.m]
);
const isReadonlySet = useMemo(
() => hasMask(de.m, DeviceEntityMask.DV_READONLY),
[de.m]
);
const isApiMqttExcludeSet = useMemo(
() => hasMask(de.m, DeviceEntityMask.DV_API_MQTT_EXCLUDE),
[de.m]
);
const isWebExcludeSet = useMemo(
() => hasMask(de.m, DeviceEntityMask.DV_WEB_EXCLUDE),
[de.m]
);
const isDeletedSet = useMemo(
() => hasMask(de.m, DeviceEntityMask.DV_DELETED),
[de.m]
);
return ( return (
<ToggleButtonGroup <ToggleButtonGroup
size="small" size="small"
color="secondary" color="secondary"
value={maskStringValue} value={getMaskString(de.m)}
onChange={handleChange} onChange={handleChange}
> >
<ToggleButton value="8" disabled={isFavoriteDisabled}> <ToggleButton
<OptionIcon type="favorite" isSet={isFavoriteSet} /> 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>
<ToggleButton value="4" disabled={isReadonlyDisabled}> <ToggleButton
<OptionIcon type="readonly" isSet={isReadonlySet} /> 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>
<ToggleButton value="2" disabled={isApiMqttExcludeDisabled}> <ToggleButton
<OptionIcon type="api_mqtt_exclude" isSet={isApiMqttExcludeSet} /> 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>
<ToggleButton value="1" disabled={isWebExcludeDisabled}> <ToggleButton
<OptionIcon type="web_exclude" isSet={isWebExcludeSet} /> 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>
<ToggleButton value="128"> <ToggleButton value="128">
<OptionIcon type="deleted" isSet={isDeletedSet} /> <OptionIcon
type="deleted"
isSet={hasMask(de.m, DeviceEntityMask.DV_DELETED)}
/>
</ToggleButton> </ToggleButton>
</ToggleButtonGroup> </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 type { ReactElement } from 'react';
import { toast } from 'react-toastify';
import CommentIcon from '@mui/icons-material/CommentTwoTone'; import CommentIcon from '@mui/icons-material/CommentTwoTone';
import DownloadIcon from '@mui/icons-material/GetApp'; 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 { useRequest } from 'alova/client';
import { SectionContent, useLayoutTitle } from 'components'; import { SectionContent, useLayoutTitle } from 'components';
import { toast } from 'components/toast';
import { AuthenticatedContext } from 'contexts/authentication'; import { AuthenticatedContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import { saveFile } from 'utils'; import { saveFile } from 'utils';
@@ -60,6 +60,8 @@ const AVATAR_STYLES: SxProps<Theme> = {
bgcolor: '#72caf9' bgcolor: '#72caf9'
}; };
const SYSTEM_INFO_API: APIcall = { device: 'system', cmd: 'info', id: 0 };
const HelpComponent = () => { const HelpComponent = () => {
const { LL } = useI18nContext(); const { LL } = useI18nContext();
useLayoutTitle(LL.HELP()); useLayoutTitle(LL.HELP());
@@ -72,12 +74,7 @@ const HelpComponent = () => {
}); });
const [imgError, setImgError] = useState<boolean>(false); const [imgError, setImgError] = useState<boolean>(false);
const getCustomSupportMethod = useMemo( useRequest(callAction({ action: 'getCustomSupport' })).onSuccess((event) => {
() => callAction({ action: 'getCustomSupport' }),
[]
);
useRequest(getCustomSupportMethod).onSuccess((event) => {
if (event?.data && Object.keys(event.data).length !== 0) { if (event?.data && Object.keys(event.data).length !== 0) {
const { Support } = event.data as { const { Support } = event.data as {
Support: { img_url?: string; html?: string[] }; Support: { img_url?: string; html?: string[] };
@@ -100,47 +97,26 @@ const HelpComponent = () => {
toast.error(String(error.error?.message || 'An error occurred')); toast.error(String(error.error?.message || 'An error occurred'));
}); });
// Optimize API call memoization const helpLinks: HelpLink[] = [
const apiCall = useMemo(() => ({ device: 'system', cmd: 'info', id: 0 }), []); {
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(() => { const imageSrc =
void sendAPI(apiCall); imgError || !customSupport.img_url ? DEFAULT_IMAGE_URL : customSupport.img_url;
}, [sendAPI, apiCall]);
const handleImageError = useCallback(() => {
setImgError(true);
}, []);
// Memoize help links to prevent recreation on every render
const helpLinks: HelpLink[] = useMemo(
() => [
{
href: 'https://emsesp.org',
icon: <MenuBookIcon />,
label: () => LL.HELP_INFORMATION_1()
},
{
href: 'https://discord.gg/GP9DPSgeJq',
icon: <CommentIcon />,
label: () => LL.HELP_INFORMATION_2()
},
{
href: 'https://github.com/emsesp/EMS-ESP32/issues/new/choose',
icon: <GitHubIcon />,
label: () => LL.HELP_INFORMATION_3()
}
],
[LL]
);
const isAdmin = useMemo(() => me?.admin ?? false, [me?.admin]);
// Memoize image source computation
const imageSrc = useMemo(
() =>
imgError || !customSupport.img_url ? DEFAULT_IMAGE_URL : customSupport.img_url,
[imgError, customSupport.img_url]
);
return ( return (
<SectionContent> <SectionContent>
@@ -157,13 +133,13 @@ const HelpComponent = () => {
component="img" component="img"
referrerPolicy="no-referrer" referrerPolicy="no-referrer"
sx={IMAGE_STYLES} sx={IMAGE_STYLES}
onError={handleImageError} onError={() => setImgError(true)}
src={imageSrc} src={imageSrc}
/> />
</Stack> </Stack>
)} )}
{isAdmin && ( {me?.admin && (
<List> <List>
{helpLinks.map(({ href, icon, label }) => ( {helpLinks.map(({ href, icon, label }) => (
<ListItem key={href}> <ListItem key={href}>
@@ -191,7 +167,7 @@ const HelpComponent = () => {
startIcon={<DownloadIcon />} startIcon={<DownloadIcon />}
variant="outlined" variant="outlined"
color="primary" color="primary"
onClick={handleDownloadSystemInfo} onClick={() => void sendAPI(SYSTEM_INFO_API)}
> >
{LL.SUPPORT_INFORMATION(0)} {LL.SUPPORT_INFORMATION(0)}
</Button> </Button>
@@ -214,7 +190,6 @@ const HelpComponent = () => {
); );
}; };
// Memoize the component to prevent unnecessary re-renders
const Help = memo(HelpComponent); const Help = memo(HelpComponent);
export default Help; 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 { useBlocker } from 'react-router';
import { toast } from 'react-toastify';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
import CircleIcon from '@mui/icons-material/Circle'; import CircleIcon from '@mui/icons-material/Circle';
@@ -25,6 +24,7 @@ import {
SectionContent, SectionContent,
useLayoutTitle useLayoutTitle
} from 'components'; } from 'components';
import { toast } from 'components/toast';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import { readModules, writeModules } from '../../api/app'; import { readModules, writeModules } from '../../api/app';
@@ -69,58 +69,53 @@ const Modules = () => {
} }
); );
const modules_theme = useTheme( const modules_theme = useTheme({
useMemo( Table: `
() => ({ --data-table-library_grid-template-columns: 48px 180px 120px 100px repeat(1, minmax(160px, 1fr)) 180px;
Table: ` `,
--data-table-library_grid-template-columns: 48px 180px 120px 100px repeat(1, minmax(160px, 1fr)) 180px; BaseRow: `
`, font-size: 14px;
BaseRow: ` .td {
font-size: 14px; height: 32px;
.td { }
height: 32px; `,
} BaseCell: `
`, &:nth-of-type(1) {
BaseCell: ` text-align: center;
&:nth-of-type(1) { }
text-align: center; `,
} HeaderRow: `
`, text-transform: uppercase;
HeaderRow: ` background-color: black;
text-transform: uppercase; color: #90CAF9;
background-color: black; .th {
color: #90CAF9; border-bottom: 1px solid #565656;
.th { height: 36px;
border-bottom: 1px solid #565656; }
height: 36px; `,
} Row: `
`, background-color: #1e1e1e;
Row: ` position: relative;
background-color: #1e1e1e; cursor: pointer;
position: relative; .td {
cursor: pointer; border-top: 1px solid #565656;
.td { border-bottom: 1px solid #565656;
border-top: 1px solid #565656; }
border-bottom: 1px solid #565656; &:hover .td {
} border-top: 1px solid #177ac9;
&:hover .td { border-bottom: 1px solid #177ac9;
border-top: 1px solid #177ac9; }
border-bottom: 1px solid #177ac9; &:nth-of-type(odd) .td {
} background-color: #303030;
&:nth-of-type(odd) .td { }
background-color: #303030; `
} });
`
}),
[]
)
);
const onDialogClose = useCallback(() => { const onDialogClose = () => {
setDialogOpen(false); setDialogOpen(false);
}, []); };
const updateModuleItem = useCallback((updatedItem: ModuleItem) => { const updateModuleItem = (updatedItem: ModuleItem) => {
void updateState(readModules(), (data: ModuleItem[]) => { void updateState(readModules(), (data: ModuleItem[]) => {
const new_data = data.map((mi) => const new_data = data.map((mi) =>
mi.id === updatedItem.id ? { ...mi, ...updatedItem } : mi mi.id === updatedItem.id ? { ...mi, ...updatedItem } : mi
@@ -128,28 +123,25 @@ const Modules = () => {
setNumChanges(new_data.filter(hasModulesChanged).length); setNumChanges(new_data.filter(hasModulesChanged).length);
return new_data; return new_data;
}); });
}, []); };
const onDialogSave = useCallback( const onDialogSave = (updatedItem: ModuleItem) => {
(updatedItem: ModuleItem) => { setDialogOpen(false);
setDialogOpen(false); updateModuleItem(updatedItem);
updateModuleItem(updatedItem); };
},
[updateModuleItem]
);
const editModuleItem = useCallback((mi: ModuleItem) => { const editModuleItem = (mi: ModuleItem) => {
setSelectedModuleItem(mi); setSelectedModuleItem(mi);
setDialogOpen(true); setDialogOpen(true);
}, []); };
const onCancel = useCallback(async () => { const onCancel = async () => {
await fetchModules().then(() => { await fetchModules().then(() => {
setNumChanges(0); setNumChanges(0);
}); });
}, [fetchModules]); };
const saveModules = useCallback(async () => { const saveModules = async () => {
try { try {
await Promise.all( await Promise.all(
modules.map((condensed_mi: ModuleItem) => modules.map((condensed_mi: ModuleItem) =>
@@ -167,9 +159,9 @@ const Modules = () => {
await fetchModules(); await fetchModules();
setNumChanges(0); setNumChanges(0);
} }
}, [modules, updateModules, LL, fetchModules]); };
const content = useMemo(() => { const renderContent = () => {
if (!modules) { if (!modules) {
return ( return (
<FormLoader onRetry={fetchModules} errorMessage={error?.message || ''} /> <FormLoader onRetry={fetchModules} errorMessage={error?.message || ''} />
@@ -262,22 +254,12 @@ const Modules = () => {
</Box> </Box>
</> </>
); );
}, [ };
modules,
fetchModules,
error,
modules_theme,
editModuleItem,
LL,
numChanges,
onCancel,
saveModules
]);
return ( return (
<SectionContent> <SectionContent>
{blocker ? <BlockNavigation blocker={blocker} /> : null} {blocker ? <BlockNavigation blocker={blocker} /> : null}
{content} {renderContent()}
{selectedModuleItem && ( {selectedModuleItem && (
<ModulesDialog <ModulesDialog
open={dialogOpen} open={dialogOpen}
+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 CancelIcon from '@mui/icons-material/Cancel';
import DoneIcon from '@mui/icons-material/Done'; import DoneIcon from '@mui/icons-material/Done';
@@ -37,14 +37,10 @@ const ModulesDialog = ({
const { LL } = useI18nContext(); const { LL } = useI18nContext();
const [editItem, setEditItem] = useState<ModuleItem>(selectedItem); const [editItem, setEditItem] = useState<ModuleItem>(selectedItem);
const updateFormValue = useMemo( const updateFormValue = updateValue(
() => setEditItem as unknown as React.Dispatch<
updateValue( React.SetStateAction<Record<string, unknown>>
setEditItem as unknown as React.Dispatch< >
React.SetStateAction<Record<string, unknown>>
>
),
[]
); );
// Sync form state when dialog opens or selected item changes // Sync form state when dialog opens or selected item changes
@@ -54,18 +50,13 @@ const ModulesDialog = ({
} }
}, [open, selectedItem]); }, [open, selectedItem]);
const handleSave = useCallback(() => { const handleSave = () => {
onSave(editItem); onSave(editItem);
}, [editItem, onSave]); };
const dialogTitle = useMemo(
() => `${LL.EDIT()} ${editItem.key}`,
[LL, editItem.key]
);
return ( return (
<Dialog sx={dialogStyle} fullWidth maxWidth="xs" open={open} onClose={onClose}> <Dialog sx={dialogStyle} fullWidth maxWidth="xs" open={open} onClose={onClose}>
<DialogTitle>{dialogTitle}</DialogTitle> <DialogTitle>{`${LL.EDIT()} ${editItem.key}`}</DialogTitle>
<DialogContent dividers> <DialogContent dividers>
<Grid container> <Grid container>
<BlockFormControlLabel <BlockFormControlLabel
+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 { useBlocker } from 'react-router';
import { toast } from 'react-toastify';
import AddIcon from '@mui/icons-material/Add'; import AddIcon from '@mui/icons-material/Add';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
@@ -26,10 +25,11 @@ import {
SectionContent, SectionContent,
useLayoutTitle useLayoutTitle
} from 'components'; } from 'components';
import { toast } from 'components/toast';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import { useInterval } from 'utils'; import { useInterval } from 'utils';
import { readSchedule, writeSchedule } from '../../api/app'; import { readCommands, readSchedule, writeSchedule } from '../../api/app';
import SettingsSchedulerDialog from './SchedulerDialog'; import SettingsSchedulerDialog from './SchedulerDialog';
import { ScheduleFlag } from './types'; import { ScheduleFlag } from './types';
import type { Schedule, ScheduleItem } from './types'; import type { Schedule, ScheduleItem } from './types';
@@ -54,14 +54,13 @@ const DEFAULT_SCHEDULE_ITEM: Omit<ScheduleItem, 'id' | 'o_id'> = {
deleted: false, deleted: false,
flags: FLAG_ALL_DAYS, flags: FLAG_ALL_DAYS,
time: '', time: '',
cmd: '', cmd_name: '',
value: '',
name: '' name: ''
}; };
const scheduleTheme = { const scheduleTheme = {
Table: ` 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: ` BaseRow: `
font-size: 14px; font-size: 14px;
@@ -70,11 +69,8 @@ const scheduleTheme = {
} }
`, `,
BaseCell: ` BaseCell: `
&:nth-of-type(2) {
text-align: center;
}
&:nth-of-type(1) { &:nth-of-type(1) {
text-align: center; text-align: 8px;
} }
`, `,
HeaderRow: ` HeaderRow: `
@@ -100,7 +96,6 @@ const scheduleTheme = {
}; };
const scheduleTypeLabels: Record<number, string> = { const scheduleTypeLabels: Record<number, string> = {
[ScheduleFlag.SCHEDULE_IMMEDIATE]: 'Immediate',
[ScheduleFlag.SCHEDULE_TIMER]: 'Timer', [ScheduleFlag.SCHEDULE_TIMER]: 'Timer',
[ScheduleFlag.SCHEDULE_CONDITION]: 'Condition', [ScheduleFlag.SCHEDULE_CONDITION]: 'Condition',
[ScheduleFlag.SCHEDULE_ONCHANGE]: 'On Change' [ScheduleFlag.SCHEDULE_ONCHANGE]: 'On Change'
@@ -125,6 +120,11 @@ const Scheduler = () => {
initialData: [] initialData: []
}); });
const { data: commandNames } = useRequest(readCommands, {
initialData: [],
initializing: true
});
const { send: updateSchedule } = useRequest( const { send: updateSchedule } = useRequest(
(data: Schedule) => writeSchedule(data), (data: Schedule) => writeSchedule(data),
{ {
@@ -132,7 +132,7 @@ const Scheduler = () => {
} }
); );
const hasScheduleChanged = useCallback((si: ScheduleItem) => { const hasScheduleChanged = (si: ScheduleItem) => {
return ( return (
si.id !== si.o_id || si.id !== si.o_id ||
(si.name || '') !== (si.o_name || '') || (si.name || '') !== (si.o_name || '') ||
@@ -140,18 +140,15 @@ const Scheduler = () => {
si.deleted !== si.o_deleted || si.deleted !== si.o_deleted ||
si.flags !== si.o_flags || si.flags !== si.o_flags ||
si.time !== si.o_time || si.time !== si.o_time ||
si.cmd !== si.o_cmd || si.cmd_name !== si.o_cmd_name
si.value !== si.o_value
); );
}, []); };
const intervalCallback = useCallback(() => { useInterval(() => {
if (numChanges === 0) { if (numChanges === 0) {
void fetchSchedule(); void fetchSchedule();
} }
}, [numChanges, fetchSchedule]); }, INTERVAL_DELAY);
useInterval(intervalCallback, INTERVAL_DELAY);
useEffect(() => { useEffect(() => {
const formatter = new Intl.DateTimeFormat(locale, { const formatter = new Intl.DateTimeFormat(locale, {
@@ -169,7 +166,7 @@ const Scheduler = () => {
const schedule_theme = useTheme(scheduleTheme); const schedule_theme = useTheme(scheduleTheme);
const saveSchedule = useCallback(async () => { const saveSchedule = async () => {
try { try {
await updateSchedule({ await updateSchedule({
schedule: schedule schedule: schedule
@@ -179,8 +176,7 @@ const Scheduler = () => {
active: condensed_si.active, active: condensed_si.active,
flags: condensed_si.flags, flags: condensed_si.flags,
time: condensed_si.time, time: condensed_si.time,
cmd: condensed_si.cmd, cmd_name: condensed_si.cmd_name,
value: condensed_si.value,
name: condensed_si.name name: condensed_si.name
})) }))
}); });
@@ -192,46 +188,43 @@ const Scheduler = () => {
await fetchSchedule(); await fetchSchedule();
setNumChanges(0); setNumChanges(0);
} }
}, [LL, schedule, updateSchedule, fetchSchedule]); };
const editScheduleItem = useCallback((si: ScheduleItem) => { const editScheduleItem = (si: ScheduleItem) => {
setCreating(false); setCreating(false);
setSelectedScheduleItem(si); setSelectedScheduleItem(si);
setDialogOpen(true); setDialogOpen(true);
if (si.o_name === undefined) { if (si.o_name === undefined) {
si.o_name = si.name; si.o_name = si.name;
} }
}, []); };
const onDialogClose = useCallback(() => { const onDialogClose = () => {
setDialogOpen(false); setDialogOpen(false);
}, []); };
const onDialogCancel = useCallback(async () => { const onDialogCancel = async () => {
await fetchSchedule().then(() => { await fetchSchedule().then(() => {
setNumChanges(0); setNumChanges(0);
}); });
}, [fetchSchedule]); };
const onDialogSave = useCallback( const onDialogSave = (updatedItem: ScheduleItem) => {
(updatedItem: ScheduleItem) => { setDialogOpen(false);
setDialogOpen(false); void updateState(readSchedule(), (data: ScheduleItem[]) => {
void updateState(readSchedule(), (data: ScheduleItem[]) => { const new_data = creating
const new_data = creating ? [...data, updatedItem]
? [...data, updatedItem] : data.map((si) =>
: data.map((si) => si.id === updatedItem.id ? { ...si, ...updatedItem } : si
si.id === updatedItem.id ? { ...si, ...updatedItem } : si );
);
setNumChanges(new_data.filter((si) => hasScheduleChanged(si)).length); setNumChanges(new_data.filter((si) => hasScheduleChanged(si)).length);
return new_data; return new_data;
}); });
}, };
[creating, hasScheduleChanged]
);
const addScheduleItem = useCallback(() => { const addScheduleItem = () => {
setCreating(true); setCreating(true);
const newItem: ScheduleItem = { const newItem: ScheduleItem = {
id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID), id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID),
@@ -239,36 +232,29 @@ const Scheduler = () => {
}; };
setSelectedScheduleItem(newItem); setSelectedScheduleItem(newItem);
setDialogOpen(true); setDialogOpen(true);
}, []); };
const filteredAndSortedSchedule = useMemo( const filteredAndSortedSchedule = schedule
() => .filter((si: ScheduleItem) => !si.deleted)
schedule .sort((a: ScheduleItem, b: ScheduleItem) => a.flags - b.flags);
.filter((si: ScheduleItem) => !si.deleted)
.sort((a: ScheduleItem, b: ScheduleItem) => a.flags - b.flags),
[schedule]
);
const dayBox = useCallback( const dayBox = (si: ScheduleItem, flag: number, isLast = false) => {
(si: ScheduleItem, flag: number) => { const dayIndex = Math.log(flag) / LOG_2;
const dayIndex = Math.log(flag) / LOG_2; const isActive = (si.flags & flag) === flag;
const isActive = (si.flags & flag) === flag;
return ( return (
<> <>
<Box> <Box>
<Typography sx={{ fontSize: 11 }} color={isActive ? 'primary' : 'grey'}> <Typography sx={{ fontSize: 11 }} color={isActive ? 'primary' : 'grey'}>
{dow[dayIndex]} {dow[dayIndex]}
</Typography> </Typography>
</Box> </Box>
<Divider orientation="vertical" flexItem /> {!isLast && <Divider orientation="vertical" flexItem />}
</> </>
); );
}, };
[dow]
);
const scheduleType = useCallback((si: ScheduleItem) => { const scheduleType = (si: ScheduleItem) => {
const label = scheduleTypeLabels[si.flags]; const label = scheduleTypeLabels[si.flags];
return ( return (
@@ -278,9 +264,9 @@ const Scheduler = () => {
</Typography> </Typography>
</Box> </Box>
); );
}, []); };
const renderSchedule = useCallback(() => { const renderSchedule = () => {
if (!schedule) { if (!schedule) {
return ( return (
<FormLoader onRetry={fetchSchedule} errorMessage={error?.message || ''} /> <FormLoader onRetry={fetchSchedule} errorMessage={error?.message || ''} />
@@ -299,9 +285,10 @@ const Scheduler = () => {
<HeaderRow> <HeaderRow>
<HeaderCell /> <HeaderCell />
<HeaderCell stiff>{LL.SCHEDULE(0)}</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.COMMAND(0)}</HeaderCell>
<HeaderCell stiff>{LL.VALUE(0)}</HeaderCell>
<HeaderCell stiff>{LL.NAME(0)}</HeaderCell> <HeaderCell stiff>{LL.NAME(0)}</HeaderCell>
</HeaderRow> </HeaderRow>
</Header> </Header>
@@ -311,12 +298,16 @@ const Scheduler = () => {
<Cell stiff> <Cell stiff>
<CircleIcon <CircleIcon
color={si.active ? 'success' : 'error'} color={si.active ? 'success' : 'error'}
sx={{ fontSize: ICON_SIZE, verticalAlign: 'middle' }} sx={{
fontSize: ICON_SIZE,
ml: '4px',
position: 'relative',
top: '2px'
}}
/> />
</Cell> </Cell>
<Cell stiff> <Cell stiff>
<Stack spacing={0.5} direction="row"> <Stack spacing={0.5} direction="row">
<Divider orientation="vertical" flexItem />
{si.flags > SCHEDULE_FLAG_THRESHOLD ? ( {si.flags > SCHEDULE_FLAG_THRESHOLD ? (
scheduleType(si) scheduleType(si)
) : ( ) : (
@@ -327,14 +318,13 @@ const Scheduler = () => {
{dayBox(si, ScheduleFlag.SCHEDULE_THU)} {dayBox(si, ScheduleFlag.SCHEDULE_THU)}
{dayBox(si, ScheduleFlag.SCHEDULE_FRI)} {dayBox(si, ScheduleFlag.SCHEDULE_FRI)}
{dayBox(si, ScheduleFlag.SCHEDULE_SAT)} {dayBox(si, ScheduleFlag.SCHEDULE_SAT)}
{dayBox(si, ScheduleFlag.SCHEDULE_SUN)} {dayBox(si, ScheduleFlag.SCHEDULE_SUN, true)}
</> </>
)} )}
</Stack> </Stack>
</Cell> </Cell>
<Cell>{si.time}</Cell> <Cell>{si.time === '' ? LL.SCHEDULER_HELP_2() : si.time}</Cell>
<Cell>{si.cmd}</Cell> <Cell>{si.cmd_name}</Cell>
<Cell>{si.value}</Cell>
<Cell>{si.name}</Cell> <Cell>{si.name}</Cell>
</Row> </Row>
))} ))}
@@ -343,17 +333,7 @@ const Scheduler = () => {
)} )}
</Table> </Table>
); );
}, [ };
schedule,
error,
fetchSchedule,
filteredAndSortedSchedule,
schedule_theme,
editScheduleItem,
LL,
dayBox,
scheduleType
]);
return ( return (
<SectionContent> <SectionContent>
@@ -372,6 +352,7 @@ const Scheduler = () => {
selectedItem={selectedScheduleItem} selectedItem={selectedScheduleItem}
validator={schedulerItemValidation(schedule, selectedScheduleItem)} validator={schedulerItemValidation(schedule, selectedScheduleItem)}
dow={dow} 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 AddIcon from '@mui/icons-material/Add';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
import CircleIcon from '@mui/icons-material/Circle';
import DoneIcon from '@mui/icons-material/Done'; import DoneIcon from '@mui/icons-material/Done';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import RemoveIcon from '@mui/icons-material/RemoveCircleOutlined'; import RemoveIcon from '@mui/icons-material/RemoveCircleOutlined';
import { import {
Box, Box,
@@ -14,6 +14,7 @@ import {
DialogContent, DialogContent,
DialogTitle, DialogTitle,
Grid, Grid,
MenuItem,
TextField, TextField,
ToggleButton, ToggleButton,
ToggleButtonGroup, ToggleButtonGroup,
@@ -21,12 +22,12 @@ import {
} from '@mui/material'; } from '@mui/material';
import { dialogStyle } from 'CustomTheme'; import { dialogStyle } from 'CustomTheme';
import type Schema from 'async-validator';
import type { ValidateFieldsError } from 'async-validator';
import { BlockFormControlLabel, ValidatedTextField } from 'components'; import { BlockFormControlLabel, ValidatedTextField } from 'components';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import { updateValue } from 'utils'; import { updateValue } from 'utils';
import { ValidationError, validate } from 'validators'; import { ValidationError, validate } from 'validators';
import type Schema from 'validators/schema';
import type { ValidateFieldsError } from 'validators/schema';
import { ScheduleFlag } from './types'; import { ScheduleFlag } from './types';
import type { ScheduleItem } from './types'; import type { ScheduleItem } from './types';
@@ -60,6 +61,12 @@ const FLAG_VALUES = [
ScheduleFlag.SCHEDULE_SAT ScheduleFlag.SCHEDULE_SAT
] as const; ] as const;
const getFlagDOWnumber = (flags: string[]) =>
flags.reduce((acc, flag) => acc | Number(flag), 0) & FLAG_MASK_127;
const getFlagDOWstring = (f: number) =>
FLAG_VALUES.filter((flag) => (f & flag) === flag).map((flag) => String(flag));
interface SchedulerDialogProps { interface SchedulerDialogProps {
open: boolean; open: boolean;
creating: boolean; creating: boolean;
@@ -68,6 +75,7 @@ interface SchedulerDialogProps {
selectedItem: ScheduleItem; selectedItem: ScheduleItem;
validator: Schema; validator: Schema;
dow: string[]; dow: string[];
commandNames: string[];
} }
const SchedulerDialog = ({ const SchedulerDialog = ({
@@ -77,33 +85,24 @@ const SchedulerDialog = ({
onSave, onSave,
selectedItem, selectedItem,
validator, validator,
dow dow,
commandNames
}: SchedulerDialogProps) => { }: SchedulerDialogProps) => {
const { LL } = useI18nContext(); const { LL } = useI18nContext();
const [editItem, setEditItem] = useState<ScheduleItem>(selectedItem); const [editItem, setEditItem] = useState<ScheduleItem>(selectedItem);
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>(); const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const [scheduleType, setScheduleType] = useState<ScheduleFlag>(); const [scheduleType, setScheduleType] = useState<ScheduleFlag>();
const updateFormValue = useMemo( const updateFormValue = updateValue(
() => setEditItem as unknown as React.Dispatch<
updateValue( React.SetStateAction<Record<string, unknown>>
setEditItem as unknown as React.Dispatch< >
React.SetStateAction<Record<string, unknown>>
>
),
[]
); );
useEffect(() => { useEffect(() => {
if (open) { if (open) {
setFieldErrors(undefined); setFieldErrors(undefined);
setEditItem(selectedItem); setEditItem(selectedItem);
// Set the flags based on type when page is loaded:
// 0-127 is day schedule
// 128 is timer
// 129 is on change
// 130 is on condition
// 132 is immediate
setScheduleType( setScheduleType(
selectedItem.flags <= SCHEDULE_TYPE_THRESHOLD selectedItem.flags <= SCHEDULE_TYPE_THRESHOLD
? ScheduleFlag.SCHEDULE_DAY ? ScheduleFlag.SCHEDULE_DAY
@@ -112,129 +111,89 @@ const SchedulerDialog = ({
} }
}, [open, selectedItem]); }, [open, selectedItem]);
// Helper function to handle save operations const handleSave = async (itemToSave: ScheduleItem) => {
const handleSave = useCallback( try {
async (itemToSave: ScheduleItem) => { setFieldErrors(undefined);
try { await validate(validator, itemToSave);
setFieldErrors(undefined); onSave(itemToSave);
await validate(validator, itemToSave); } catch (error) {
onSave(itemToSave); setFieldErrors((error as ValidationError).fieldErrors);
} 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;
} }
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_TIMER) return LL.TIMER(1);
if (scheduleType === ScheduleFlag.SCHEDULE_CONDITION) return LL.CONDITION(); if (scheduleType === ScheduleFlag.SCHEDULE_CONDITION) return LL.CONDITION();
if (scheduleType === ScheduleFlag.SCHEDULE_ONCHANGE) return LL.ONCHANGE(); if (scheduleType === ScheduleFlag.SCHEDULE_ONCHANGE) return LL.ONCHANGE();
if (scheduleType === ScheduleFlag.SCHEDULE_IMMEDIATE) return LL.IMMEDIATE();
return LL.TIME(1); return LL.TIME(1);
}, [scheduleType, LL]); })();
return ( return (
<Dialog sx={dialogStyle} open={open} onClose={handleClose}> <Dialog sx={dialogStyle} open={open} onClose={handleClose}>
@@ -287,14 +246,6 @@ const SchedulerDialog = ({
{LL.CONDITION()} {LL.CONDITION()}
</Typography> </Typography>
</ToggleButton> </ToggleButton>
<ToggleButton value={ScheduleFlag.SCHEDULE_IMMEDIATE}>
<Typography
sx={{ fontSize: TYPOGRAPHY_FONT_SIZE }}
color={isImmediateSchedule ? 'primary' : 'grey'}
>
{LL.IMMEDIATE()}
</Typography>
</ToggleButton>
</ToggleButtonGroup> </ToggleButtonGroup>
{isDaySchedule && ( {isDaySchedule && (
@@ -312,78 +263,74 @@ const SchedulerDialog = ({
</ToggleButtonGroup> </ToggleButtonGroup>
)} )}
{!isImmediateSchedule && ( <Grid container>
<> <BlockFormControlLabel
<Grid container> control={
<BlockFormControlLabel <Checkbox
control={ checked={editItem.active}
<Checkbox onChange={updateFormValue}
checked={editItem.active} name="active"
onChange={updateFormValue}
name="active"
/>
}
label={LL.ACTIVE()}
/> />
</Grid> }
<Grid container> label={LL.ACTIVE()}
{needsTimeField ? ( />
<> <CircleIcon
<TextField color={editItem.active ? 'success' : 'error'}
name="time" sx={{
type="time" fontSize: 16,
label={timeFieldLabel} mt: '12px'
value={timeFieldValue} }}
margin="normal" />
onChange={updateFormValue} </Grid>
/>
{isTimerSchedule && ( <Grid container>
<Typography {needsTimeField ? (
sx={{ ml: 2, mt: 4 }} <>
color="warning" <TextField
variant="body2" name="time"
> type="time"
{LL.SCHEDULER_HELP_2()} label={timeFieldLabel}
</Typography> value={timeFieldValue}
)} margin="normal"
</> onChange={updateFormValue}
) : ( />
<TextField {isTimerSchedule && (
name="time" <Typography sx={{ ml: 2, mt: 4 }} color="warning" variant="body2">
label={timeFieldLabel} 00:00 = {LL.SCHEDULER_HELP_2()}
multiline </Typography>
fullWidth
value={timeFieldValue}
margin="normal"
onChange={updateFormValue}
/>
)} )}
</Grid> </>
</> ) : (
)} <TextField
<ValidatedTextField name="time"
fieldErrors={fieldErrors || {}} label={timeFieldLabel}
name="cmd" multiline
label={LL.COMMAND(0)} fullWidth
multiline value={timeFieldValue}
fullWidth margin="normal"
value={editItem.cmd} onChange={updateFormValue}
margin="normal" />
onChange={updateFormValue} )}
/> </Grid>
<TextField <TextField
name="value" name="cmd_name"
label={LL.VALUE(0)} label={LL.COMMAND(0)}
multiline value={editItem.cmd_name}
margin="normal"
fullWidth fullWidth
value={editItem.value} select
margin="normal"
onChange={updateFormValue} onChange={updateFormValue}
/> >
{commandNames.map((name) => (
<MenuItem key={name} value={name}>
{name}
</MenuItem>
))}
</TextField>
<ValidatedTextField <ValidatedTextField
fieldErrors={fieldErrors || {}} fieldErrors={fieldErrors || {}}
name="name" name="name"
label={LL.NAME(0) + ' (' + LL.OPTIONAL() + ')'} label={LL.NAME(0)}
value={editItem.name} value={editItem.name}
fullWidth fullWidth
margin="normal" margin="normal"
@@ -420,16 +367,6 @@ const SchedulerDialog = ({
> >
{creating ? LL.ADD(0) : LL.UPDATE()} {creating ? LL.ADD(0) : LL.UPDATE()}
</Button> </Button>
{isImmediateSchedule && editItem.cmd !== '' && (
<Button
startIcon={<PlayArrowIcon />}
variant="outlined"
onClick={saveandactivate}
color="success"
>
{LL.EXECUTE()}
</Button>
)}
</DialogActions> </DialogActions>
</Dialog> </Dialog>
); );
+246 -290
View File
@@ -1,5 +1,4 @@
import { useCallback, useContext, useMemo, useRef, useState } from 'react'; import { useContext, useRef, useState } from 'react';
import { toast } from 'react-toastify';
import AddCircleOutlineOutlinedIcon from '@mui/icons-material/AddCircleOutlineOutlined'; import AddCircleOutlineOutlinedIcon from '@mui/icons-material/AddCircleOutlineOutlined';
import KeyboardArrowDownOutlinedIcon from '@mui/icons-material/KeyboardArrowDownOutlined'; 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 type { State } from '@table-library/react-table-library/types/common';
import { useRequest } from 'alova/client'; import { useRequest } from 'alova/client';
import { SectionContent, useLayoutTitle } from 'components'; import { SectionContent, useLayoutTitle } from 'components';
import { toast } from 'components/toast';
import { AuthenticatedContext } from 'contexts/authentication'; import { AuthenticatedContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import { useInterval } from 'utils'; import { useInterval } from 'utils';
@@ -158,18 +158,16 @@ const Sensors = () => {
} }
); );
const intervalCallback = useCallback(() => { useInterval(() => {
if (!temperatureDialogOpen && !analogDialogOpen) { if (!temperatureDialogOpen && !analogDialogOpen) {
void fetchSensorData(); void fetchSensorData();
} }
}, [temperatureDialogOpen, analogDialogOpen, fetchSensorData]); });
useInterval(intervalCallback);
const temperature_theme = useTheme([common_theme, temperature_theme_config]); const temperature_theme = useTheme([common_theme, temperature_theme_config]);
const analog_theme = useTheme([common_theme, analog_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) { if (state.sortKey === sortKey && state.reverse) {
return <KeyboardArrowDownOutlinedIcon />; return <KeyboardArrowDownOutlinedIcon />;
} }
@@ -177,7 +175,7 @@ const Sensors = () => {
return <KeyboardArrowUpOutlinedIcon />; return <KeyboardArrowUpOutlinedIcon />;
} }
return <UnfoldMoreOutlinedIcon />; return <UnfoldMoreOutlinedIcon />;
}, []); };
const analog_sort = useSort( const analog_sort = useSort(
{ nodes: sensorData.as }, { nodes: sensorData.as },
@@ -234,119 +232,104 @@ const Sensors = () => {
useLayoutTitle(LL.SENSORS()); useLayoutTitle(LL.SENSORS());
const formatDurationMin = useCallback( const formatDurationMin = (duration_min: number) => {
(duration_min: number) => { const totalMs = duration_min * MS_PER_MINUTE;
const totalMs = duration_min * MS_PER_MINUTE; const days = Math.trunc(totalMs / MS_PER_DAY);
const days = Math.trunc(totalMs / MS_PER_DAY); const hours = Math.trunc(totalMs / MS_PER_HOUR) % 24;
const hours = Math.trunc(totalMs / MS_PER_HOUR) % 24; const minutes = Math.trunc(totalMs / MS_PER_MINUTE) % 60;
const minutes = Math.trunc(totalMs / MS_PER_MINUTE) % 60;
const parts: string[] = []; const parts: string[] = [];
if (days > 0) { if (days > 0) {
parts.push(LL.NUM_DAYS({ num: days })); parts.push(LL.NUM_DAYS({ num: days }));
} }
if (hours > 0) { if (hours > 0) {
parts.push(LL.NUM_HOURS({ num: hours })); parts.push(LL.NUM_HOURS({ num: hours }));
} }
if (minutes > 0) { if (minutes > 0) {
parts.push(LL.NUM_MINUTES({ num: minutes })); parts.push(LL.NUM_MINUTES({ num: minutes }));
} }
return parts.join(' '); return parts.join(' ');
}, };
[LL]
);
const formatValue = useCallback( const formatValue = (value: unknown, uom: DeviceValueUOM) => {
(value: unknown, uom: DeviceValueUOM) => { if (value === undefined) {
if (value === undefined) { return '';
return ''; }
} if (typeof value !== 'number') {
if (typeof value !== 'number') { return value as string;
return value as string; }
} switch (uom) {
switch (uom) { case DeviceValueUOM.HOURS:
case DeviceValueUOM.HOURS: return value ? formatDurationMin(value * 60) : LL.NUM_HOURS({ num: 0 });
return value ? formatDurationMin(value * 60) : LL.NUM_HOURS({ num: 0 }); case DeviceValueUOM.MINUTES:
case DeviceValueUOM.MINUTES: return value ? formatDurationMin(value) : LL.NUM_MINUTES({ num: 0 });
return value ? formatDurationMin(value) : LL.NUM_MINUTES({ num: 0 }); case DeviceValueUOM.SECONDS:
case DeviceValueUOM.SECONDS: return LL.NUM_SECONDS({ num: value });
return LL.NUM_SECONDS({ num: value }); case DeviceValueUOM.NONE:
case DeviceValueUOM.NONE: return new Intl.NumberFormat().format(value);
return new Intl.NumberFormat().format(value); case DeviceValueUOM.DEGREES:
case DeviceValueUOM.DEGREES: case DeviceValueUOM.DEGREES_R:
case DeviceValueUOM.DEGREES_R: case DeviceValueUOM.FAHRENHEIT:
case DeviceValueUOM.FAHRENHEIT: return (
return ( new Intl.NumberFormat(undefined, {
new Intl.NumberFormat(undefined, { minimumFractionDigits: 1
minimumFractionDigits: 1 }).format(value) +
}).format(value) + ' ' +
' ' + DeviceValueUOM_s[uom]
DeviceValueUOM_s[uom] );
); default:
default: return new Intl.NumberFormat().format(value) + ' ' + DeviceValueUOM_s[uom];
return new Intl.NumberFormat().format(value) + ' ' + DeviceValueUOM_s[uom]; }
} };
},
[formatDurationMin, LL]
);
const updateTemperatureSensor = useCallback( const updateTemperatureSensor = (ts: TemperatureSensor) => {
(ts: TemperatureSensor) => { if (me.admin) {
if (me.admin) { ts.o_n = ts.n;
ts.o_n = ts.n; setSelectedTemperatureSensor(ts);
setSelectedTemperatureSensor(ts); setTemperatureDialogOpen(true);
setTemperatureDialogOpen(true); }
} };
},
[me.admin]
);
const onTemperatureDialogClose = useCallback(() => { const onTemperatureDialogClose = () => {
setTemperatureDialogOpen(false); setTemperatureDialogOpen(false);
void fetchSensorData(); void fetchSensorData();
}, [fetchSensorData]); };
const onTemperatureDialogSave = useCallback( const onTemperatureDialogSave = async (ts: TemperatureSensor) => {
async (ts: TemperatureSensor) => { await sendTemperatureSensor({
await sendTemperatureSensor({ id: ts.id,
id: ts.id, name: ts.n,
name: ts.n, offset: ts.o,
offset: ts.o, is_system: ts.s
is_system: ts.s })
.then(() => {
toast.success(LL.UPDATED_OF(LL.SENSOR(1)));
}) })
.then(() => { .catch(() => {
toast.success(LL.UPDATED_OF(LL.SENSOR(1))); toast.error(LL.UPDATE_OF(LL.SENSOR(2)) + ' ' + LL.FAILED(1));
}) })
.catch(() => { .finally(() => {
toast.error(LL.UPDATE_OF(LL.SENSOR(2)) + ' ' + LL.FAILED(1)); setTemperatureDialogOpen(false);
}) setSelectedTemperatureSensor(undefined);
.finally(() => { void fetchSensorData();
setTemperatureDialogOpen(false); });
setSelectedTemperatureSensor(undefined); };
void fetchSensorData();
});
},
[sendTemperatureSensor, LL, fetchSensorData]
);
const updateAnalogSensor = useCallback( const updateAnalogSensor = (as: AnalogSensor) => {
(as: AnalogSensor) => { if (me.admin) {
if (me.admin) { setCreating(false);
setCreating(false); as.o_n = as.n;
as.o_n = as.n; setSelectedAnalogSensor(as);
setSelectedAnalogSensor(as); setAnalogDialogOpen(true);
setAnalogDialogOpen(true); }
} };
},
[me.admin]
);
const onAnalogDialogClose = useCallback(() => { const onAnalogDialogClose = () => {
setAnalogDialogOpen(false); setAnalogDialogOpen(false);
void fetchSensorData(); void fetchSensorData();
}, [fetchSensorData]); };
const addAnalogSensor = useCallback(() => { const addAnalogSensor = () => {
if (firstAvailableGPIO.current === undefined) { if (firstAvailableGPIO.current === undefined) {
toast.error(LL.NO_GPIO()); toast.error(LL.NO_GPIO());
return; return;
@@ -366,194 +349,167 @@ const Sensors = () => {
o_n: '' o_n: ''
}); });
setAnalogDialogOpen(true); setAnalogDialogOpen(true);
}, []); };
const onAnalogDialogSave = useCallback( const onAnalogDialogSave = async (as: AnalogSensor) => {
async (as: AnalogSensor) => { await sendAnalogSensor({
await sendAnalogSensor({ id: as.id,
id: as.id, gpio: as.g,
gpio: as.g, name: as.n,
name: as.n, offset: as.o,
offset: as.o, factor: as.f,
factor: as.f, uom: as.u,
uom: as.u, type: as.t,
type: as.t, deleted: as.d,
deleted: as.d, is_system: as.s
is_system: as.s })
.then(() => {
toast.success(LL.UPDATED_OF(LL.ANALOG_SENSOR(2)));
}) })
.then(() => { .catch(() => {
toast.success(LL.UPDATED_OF(LL.ANALOG_SENSOR(2))); toast.error(LL.UPDATE_OF(LL.ANALOG_SENSOR(5)) + ' ' + LL.FAILED(1));
}) })
.catch(() => { .finally(() => {
toast.error(LL.UPDATE_OF(LL.ANALOG_SENSOR(5)) + ' ' + LL.FAILED(1)); setAnalogDialogOpen(false);
}) setSelectedAnalogSensor(undefined);
.finally(() => { void fetchSensorData();
setAnalogDialogOpen(false); });
setSelectedAnalogSensor(undefined); };
void fetchSensorData();
}); const RenderAnalogSensors = (
}, <Table
[sendAnalogSensor, LL, fetchSensorData] 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( const RenderTemperatureSensors = (
() => ( <Table
<Table data={{ nodes: sensorData.ts }}
data={{ nodes: sensorData.as }} theme={temperature_theme}
theme={analog_theme} sort={temperature_sort}
sort={analog_sort} layout={{ custom: true }}
layout={{ custom: true }} >
> {(tableList: TemperatureSensor[]) => (
{(tableList: AnalogSensor[]) => ( <>
<> <Header>
<Header> <HeaderRow>
<HeaderRow> <HeaderCell resize>
<HeaderCell stiff> <Button
<Button fullWidth
fullWidth style={HEADER_BUTTON_STYLE}
style={HEADER_BUTTON_STYLE} endIcon={getSortIcon(temperature_sort.state, 'NAME')}
endIcon={getSortIcon(analog_sort.state, 'GPIO')} onClick={() =>
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'GPIO' })} temperature_sort.fns.onToggleSort({ sortKey: 'NAME' })
> }
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> {LL.NAME(0)}
<Cell>{as.n}</Cell> </Button>
<Cell stiff>{AnalogTypeNames[as.t - 1]} </Cell> </HeaderCell>
{(as.t === AnalogType.DIGITAL_OUT && <HeaderCell stiff>
as.g !== GPIO_25 && <Button
as.g !== GPIO_26) || fullWidth
as.t === AnalogType.DIGITAL_IN || style={HEADER_BUTTON_STYLE_END}
as.t === AnalogType.PULSE ? ( endIcon={getSortIcon(temperature_sort.state, 'VALUE')}
<Cell stiff>{as.v ? LL.ON() : LL.OFF()}</Cell> onClick={() =>
) : ( temperature_sort.fns.onToggleSort({ sortKey: 'VALUE' })
<Cell stiff>{formatValue(as.v, as.u)}</Cell> }
)}
</Row>
))}
</Body>
</>
)}
</Table>
),
[
analog_sort,
analog_theme,
getSortIcon,
sensorData.as,
LL,
updateAnalogSensor,
formatValue
]
);
const RenderTemperatureSensors = useMemo(
() => (
<Table
data={{ nodes: sensorData.ts }}
theme={temperature_theme}
sort={temperature_sort}
layout={{ custom: true }}
>
{(tableList: TemperatureSensor[]) => (
<>
<Header>
<HeaderRow>
<HeaderCell resize>
<Button
fullWidth
style={HEADER_BUTTON_STYLE}
endIcon={getSortIcon(temperature_sort.state, 'NAME')}
onClick={() =>
temperature_sort.fns.onToggleSort({ sortKey: 'NAME' })
}
>
{LL.NAME(0)}
</Button>
</HeaderCell>
<HeaderCell stiff>
<Button
fullWidth
style={HEADER_BUTTON_STYLE_END}
endIcon={getSortIcon(temperature_sort.state, 'VALUE')}
onClick={() =>
temperature_sort.fns.onToggleSort({ sortKey: 'VALUE' })
}
>
{LL.VALUE(0)}
</Button>
</HeaderCell>
</HeaderRow>
</Header>
<Body>
{tableList.map((ts: TemperatureSensor) => (
<Row
style={{ color: ts.s ? 'grey' : 'inherit' }}
key={ts.id}
item={ts}
onClick={() => updateTemperatureSensor(ts)}
> >
<Cell>{ts.n}</Cell> {LL.VALUE(0)}
<Cell>{formatValue(ts.t, ts.u)}</Cell> </Button>
</Row> </HeaderCell>
))} </HeaderRow>
</Body> </Header>
</> <Body>
)} {tableList.map((ts: TemperatureSensor) => (
</Table> <Row
), style={{ color: ts.s ? 'grey' : 'inherit' }}
[ key={ts.id}
temperature_sort, item={ts}
temperature_theme, onClick={() => updateTemperatureSensor(ts)}
getSortIcon, >
sensorData.ts, <Cell>{ts.n}</Cell>
LL, <Cell>{formatValue(ts.t, ts.u)}</Cell>
updateTemperatureSensor, </Row>
formatValue ))}
] </Body>
</>
)}
</Table>
); );
return ( 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 CancelIcon from '@mui/icons-material/Cancel';
import DoneIcon from '@mui/icons-material/Done'; import DoneIcon from '@mui/icons-material/Done';
@@ -18,12 +18,12 @@ import {
} from '@mui/material'; } from '@mui/material';
import { dialogStyle } from 'CustomTheme'; import { dialogStyle } from 'CustomTheme';
import type Schema from 'async-validator';
import type { ValidateFieldsError } from 'async-validator';
import { ValidatedTextField } from 'components'; import { ValidatedTextField } from 'components';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import { numberValue, updateValue } from 'utils'; import { numberValue, updateValue } from 'utils';
import { ValidationError, validate } from 'validators'; import { ValidationError, validate } from 'validators';
import type Schema from 'validators/schema';
import type { ValidateFieldsError } from 'validators/schema';
import { AnalogType, AnalogTypeNames, DeviceValueUOM_s } from './types'; import { AnalogType, AnalogTypeNames, DeviceValueUOM_s } from './types';
import type { AnalogSensor } from './types'; import type { AnalogSensor } from './types';
@@ -53,84 +53,54 @@ const SensorsAnalogDialog = ({
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>(); const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const [editItem, setEditItem] = useState<AnalogSensor>(selectedItem); const [editItem, setEditItem] = useState<AnalogSensor>(selectedItem);
const updateFormValue = useMemo( const updateFormValue = updateValue((updater) =>
() => setEditItem(
updateValue((updater) => (prev) =>
setEditItem( updater(
(prev) => prev as unknown as Record<string, unknown>
updater( ) as unknown as AnalogSensor
prev as unknown as Record<string, unknown> )
) as unknown as AnalogSensor
)
),
[setEditItem]
); );
// Memoize helper functions to check sensor type conditions const isCounterOrRate =
const isCounterOrRate = useMemo( editItem.t === AnalogType.COUNTER ||
() => editItem.t === AnalogType.RATE ||
editItem.t === AnalogType.COUNTER || (editItem.t >= AnalogType.CNT_0 && editItem.t <= AnalogType.CNT_2);
editItem.t === AnalogType.RATE || const isCounter =
(editItem.t >= AnalogType.CNT_0 && editItem.t <= AnalogType.CNT_2), editItem.t === AnalogType.COUNTER ||
[editItem.t] (editItem.t >= AnalogType.CNT_0 && editItem.t <= AnalogType.CNT_2);
); const isFreqType =
const isCounter = useMemo( editItem.t >= AnalogType.FREQ_0 && editItem.t <= AnalogType.FREQ_2;
() => const isPWM =
editItem.t === AnalogType.COUNTER || editItem.t === AnalogType.PWM_0 ||
(editItem.t >= AnalogType.CNT_0 && editItem.t <= AnalogType.CNT_2), editItem.t === AnalogType.PWM_1 ||
[editItem.t] editItem.t === AnalogType.PWM_2;
); const isDACOutGPIO =
const isFreqType = useMemo( editItem.t === AnalogType.DIGITAL_OUT &&
() => editItem.t >= AnalogType.FREQ_0 && editItem.t <= AnalogType.FREQ_2, (editItem.g === 25 || editItem.g === 26);
[editItem.t] const isDigitalOutGPIO =
); editItem.t === AnalogType.DIGITAL_OUT && editItem.g !== 25 && editItem.g !== 26;
const isPWM = useMemo(
() =>
editItem.t === AnalogType.PWM_0 ||
editItem.t === AnalogType.PWM_1 ||
editItem.t === AnalogType.PWM_2,
[editItem.t]
);
const isDACOutGPIO = useMemo(
() =>
editItem.t === AnalogType.DIGITAL_OUT &&
(editItem.g === 25 || editItem.g === 26),
[editItem.t, editItem.g]
);
const isDigitalOutGPIO = useMemo(
() =>
editItem.t === AnalogType.DIGITAL_OUT &&
editItem.g !== 25 &&
editItem.g !== 26,
[editItem.t, editItem.g]
);
// Memoize menu items to avoid recreation on each render const analogTypeMenuItems = AnalogTypeNames.map((val, i) => ({
const analogTypeMenuItems = useMemo( name: val,
() => value: i + 1
AnalogTypeNames.map((val, i) => ({ name: val, value: i + 1 })) }))
.sort((a, b) => a.name.localeCompare(b.name)) .sort((a, b) => a.name.localeCompare(b.name))
.map(({ name, value }) => ( .map(({ name, value }) => (
<MenuItem <MenuItem
key={name} key={name}
value={value} value={value}
disabled={disabledTypeList?.includes(value)} disabled={disabledTypeList?.includes(value)}
> >
{name} {name}
</MenuItem> </MenuItem>
)), ));
[disabledTypeList]
);
const uomMenuItems = useMemo( const uomMenuItems = DeviceValueUOM_s.map((val, i) => (
() => <MenuItem key={val} value={i}>
DeviceValueUOM_s.map((val, i) => ( {val}
<MenuItem key={val} value={i}> </MenuItem>
{val} ));
</MenuItem>
)),
[]
);
const analogGPIOMenuItems = () => const analogGPIOMenuItems = () =>
// add selectedItem.g to the list // add selectedItem.g to the list
@@ -157,16 +127,16 @@ const SensorsAnalogDialog = ({
} }
}, [open, selectedItem]); }, [open, selectedItem]);
const handleClose = useCallback( const handleClose = (
(_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => { _event: React.SyntheticEvent,
if (reason !== 'backdropClick') { reason: 'backdropClick' | 'escapeKeyDown'
onClose(); ) => {
} if (reason !== 'backdropClick') {
}, onClose();
[onClose] }
); };
const save = useCallback(async () => { const save = async () => {
try { try {
setFieldErrors(undefined); setFieldErrors(undefined);
await validate(validator, editItem); await validate(validator, editItem);
@@ -174,17 +144,13 @@ const SensorsAnalogDialog = ({
} catch (error) { } catch (error) {
setFieldErrors((error as ValidationError).fieldErrors); setFieldErrors((error as ValidationError).fieldErrors);
} }
}, [validator, editItem, onSave]); };
const remove = useCallback(() => { const remove = () => {
onSave({ ...editItem, d: true }); onSave({ ...editItem, d: true });
}, [editItem, onSave]); };
const dialogTitle = useMemo( const dialogTitle = `${creating ? LL.ADD(1) + ' ' + LL.NEW(0) : LL.EDIT()} ${LL.ANALOG_SENSOR(0)}`;
() =>
`${creating ? LL.ADD(1) + ' ' + LL.NEW(0) : LL.EDIT()} ${LL.ANALOG_SENSOR(0)}`,
[creating, LL]
);
return ( return (
<Dialog sx={dialogStyle} open={open} onClose={handleClose}> <Dialog sx={dialogStyle} open={open} onClose={handleClose}>
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useEffect, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
import DoneIcon from '@mui/icons-material/Done'; import DoneIcon from '@mui/icons-material/Done';
@@ -16,12 +16,12 @@ import {
} from '@mui/material'; } from '@mui/material';
import { dialogStyle } from 'CustomTheme'; import { dialogStyle } from 'CustomTheme';
import type Schema from 'async-validator';
import type { ValidateFieldsError } from 'async-validator';
import { ValidatedTextField } from 'components'; import { ValidatedTextField } from 'components';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import { numberValue, updateValue } from 'utils'; import { numberValue, updateValue } from 'utils';
import { ValidationError, validate } from 'validators'; import { ValidationError, validate } from 'validators';
import type Schema from 'validators/schema';
import type { ValidateFieldsError } from 'validators/schema';
import type { TemperatureSensor } from './types'; import type { TemperatureSensor } from './types';
@@ -50,16 +50,12 @@ const SensorsTemperatureDialog = ({
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>(); const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const [editItem, setEditItem] = useState<TemperatureSensor>(selectedItem); const [editItem, setEditItem] = useState<TemperatureSensor>(selectedItem);
const updateFormValue = useMemo( const updateFormValue = updateValue(
() => setEditItem as unknown as (
updateValue( updater: (
setEditItem as unknown as ( prevState: Readonly<Record<string, unknown>>
updater: ( ) => Record<string, unknown>
prevState: Readonly<Record<string, unknown>> ) => void
) => Record<string, unknown>
) => void
),
[setEditItem]
); );
useEffect(() => { useEffect(() => {
@@ -69,16 +65,13 @@ const SensorsTemperatureDialog = ({
} }
}, [open, selectedItem]); }, [open, selectedItem]);
const handleClose = useCallback( const handleClose = (_event: React.SyntheticEvent, reason?: string) => {
(_event: React.SyntheticEvent, reason?: string) => { if (reason !== 'backdropClick') {
if (reason !== 'backdropClick') { onClose();
onClose(); }
} };
},
[onClose]
);
const save = useCallback(async () => { const save = async () => {
try { try {
setFieldErrors(undefined); setFieldErrors(undefined);
await validate(validator, editItem); await validate(validator, editItem);
@@ -86,29 +79,11 @@ const SensorsTemperatureDialog = ({
} catch (error) { } catch (error) {
setFieldErrors((error as ValidationError).fieldErrors); 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 ( return (
<Dialog sx={dialogStyle} open={open} onClose={handleClose}> <Dialog sx={dialogStyle} open={open} onClose={handleClose}>
<DialogTitle>{dialogTitle}</DialogTitle> <DialogTitle>{`${LL.EDIT()} ${LL.TEMP_SENSOR()}`}</DialogTitle>
<DialogContent dividers> <DialogContent dividers>
<Typography sx={{ mb: 2 }} color="warning" variant="body2"> <Typography sx={{ mb: 2 }} color="warning" variant="body2">
{LL.ID_OF(LL.SENSOR(0))}: {editItem.id} {LL.ID_OF(LL.SENSOR(0))}: {editItem.id}
@@ -128,12 +103,23 @@ const SensorsTemperatureDialog = ({
<TextField <TextField
name="o" name="o"
label={LL.OFFSET()} label={LL.OFFSET()}
value={offsetValue} value={numberValue(editItem.o)}
sx={{ width: '11ch' }} sx={{ width: '11ch' }}
type="number" type="number"
variant="outlined" variant="outlined"
onChange={updateFormValue} onChange={updateFormValue}
slotProps={slotProps} slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">{TEMP_UNIT}</InputAdornment>
)
},
htmlInput: {
min: OFFSET_MIN,
max: OFFSET_MAX,
step: OFFSET_STEP
}
}}
/> />
</Grid> </Grid>
</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 PersonIcon from '@mui/icons-material/Person';
import { import {
@@ -23,9 +23,9 @@ const UserProfileComponent = () => {
useLayoutTitle(LL.USER_PROFILE()); useLayoutTitle(LL.USER_PROFILE());
const handleSignOut = useCallback(() => { const handleSignOut = () => {
signOut(true); signOut(true);
}, [signOut]); };
return ( return (
<SectionContent> <SectionContent>
+34 -8
View File
@@ -1,4 +1,5 @@
export interface Settings { export interface Settings {
system_name: string;
locale: string; locale: string;
tx_mode: number; tx_mode: number;
ems_bus_id: number; ems_bus_id: number;
@@ -43,7 +44,17 @@ export interface Settings {
modbus_port: number; modbus_port: number;
modbus_max_clients: number; modbus_max_clients: number;
modbus_timeout: number; modbus_timeout: number;
email_enabled: boolean;
email_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; developer_mode: boolean;
disable_reset: boolean;
} }
export enum busConnectionStatus { export enum busConnectionStatus {
@@ -339,21 +350,19 @@ export enum DeviceEntityMask {
} }
export interface ScheduleItem { export interface ScheduleItem {
id: number; // unique index id: number; // unique index for table
active: boolean; active: boolean;
deleted?: boolean; deleted?: boolean;
flags: number; flags: number;
time: string; // also used for Condition and On Change time: string; // also used for Condition and On Change
cmd: string; cmd_name: string; // references a named Command
value: string; name: string;
name: string; // can be empty
o_id?: number; o_id?: number;
o_active?: boolean; o_active?: boolean;
o_deleted?: boolean; o_deleted?: boolean;
o_flags?: number; o_flags?: number;
o_time?: string; o_time?: string;
o_cmd?: string; o_cmd_name?: string;
o_value?: string;
o_name?: string; o_name?: string;
} }
@@ -361,6 +370,23 @@ export interface Schedule {
readonly schedule: readonly ScheduleItem[]; 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 { export interface ModuleItem {
id: number; // unique index id: number; // unique index
key: string; key: string;
@@ -391,8 +417,7 @@ export enum ScheduleFlag {
SCHEDULE_DAY = 0, // no bits set SCHEDULE_DAY = 0, // no bits set
SCHEDULE_TIMER = 128, // bit 8 SCHEDULE_TIMER = 128, // bit 8
SCHEDULE_ONCHANGE = 129, // bit 1 SCHEDULE_ONCHANGE = 129, // bit 1
SCHEDULE_CONDITION = 130, // bit 2 SCHEDULE_CONDITION = 130 // bit 2
SCHEDULE_IMMEDIATE = 132 // bit 3
} }
export interface EntityItem { export interface EntityItem {
@@ -435,6 +460,7 @@ export const enum DeviceType {
ANALOGSENSOR = 2, ANALOGSENSOR = 2,
SCHEDULER = 3, SCHEDULER = 3,
CUSTOM = 4, CUSTOM = 4,
COMMAND = 5,
BOILER, BOILER,
THERMOSTAT, THERMOSTAT,
MIXER, MIXER,
+26 -3
View File
@@ -1,9 +1,10 @@
import Schema from 'async-validator'; import Schema from 'validators/schema';
import type { InternalRuleItem } from 'async-validator'; import type { InternalRuleItem } from 'validators/schema';
import { IP_OR_HOSTNAME_VALIDATOR } from 'validators/shared'; import { IP_OR_HOSTNAME_VALIDATOR } from 'validators/shared';
import type { import type {
AnalogSensor, AnalogSensor,
CommandItem,
DeviceValue, DeviceValue,
EntityItem, EntityItem,
ScheduleItem, ScheduleItem,
@@ -232,7 +233,29 @@ export const schedulerItemValidation = (
scheduleItem: ScheduleItem scheduleItem: ScheduleItem
) => ) =>
new Schema({ 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: [ cmd: [
{ required: true, message: 'Command is required' }, { 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 CancelIcon from '@mui/icons-material/Cancel';
import WarningIcon from '@mui/icons-material/Warning'; 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 * as APApi from 'api/ap';
import type { ValidateFieldsError } from 'async-validator';
import { import {
BlockFormControlLabel, BlockFormControlLabel,
BlockNavigation, BlockNavigation,
@@ -22,9 +21,9 @@ import type { APSettingsType } from 'types';
import { APProvisionMode } from 'types'; import { APProvisionMode } from 'types';
import { numberValue, updateValueDirty, useRest } from 'utils'; import { numberValue, updateValueDirty, useRest } from 'utils';
import { ValidationError, createAPSettingsValidator, validate } from 'validators'; import { ValidationError, createAPSettingsValidator, validate } from 'validators';
import type { ValidateFieldsError } from 'validators/schema';
export const isAPEnabled = ({ provision_mode }: APSettingsType) => export const isAPEnabled = ({ provision_mode }: APSettingsType) =>
provision_mode === APProvisionMode.AP_MODE_ALWAYS ||
provision_mode === APProvisionMode.AP_MODE_DISCONNECTED; provision_mode === APProvisionMode.AP_MODE_DISCONNECTED;
// Efficient range function without recursion // Efficient range function without recursion
@@ -63,22 +62,16 @@ const APSettings = () => {
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>(); const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const updateFormValue = useMemo( const updateFormValue = updateValueDirty(
() => origData as unknown as Record<string, unknown>,
updateValueDirty( dirtyFlags,
origData as unknown as Record<string, unknown>, setDirtyFlags,
dirtyFlags, updateDataValue as (value: unknown) => void
setDirtyFlags,
updateDataValue as (value: unknown) => void
),
[origData, dirtyFlags, setDirtyFlags, updateDataValue]
); );
// Memoize AP enabled state const apEnabled = data ? isAPEnabled(data) : false;
const apEnabled = useMemo(() => (data ? isAPEnabled(data) : false), [data]);
// Memoize validation and submit handler const validateAndSubmit = async () => {
const validateAndSubmit = useCallback(async () => {
if (!data) return; if (!data) return;
try { try {
@@ -88,7 +81,7 @@ const APSettings = () => {
} catch (error) { } catch (error) {
setFieldErrors((error as ValidationError).fieldErrors); setFieldErrors((error as ValidationError).fieldErrors);
} }
}, [data, saveData]); };
const content = () => { const content = () => {
if (!data) { if (!data) {
@@ -108,9 +101,6 @@ const APSettings = () => {
onChange={updateFormValue} onChange={updateFormValue}
margin="normal" margin="normal"
> >
<MenuItem value={APProvisionMode.AP_MODE_ALWAYS}>
{LL.AP_PROVIDE_TEXT_1()}
</MenuItem>
<MenuItem value={APProvisionMode.AP_MODE_DISCONNECTED}> <MenuItem value={APProvisionMode.AP_MODE_DISCONNECTED}>
{LL.AP_PROVIDE_TEXT_2()} {LL.AP_PROVIDE_TEXT_2()}
</MenuItem> </MenuItem>
@@ -1,5 +1,4 @@
import { useCallback, useMemo, useState } from 'react'; import { useState } from 'react';
import { toast } from 'react-toastify';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
import PowerSettingsNewIcon from '@mui/icons-material/PowerSettingsNew'; import PowerSettingsNewIcon from '@mui/icons-material/PowerSettingsNew';
@@ -20,7 +19,6 @@ import { readSystemStatus } from 'api/system';
import { useRequest } from 'alova/client'; import { useRequest } from 'alova/client';
import SystemMonitor from 'app/status/SystemMonitor'; import SystemMonitor from 'app/status/SystemMonitor';
import type { ValidateFieldsError } from 'async-validator';
import { import {
BlockFormControlLabel, BlockFormControlLabel,
BlockNavigation, BlockNavigation,
@@ -28,12 +26,15 @@ import {
FormLoader, FormLoader,
MessageBox, MessageBox,
SectionContent, SectionContent,
ValidatedPasswordField,
ValidatedTextField, ValidatedTextField,
useLayoutTitle useLayoutTitle
} from 'components'; } from 'components';
import { toast } from 'components/toast';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import { numberValue, updateValueDirty, useRest } from 'utils'; import { numberValue, updateValueDirty, useRest } from 'utils';
import { ValidationError, validate } from 'validators'; import { ValidationError, validate } from 'validators';
import type { ValidateFieldsError } from 'validators/schema';
import { API, getBoardProfile, readSettings, writeSettings } from '../../api/app'; import { API, getBoardProfile, readSettings, writeSettings } from '../../api/app';
import { BOARD_PROFILES } from '../main/types'; import { BOARD_PROFILES } from '../main/types';
@@ -106,82 +107,79 @@ const ApplicationSettings = () => {
}); });
}); });
// Memoized input props to prevent recreation on every render const SecondsInputProps = {
const SecondsInputProps = useMemo( endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
() => ({ };
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
}),
[LL]
);
const MinutesInputProps = useMemo( const MinutesInputProps = {
() => ({ endAdornment: <InputAdornment position="end">{LL.MINUTES()}</InputAdornment>
endAdornment: <InputAdornment position="end">{LL.MINUTES()}</InputAdornment> };
}),
[LL]
);
const HoursInputProps = useMemo( const HoursInputProps = {
() => ({ endAdornment: <InputAdornment position="end">{LL.HOURS()}</InputAdornment>
endAdornment: <InputAdornment position="end">{LL.HOURS()}</InputAdornment> };
}),
[LL]
);
const doRestart = useCallback(async () => { const doRestart = async () => {
setRestarting(true); setRestarting(true);
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch( await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
(error: Error) => { (error: Error) => {
toast.error(error.message); toast.error(error.message);
} }
); );
}, [sendAPI]); };
const updateBoardProfile = useCallback( const updateBoardProfile = async (board_profile: string) => {
async (board_profile: string) => { await readBoardProfile(board_profile).catch((error: Error) => {
await readBoardProfile(board_profile).catch((error: Error) => { toast.error(error.message);
toast.error(error.message); });
}); };
},
[readBoardProfile]
);
useLayoutTitle(LL.APPLICATION()); useLayoutTitle(LL.APPLICATION());
const validateAndSubmit = useCallback(async () => { const validateAndSubmit = async () => {
try { try {
setFieldErrors(undefined); setFieldErrors(undefined);
await validate(createSettingsValidator(data), data); await validate(createSettingsValidator(data), data);
await saveData();
} catch (error) { } catch (error) {
setFieldErrors((error as ValidationError).fieldErrors); setFieldErrors((error as ValidationError).fieldErrors);
} finally {
await saveData();
} }
}, [data, saveData]); };
const changeBoardProfile = useCallback( const changeBoardProfile = (event: React.ChangeEvent<HTMLInputElement>) => {
(event: React.ChangeEvent<HTMLInputElement>) => { const boardProfile = event.target.value;
const boardProfile = event.target.value; updateFormValue(event);
updateFormValue(event); if (boardProfile === 'CUSTOM') {
if (boardProfile === 'CUSTOM') { updateDataValue({
updateDataValue({ ...data,
...data, board_profile: boardProfile
board_profile: boardProfile });
}); } else {
} else { void updateBoardProfile(boardProfile);
void updateBoardProfile(boardProfile); }
} };
},
[data, updateBoardProfile, updateFormValue, updateDataValue]
);
const restart = useCallback(async () => { const restart = async () => {
await validateAndSubmit(); await validateAndSubmit();
await doRestart(); await doRestart();
}, [validateAndSubmit, doRestart]); };
// Memoize board profile select items to prevent recreation const sendmail = async () => {
const boardProfileItems = useMemo(() => boardProfileSelectItems(), []); 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 = () => { const content = () => {
if (!data || !hardwareData) { if (!data || !hardwareData) {
@@ -190,7 +188,19 @@ const ApplicationSettings = () => {
return ( 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()} {LL.SERVICES()}
</Typography> </Typography>
<Typography color="secondary">API</Typography> <Typography color="secondary">API</Typography>
@@ -351,6 +361,148 @@ const ApplicationSettings = () => {
</Grid> </Grid>
</Grid> </Grid>
)} )}
<Typography color="secondary">eMail</Typography>
<BlockFormControlLabel
control={
<Checkbox
checked={data.email_enabled}
onChange={updateFormValue}
name="email_enabled"
disabled={!hardwareData.psram}
/>
}
label={
<Typography color={!hardwareData.psram ? 'grey' : 'default'}>
Enable eMail notification
{!hardwareData.psram && (
<Typography variant="caption">
&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"> <Typography sx={{ pb: 1, pt: 2 }} variant="h6" color="primary">
{LL.SENSORS()} {LL.SENSORS()}
</Typography> </Typography>
@@ -751,6 +903,16 @@ const ApplicationSettings = () => {
} }
label={LL.DEVELOPER_MODE()} label={LL.DEVELOPER_MODE()}
/> />
<BlockFormControlLabel
control={
<Checkbox
checked={data.disable_reset}
onChange={updateFormValue}
name="disable_reset"
/>
}
label={LL.DISABLE_RESET()}
/>
<BlockFormControlLabel <BlockFormControlLabel
control={ control={
<Checkbox <Checkbox
+26 -36
View File
@@ -1,5 +1,4 @@
import { useCallback, useMemo, useState } from 'react'; import { useState } from 'react';
import { toast } from 'react-toastify';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
import DownloadIcon from '@mui/icons-material/GetApp'; import DownloadIcon from '@mui/icons-material/GetApp';
@@ -27,6 +26,7 @@ import {
SingleUpload, SingleUpload,
useLayoutTitle useLayoutTitle
} from 'components'; } from 'components';
import { toast } from 'components/toast';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import { saveFile } from 'utils'; import { saveFile } from 'utils';
@@ -57,7 +57,7 @@ const DownloadUpload = () => {
const { data, send: loadData, error } = useRequest(SystemApi.readSystemStatus); const { data, send: loadData, error } = useRequest(SystemApi.readSystemStatus);
const doRestart = useCallback(async () => { const doRestart = async () => {
setRestarting(true); setRestarting(true);
try { try {
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }); await sendAPI({ device: 'system', cmd: 'restart', id: 0 });
@@ -65,16 +65,33 @@ const DownloadUpload = () => {
toast.error((error as Error).message); toast.error((error as Error).message);
setRestarting(false); setRestarting(false);
} }
}, [sendAPI]); };
useLayoutTitle(LL.DOWNLOAD_UPLOAD()); useLayoutTitle(LL.DOWNLOAD_UPLOAD());
const handleCloseBackupDialog = useCallback(() => { const handleCloseBackupDialog = () => {
setConfirmBackup(false); 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 <Dialog
sx={dialogStyle} sx={dialogStyle}
open={confirmBackup} open={confirmBackup}
@@ -98,40 +115,13 @@ const DownloadUpload = () => {
<Button <Button
startIcon={<DownloadIcon />} startIcon={<DownloadIcon />}
variant="outlined" variant="outlined"
onClick={() => handleDownload('systembackup')()} onClick={handleDownload('systembackup')}
color="primary" color="primary"
> >
{LL.DOWNLOAD(0)} {LL.DOWNLOAD(0)}
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </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"> <Typography sx={{ pb: 2 }} variant="h6" color="primary">
{LL.DOWNLOAD(0)} {LL.DOWNLOAD(0)}
+34 -46
View File
@@ -1,5 +1,4 @@
import { useCallback, useMemo, useState } from 'react'; import { useState } from 'react';
import { toast } from 'react-toastify';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
import SettingsBackupRestoreIcon from '@mui/icons-material/SettingsBackupRestore'; import SettingsBackupRestoreIcon from '@mui/icons-material/SettingsBackupRestore';
@@ -17,7 +16,6 @@ import {
import * as MqttApi from 'api/mqtt'; import * as MqttApi from 'api/mqtt';
import type { ValidateFieldsError } from 'async-validator';
import { import {
BlockFormControlLabel, BlockFormControlLabel,
BlockNavigation, BlockNavigation,
@@ -28,10 +26,12 @@ import {
ValidatedTextField, ValidatedTextField,
useLayoutTitle useLayoutTitle
} from 'components'; } from 'components';
import { toast } from 'components/toast';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import type { MqttSettingsType } from 'types'; import type { MqttSettingsType } from 'types';
import { numberValue, updateValueDirty, useRest } from 'utils'; import { numberValue, updateValueDirty, useRest } from 'utils';
import { ValidationError, createMqttSettingsValidator, validate } from 'validators'; import { ValidationError, createMqttSettingsValidator, validate } from 'validators';
import type { ValidateFieldsError } from 'validators/schema';
import { callAction } from '../../api/app'; import { callAction } from '../../api/app';
@@ -57,7 +57,7 @@ const MqttSettings = () => {
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>(); const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const sendResetMQTT = useCallback(() => { const sendResetMQTT = () => {
void callAction({ action: 'resetMQTT' }) void callAction({ action: 'resetMQTT' })
.then(() => { .then(() => {
toast.success('MQTT ' + LL.REFRESH() + ' successful'); toast.success('MQTT ' + LL.REFRESH() + ' successful');
@@ -65,29 +65,20 @@ const MqttSettings = () => {
.catch((error) => { .catch((error) => {
toast.error(String(error.error?.message || 'An error occurred')); toast.error(String(error.error?.message || 'An error occurred'));
}); });
}, []); };
const updateFormValue = useMemo( const updateFormValue = updateValueDirty(
() => origData as unknown as Record<string, unknown>,
updateValueDirty( dirtyFlags,
origData as unknown as Record<string, unknown>, setDirtyFlags,
dirtyFlags, updateDataValue as (value: unknown) => void
setDirtyFlags,
updateDataValue as (value: unknown) => void
),
[origData, dirtyFlags, setDirtyFlags, updateDataValue]
); );
const SecondsInputProps = useMemo( const SecondsInputProps = {
() => ({ endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment> };
}),
[LL]
);
const emptyFieldErrors = useMemo(() => ({}), []); const validateAndSubmit = async () => {
const validateAndSubmit = useCallback(async () => {
if (!data) return; if (!data) return;
try { try {
setFieldErrors(undefined); setFieldErrors(undefined);
@@ -96,25 +87,22 @@ const MqttSettings = () => {
} catch (error) { } catch (error) {
setFieldErrors((error as ValidationError).fieldErrors); setFieldErrors((error as ValidationError).fieldErrors);
} }
}, [data, saveData]); };
const publishIntervalFields = useMemo( const publishIntervalFields = [
() => [ { name: 'publish_time_heartbeat', label: 'Heartbeat', validated: true },
{ name: 'publish_time_heartbeat', label: 'Heartbeat', validated: true }, { name: 'publish_time_boiler', label: LL.MQTT_INT_BOILER(), validated: false },
{ name: 'publish_time_boiler', label: LL.MQTT_INT_BOILER(), validated: false }, {
{ name: 'publish_time_thermostat',
name: 'publish_time_thermostat', label: LL.MQTT_INT_THERMOSTATS(),
label: LL.MQTT_INT_THERMOSTATS(), validated: false
validated: false },
}, { name: 'publish_time_solar', label: LL.MQTT_INT_SOLAR(), 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_mixer', label: LL.MQTT_INT_MIXER(), validated: false }, { name: 'publish_time_water', label: LL.MQTT_INT_WATER(), 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_sensor', label: LL.SENSORS(), validated: false }, { name: 'publish_time_other', label: LL.DEFAULT(0), validated: false }
{ name: 'publish_time_other', label: LL.DEFAULT(0), validated: false } ];
],
[LL]
);
if (!data) { if (!data) {
return ( return (
@@ -154,7 +142,7 @@ const MqttSettings = () => {
<Grid container spacing={2} rowSpacing={0}> <Grid container spacing={2} rowSpacing={0}>
<Grid> <Grid>
<ValidatedTextField <ValidatedTextField
fieldErrors={fieldErrors ?? emptyFieldErrors} fieldErrors={fieldErrors ?? {}}
name="host" name="host"
label={LL.ADDRESS_OF(LL.BROKER())} label={LL.ADDRESS_OF(LL.BROKER())}
multiline multiline
@@ -166,7 +154,7 @@ const MqttSettings = () => {
</Grid> </Grid>
<Grid> <Grid>
<ValidatedTextField <ValidatedTextField
fieldErrors={fieldErrors ?? emptyFieldErrors} fieldErrors={fieldErrors ?? {}}
name="port" name="port"
label="Port" label="Port"
variant="outlined" variant="outlined"
@@ -178,7 +166,7 @@ const MqttSettings = () => {
</Grid> </Grid>
<Grid> <Grid>
<ValidatedTextField <ValidatedTextField
fieldErrors={fieldErrors ?? emptyFieldErrors} fieldErrors={fieldErrors ?? {}}
name="base" name="base"
label={LL.BASE_TOPIC()} label={LL.BASE_TOPIC()}
variant="outlined" variant="outlined"
@@ -219,7 +207,7 @@ const MqttSettings = () => {
</Grid> </Grid>
<Grid> <Grid>
<ValidatedTextField <ValidatedTextField
fieldErrors={fieldErrors ?? emptyFieldErrors} fieldErrors={fieldErrors ?? {}}
name="keep_alive" name="keep_alive"
label="Keep Alive" label="Keep Alive"
slotProps={{ slotProps={{
@@ -438,7 +426,7 @@ const MqttSettings = () => {
<Grid key={field.name}> <Grid key={field.name}>
{field.validated ? ( {field.validated ? (
<ValidatedTextField <ValidatedTextField
fieldErrors={fieldErrors ?? emptyFieldErrors} fieldErrors={fieldErrors ?? {}}
name={field.name} name={field.name}
label={field.label} label={field.label}
slotProps={{ slotProps={{
+43 -81
View File
@@ -1,5 +1,4 @@
import { useCallback, useMemo, useState } from 'react'; import { useState } from 'react';
import { toast } from 'react-toastify';
import AccessTimeIcon from '@mui/icons-material/AccessTime'; import AccessTimeIcon from '@mui/icons-material/AccessTime';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
@@ -24,7 +23,6 @@ import { readNTPSettings } from 'api/ntp';
import { dialogStyle } from 'CustomTheme'; import { dialogStyle } from 'CustomTheme';
import { useRequest } from 'alova/client'; import { useRequest } from 'alova/client';
import { updateState } from 'alova/client'; import { updateState } from 'alova/client';
import type { ValidateFieldsError } from 'async-validator';
import { import {
BlockFormControlLabel, BlockFormControlLabel,
BlockNavigation, BlockNavigation,
@@ -34,11 +32,13 @@ import {
ValidatedTextField, ValidatedTextField,
useLayoutTitle useLayoutTitle
} from 'components'; } from 'components';
import { toast } from 'components/toast';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import type { NTPSettingsType, Time } from 'types'; import type { NTPSettingsType, Time } from 'types';
import { formatLocalDateTime, updateValueDirty, useRest } from 'utils'; import { formatLocalDateTime, updateValueDirty, useRest } from 'utils';
import { ValidationError, validate } from 'validators'; import { ValidationError, validate } from 'validators';
import { NTP_SETTINGS_VALIDATOR } from 'validators/ntp'; import { NTP_SETTINGS_VALIDATOR } from 'validators/ntp';
import type { ValidateFieldsError } from 'validators/schema';
import { import {
TIME_ZONES, TIME_ZONES,
@@ -67,19 +67,16 @@ const NTPSettings = () => {
const { LL } = useI18nContext(); const { LL } = useI18nContext();
useLayoutTitle('NTP'); useLayoutTitle('NTP');
// Memoized timezone select items for better performance
const timeZoneItems = useTimeZoneSelectItems(); const timeZoneItems = useTimeZoneSelectItems();
const timeZoneItemsT = timeZoneSelectItemsT(); const timeZoneItemsT = timeZoneSelectItemsT();
// Memoized selected timezone value const selectedTzValue = data
const selectedTzValue = useMemo( ? selectedTimeZone(data.tz_label, data.tz_format)
() => (data ? selectedTimeZone(data.tz_label, data.tz_format) : undefined), : undefined;
[data?.tz_label, data?.tz_format]
); const selectedTzValueT = data
const selectedTzValueT = useMemo( ? selectedTimeZone(data.tz_label_t, data.tz_format_t)
() => (data ? selectedTimeZone(data.tz_label_t, data.tz_format_t) : undefined), : undefined;
[data?.tz_label_t, data?.tz_format_t]
);
const [localTime, setLocalTime] = useState<string>(''); const [localTime, setLocalTime] = useState<string>('');
const [settingTime, setSettingTime] = useState<boolean>(false); const [settingTime, setSettingTime] = useState<boolean>(false);
const [processing, setProcessing] = useState<boolean>(false); const [processing, setProcessing] = useState<boolean>(false);
@@ -92,32 +89,22 @@ const NTPSettings = () => {
} }
); );
// Memoize updateFormValue to prevent recreation on every render const updateFormValue = updateValueDirty(
const updateFormValue = useMemo( origData as unknown as Record<string, unknown>,
() => dirtyFlags,
updateValueDirty( setDirtyFlags,
origData as unknown as Record<string, unknown>, updateDataValue as (value: unknown) => void
dirtyFlags,
setDirtyFlags,
updateDataValue as (value: unknown) => void
),
[origData, dirtyFlags, setDirtyFlags, updateDataValue]
); );
// Memoize updateLocalTime handler const updateLocalTime = (event: React.ChangeEvent<HTMLInputElement>) =>
const updateLocalTime = useCallback( setLocalTime(event.target.value);
(event: React.ChangeEvent<HTMLInputElement>) => setLocalTime(event.target.value),
[]
);
// Memoize openSetTime handler const openSetTime = () => {
const openSetTime = useCallback(() => {
setLocalTime(formatLocalDateTime(new Date())); setLocalTime(formatLocalDateTime(new Date()));
setSettingTime(true); setSettingTime(true);
}, []); };
// Memoize configureTime handler const configureTime = async () => {
const configureTime = useCallback(async () => {
setProcessing(true); setProcessing(true);
try { try {
@@ -130,13 +117,11 @@ const NTPSettings = () => {
} finally { } finally {
setProcessing(false); setProcessing(false);
} }
}, [localTime, updateTime, LL, loadData]); };
// Memoize close dialog handler const handleCloseSetTime = () => setSettingTime(false);
const handleCloseSetTime = useCallback(() => setSettingTime(false), []);
// Memoize validate and submit handler const validateAndSubmit = async () => {
const validateAndSubmit = useCallback(async () => {
if (!data) return; if (!data) return;
try { try {
setFieldErrors(undefined); setFieldErrors(undefined);
@@ -145,35 +130,27 @@ const NTPSettings = () => {
} catch (error) { } catch (error) {
setFieldErrors((error as ValidationError).fieldErrors); setFieldErrors((error as ValidationError).fieldErrors);
} }
}, [data, saveData]); };
// Memoize timezone change handler const changeTimeZone = (event: React.ChangeEvent<HTMLInputElement>) => {
const changeTimeZone = useCallback( void updateState(readNTPSettings(), (settings: NTPSettingsType) => ({
(event: React.ChangeEvent<HTMLInputElement>) => { ...settings,
void updateState(readNTPSettings(), (settings: NTPSettingsType) => ({ tz_label: event.target.value,
...settings, tz_format: TIME_ZONES[event.target.value]
tz_label: event.target.value, }));
tz_format: TIME_ZONES[event.target.value] updateFormValue(event);
})); };
updateFormValue(event);
},
[updateFormValue]
);
const changeTimeZoneT = useCallback( const changeTimeZoneT = (event: React.ChangeEvent<HTMLInputElement>) => {
(event: React.ChangeEvent<HTMLInputElement>) => { void updateState(readNTPSettings(), (settings: NTPSettingsType) => ({
void updateState(readNTPSettings(), (settings: NTPSettingsType) => ({ ...settings,
...settings, tz_label_t: event.target.value,
tz_label_t: event.target.value, tz_format_t: TIME_ZONES[event.target.value]
tz_format_t: TIME_ZONES[event.target.value] }));
})); updateFormValue(event);
updateFormValue(event); };
},
[updateFormValue]
);
// Memoize render content to prevent unnecessary re-renders const renderContent = () => {
const renderContent = useMemo(() => {
if (!data) { if (!data) {
return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />; 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 ( return (
<SectionContent> <SectionContent>
{blocker ? <BlockNavigation blocker={blocker} /> : null} {blocker ? <BlockNavigation blocker={blocker} /> : null}
{renderContent} {renderContent()}
<Dialog sx={dialogStyle} open={settingTime} onClose={handleCloseSetTime}> <Dialog sx={dialogStyle} open={settingTime} onClose={handleCloseSetTime}>
<DialogTitle>{LL.SET_TIME(1)}</DialogTitle> <DialogTitle>{LL.SET_TIME(1)}</DialogTitle>
<DialogContent dividers> <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 AccessTimeIcon from '@mui/icons-material/AccessTime';
import CancelIcon from '@mui/icons-material/Cancel'; import BuildIcon from '@mui/icons-material/Build';
import DeviceHubIcon from '@mui/icons-material/DeviceHub'; import DeviceHubIcon from '@mui/icons-material/DeviceHub';
import ImportExportIcon from '@mui/icons-material/ImportExport'; import ImportExportIcon from '@mui/icons-material/ImportExport';
import LockIcon from '@mui/icons-material/Lock'; import LockIcon from '@mui/icons-material/Lock';
import SettingsBackupRestoreIcon from '@mui/icons-material/SettingsBackupRestore';
import SettingsEthernetIcon from '@mui/icons-material/SettingsEthernet'; import SettingsEthernetIcon from '@mui/icons-material/SettingsEthernet';
import SettingsInputAntennaIcon from '@mui/icons-material/SettingsInputAntenna'; import SettingsInputAntennaIcon from '@mui/icons-material/SettingsInputAntenna';
import TuneIcon from '@mui/icons-material/Tune'; import TuneIcon from '@mui/icons-material/Tune';
import ViewModuleIcon from '@mui/icons-material/ViewModule'; import ViewModuleIcon from '@mui/icons-material/ViewModule';
import { import { List } from '@mui/material';
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Divider,
List
} from '@mui/material';
import { API } from 'api/app';
import { dialogStyle } from 'CustomTheme';
import { useRequest } from 'alova/client';
import type { APIcall } from 'app/main/types';
import { SectionContent, useLayoutTitle } from 'components'; import { SectionContent, useLayoutTitle } from 'components';
import ListMenuItem from 'components/layout/ListMenuItem'; import ListMenuItem from 'components/layout/ListMenuItem';
import { AuthenticatedContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import SystemMonitor from '../status/SystemMonitor';
const Settings = () => { const Settings = () => {
const { LL } = useI18nContext(); const { LL } = useI18nContext();
const { versions } = useContext(AuthenticatedContext);
useLayoutTitle(LL.SETTINGS(0)); useLayoutTitle(LL.SETTINGS(0));
const [confirmFactoryReset, setConfirmFactoryReset] = useState(false); const upgradeAvailable = versions?.current?.upgradeable ?? false;
const [restarting, setRestarting] = useState<boolean>(); const firmwareText = versions?.current?.version
? `v${versions.current.version}${upgradeAvailable ? ` (${LL.UPDATE_AVAILABLE()})` : ''}`
: '';
const { send: sendAPI } = useRequest((data: APIcall) => API(data), { return (
immediate: false <SectionContent>
}); <List>
<ListMenuItem
icon={BuildIcon}
bgcolor="#72caf9"
label="EMS-ESP Firmware"
text={firmwareText}
to="/settings/version"
badge={upgradeAvailable}
/>
const doFormat = useCallback(async () => { <ListMenuItem
await sendAPI({ device: 'system', cmd: 'format', id: 0 }).then(() => { icon={TuneIcon}
setRestarting(true); bgcolor="#134ba2"
setConfirmFactoryReset(false); label={LL.APPLICATION()}
}); text={LL.APPLICATION_SETTINGS_1()}
}, [sendAPI]); to="application"
/>
const handleFactoryResetClose = useCallback(() => { <ListMenuItem
setConfirmFactoryReset(false); icon={SettingsEthernetIcon}
}, []); bgcolor="#40828f"
label={LL.NETWORK(0)}
text={LL.CONFIGURE(LL.SETTINGS_OF(LL.NETWORK(1)))}
to="network"
/>
const handleFactoryResetClick = useCallback(() => { <ListMenuItem
setConfirmFactoryReset(true); icon={SettingsInputAntennaIcon}
}, []); bgcolor="#5f9a5f"
label={LL.ACCESS_POINT(0)}
text={LL.CONFIGURE(LL.ACCESS_POINT(1))}
to="ap"
/>
const content = useMemo(() => { <ListMenuItem
return ( icon={AccessTimeIcon}
<> bgcolor="#c5572c"
<List> label="NTP"
<ListMenuItem text={LL.CONFIGURE(LL.LOCAL_TIME(1))}
icon={TuneIcon} to="ntp"
bgcolor="#134ba2" />
label={LL.APPLICATION()}
text={LL.APPLICATION_SETTINGS_1()}
to="application"
/>
<ListMenuItem <ListMenuItem
icon={SettingsEthernetIcon} icon={DeviceHubIcon}
bgcolor="#40828f" bgcolor="#68374d"
label={LL.NETWORK(0)} label="MQTT"
text={LL.CONFIGURE(LL.SETTINGS_OF(LL.NETWORK(1)))} text={LL.CONFIGURE('MQTT')}
to="network" to="mqtt"
/> />
<ListMenuItem <ListMenuItem
icon={SettingsInputAntennaIcon} icon={LockIcon}
bgcolor="#5f9a5f" label={LL.SECURITY(0)}
label={LL.ACCESS_POINT(0)} text={LL.SECURITY_1()}
text={LL.CONFIGURE(LL.ACCESS_POINT(1))} to="security"
to="ap" />
/>
<ListMenuItem <ListMenuItem
icon={AccessTimeIcon} icon={ViewModuleIcon}
bgcolor="#c5572c" bgcolor="#efc34b"
label="NTP" label={LL.MODULES()}
text={LL.CONFIGURE(LL.LOCAL_TIME(1))} text={LL.MODULES_1()}
to="ntp" to="modules"
/> />
<ListMenuItem <ListMenuItem
icon={DeviceHubIcon} icon={ImportExportIcon}
bgcolor="#68374d" bgcolor="#5d89f7"
label="MQTT" label={LL.DOWNLOAD_UPLOAD()}
text={LL.CONFIGURE('MQTT')} text={LL.DOWNLOAD_UPLOAD_1()}
to="mqtt" to="downloadUpload"
/> />
</List>
<ListMenuItem </SectionContent>
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>;
}; };
export default Settings; export default Settings;
+4 -16
View File
@@ -1,5 +1,3 @@
import { useMemo } from 'react';
import { MenuItem } from '@mui/material'; import { MenuItem } from '@mui/material';
export const TIME_ZONES: Record<string, string> = { 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; 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) => ( const precomputedTimeZoneItems = TIME_ZONE_LABELS.map((label) => (
<MenuItem key={label} value={label}> <MenuItem key={label} value={label}>
{label} {label}
</MenuItem> </MenuItem>
)); ));
export function useTimeZoneSelectItems() {
return precomputedTimeZoneItems;
}
export function timeZoneSelectItems() { export function timeZoneSelectItems() {
return precomputedTimeZoneItems; return precomputedTimeZoneItems;
} }
+944
View File
@@ -0,0 +1,944 @@
import { memo, useContext, useMemo, useState } from 'react';
import { Link } from 'react-router';
import CancelIcon from '@mui/icons-material/Cancel';
import CloseIcon from '@mui/icons-material/Close';
import CheckIcon from '@mui/icons-material/Done';
import DownloadIcon from '@mui/icons-material/GetApp';
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
import PowerSettingsNewIcon from '@mui/icons-material/PowerSettingsNew';
import SettingsBackupRestoreIcon from '@mui/icons-material/SettingsBackupRestore';
import WarningIcon from '@mui/icons-material/Warning';
import {
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Grid,
IconButton,
Table,
TableBody,
TableCell,
TableRow,
Typography
} from '@mui/material';
import * as SystemApi from 'api/system';
import { API, callAction } from 'api/app';
import { dialogStyle } from 'CustomTheme';
import { useRequest } from 'alova/client';
import type { APIcall } from 'app/main/types';
import SystemMonitor from 'app/status/SystemMonitor';
import {
FormLoader,
SectionContent,
SingleUpload,
useLayoutTitle
} from 'components';
import { toast } from 'components/toast';
import { AuthenticatedContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react';
import type { TranslationFunctions } from 'i18n/i18n-types';
import type { VersionInfo } from 'types';
import { prettyDateTime } from 'utils/time';
// Constants moved outside component to avoid recreation
const STABLE_URL = 'https://github.com/emsesp/EMS-ESP32/releases/download/';
const STABLE_RELNOTES_URL =
'https://github.com/emsesp/EMS-ESP32/blob/main/CHANGELOG.md';
const DEV_URL = 'https://github.com/emsesp/EMS-ESP32/releases/download/latest/';
const DEV_RELNOTES_URL =
'https://github.com/emsesp/EMS-ESP32/blob/dev/CHANGELOG_LATEST.md';
// Types for better type safety
interface PartitionData {
partition: string;
version: string;
install_date?: string;
size: number;
}
interface VersionData {
emsesp_version: string;
arduino_version: string;
esp_platform: string;
flash_chip_size: number;
psram: boolean;
build_flags?: string;
partition: string;
partitions: PartitionData[];
developer_mode: boolean;
disable_reset: boolean;
}
// Memoized components for better performance
const VersionInfoDialog = memo(
({
showVersionInfo,
latestVersion,
latestDevVersion,
partitionVersion,
partition,
currentPartition,
size,
locale,
LL,
onClose
}: {
showVersionInfo: number;
latestVersion: VersionInfo | undefined;
latestDevVersion: VersionInfo | undefined;
partitionVersion: VersionInfo | undefined;
partition: string;
currentPartition: string;
size: number;
locale: string;
LL: TranslationFunctions;
onClose: () => void;
}) => {
if (showVersionInfo === 0) return null;
const isStable = showVersionInfo === 1;
const isDev = showVersionInfo === 2;
const isPartition = showVersionInfo === 3;
const version = isStable
? latestVersion
: isDev
? latestDevVersion
: partitionVersion;
const relNotesUrl = isStable
? STABLE_RELNOTES_URL
: isDev
? DEV_RELNOTES_URL
: '';
return (
<Dialog sx={dialogStyle} open={showVersionInfo !== 0} onClose={onClose}>
<DialogTitle>{LL.FIRMWARE_VERSION_INFO()}</DialogTitle>
<DialogContent dividers>
<Table size="small" sx={{ borderCollapse: 'collapse', minWidth: 0 }}>
<TableBody>
<TableRow sx={{ height: 24, borderBottom: 'none' }}>
<TableCell
component="th"
scope="row"
sx={{
color: 'lightblue',
borderBottom: 'none',
pr: 1,
py: 0.5,
fontSize: 13
}}
>
{LL.VERSION()}
</TableCell>
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
{isPartition
? typeof version === 'string'
? version
: version?.version
: version?.version}
</TableCell>
</TableRow>
<TableRow sx={{ height: 24, borderBottom: 'none' }}>
<TableCell
component="th"
scope="row"
sx={{
color: 'lightblue',
borderBottom: 'none',
pr: 1,
py: 0.5,
fontSize: 13,
width: 140
}}
>
{isPartition ? LL.TYPE(0) : LL.RELEASE_TYPE()}
</TableCell>
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
{partition === currentPartition && LL.ACTIVE() + ' '}
{isStable
? LL.STABLE()
: isDev
? LL.DEVELOPMENT()
: 'Partition ' + LL.VERSION()}
</TableCell>
</TableRow>
{isPartition && (
<TableRow sx={{ height: 24, borderBottom: 'none' }}>
<TableCell
component="th"
scope="row"
sx={{
color: 'lightblue',
borderBottom: 'none',
pr: 1,
py: 0.5,
fontSize: 13
}}
>
Partition
</TableCell>
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
{partition}
</TableCell>
</TableRow>
)}
{isPartition && (
<TableRow sx={{ height: 24, borderBottom: 'none' }}>
<TableCell
component="th"
scope="row"
sx={{
color: 'lightblue',
borderBottom: 'none',
pr: 1,
py: 0.5,
fontSize: 13
}}
>
Size
</TableCell>
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
{size} KB
</TableCell>
</TableRow>
)}
{version && version.date && (
<TableRow sx={{ height: 24, borderBottom: 'none' }}>
<TableCell
component="th"
scope="row"
sx={{
color: 'lightblue',
borderBottom: 'none',
pr: 1,
py: 0.5,
fontSize: 13
}}
>
{isPartition ? 'Install Date' : 'Build Date'}
</TableCell>
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
{prettyDateTime(locale, new Date(version.date))}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</DialogContent>
<DialogActions>
{!isPartition && (
<Button
variant="outlined"
component="a"
href={relNotesUrl}
target="_blank"
color="primary"
>
Changelog
</Button>
)}
<Button variant="outlined" onClick={onClose} color="secondary">
{LL.CLOSE()}
</Button>
</DialogActions>
</Dialog>
);
}
);
const InstallDialog = memo(
({
openInstallDialog,
fetchDevVersion,
latestVersion,
latestDevVersion,
upgradeImportantMessageType,
platform,
LL,
onClose,
onInstall
}: {
openInstallDialog: boolean;
fetchDevVersion: boolean;
latestVersion: VersionInfo | undefined;
latestDevVersion: VersionInfo | undefined;
upgradeImportantMessageType: number;
platform: string;
LL: TranslationFunctions;
onClose: () => void;
onInstall: (url: string) => void;
}) => {
const binURL = (() => {
if (!latestVersion || !latestDevVersion) return '';
const version = fetchDevVersion ? latestDevVersion : latestVersion;
const filename = `EMS-ESP-${version.version.replaceAll('.', '_')}-${platform}.bin`;
return fetchDevVersion
? `${DEV_URL}${filename}`
: `${STABLE_URL}v${version.version}/${filename}`;
})();
return (
<Dialog sx={dialogStyle} open={openInstallDialog} onClose={onClose}>
<DialogTitle>
{`${LL.INSTALL()} ${fetchDevVersion ? LL.DEVELOPMENT() : LL.STABLE()} Firmware`}
</DialogTitle>
<DialogContent dividers>
<Typography sx={{ mb: 2 }}>
{LL.INSTALL_VERSION(
LL.INSTALL(),
fetchDevVersion ? latestDevVersion?.version : latestVersion?.version
)}
</Typography>
{upgradeImportantMessageType === 2 && LL.UPGRADE_IMPORTANT_MESSAGES_2()}
{upgradeImportantMessageType === 1 && (
<>
{LL.UPGRADE_IMPORTANT_MESSAGES_1()}
<Typography sx={{ mt: 2 }}>
<Link to="/settings/downloadUpload" style={{ color: 'lightblue' }}>
{LL.DOWNLOAD_SYSTEM_BACKUP()}
</Link>
</Typography>
</>
)}
<Typography sx={{ mt: 2 }}>
<Link
to="https://docs.emsesp.org/FAQ#upgrading-the-firmware"
target="_blank"
rel="noreferrer"
style={{ color: 'lightblue' }}
>
{LL.ONLINE_HELP()}
</Link>
</Typography>
</DialogContent>
<DialogActions>
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={onClose}
color="secondary"
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<DownloadIcon />}
variant="outlined"
onClick={onClose}
color="primary"
>
<Link
to={binURL}
target="_blank"
rel="noreferrer"
style={{ color: 'lightblue', textDecoration: 'none' }}
>
{LL.DOWNLOAD(0)}
</Link>
</Button>
<Button
startIcon={<WarningIcon color="warning" />}
variant="outlined"
onClick={() => onInstall(binURL)}
color="primary"
>
{LL.INSTALL()}
</Button>
</DialogActions>
</Dialog>
);
}
);
const InstallPartitionDialog = memo(
({
openInstallPartitionDialog,
version,
partition,
LL,
onClose,
onInstall
}: {
openInstallPartitionDialog: boolean;
version: string;
partition: string;
LL: TranslationFunctions;
onClose: () => void;
onInstall: (partition: string) => void;
}) => {
return (
<Dialog sx={dialogStyle} open={openInstallPartitionDialog} onClose={onClose}>
<DialogTitle>
{LL.INSTALL()} {LL.STORED_VERSIONS()}
</DialogTitle>
<DialogContent dividers>
<Typography sx={{ mb: 2 }}>
{LL.INSTALL_VERSION(LL.INSTALL(), version)}
</Typography>
</DialogContent>
<DialogActions>
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={onClose}
color="secondary"
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<WarningIcon color="warning" />}
variant="outlined"
onClick={() => onInstall(partition)}
color="primary"
>
{LL.INSTALL()}
</Button>
</DialogActions>
</Dialog>
);
}
);
// Helper function moved outside component
const getPlatform = (data: VersionData): string => {
return `${data.esp_platform}-${data.flash_chip_size >= 16384 ? '16MB' : '4MB'}${data.psram ? '+' : ''}`;
};
const Version = () => {
const { LL, locale } = useI18nContext();
const { me, versions } = useContext(AuthenticatedContext);
const [restarting, setRestarting] = useState<boolean>(false);
const [confirmFactoryReset, setConfirmFactoryReset] = useState<boolean>(false);
const [confirmRestart, setConfirmRestart] = useState<boolean>(false);
const [openInstallDialog, setOpenInstallDialog] = useState<boolean>(false);
const [partitionVersion, setPartitionVersion] = useState<VersionInfo | undefined>(
undefined
);
const [partition, setPartition] = useState<string>('');
const [openInstallPartitionDialog, setOpenInstallPartitionDialog] =
useState<boolean>(false);
const [fetchDevVersion, setFetchDevVersion] = useState<boolean>(false);
const [showVersionInfo, setShowVersionInfo] = useState<number>(0); // 1 = stable, 2 = dev, 3 = partition
const [firmwareSize, setFirmwareSize] = useState<number>(0);
const latestVersion = useMemo<VersionInfo | undefined>(
() =>
versions?.stable
? { version: versions.stable.version, date: versions.stable.date }
: undefined,
[versions?.stable]
);
const latestDevVersion = useMemo<VersionInfo | undefined>(
() =>
versions?.dev
? { version: versions.dev.version, date: versions.dev.date }
: undefined,
[versions?.dev]
);
const usingDevVersion = versions?.current?.type === 'dev';
const stableUpgradeAvailable = versions?.stable?.upgradeable ?? false;
const devUpgradeAvailable = versions?.dev?.upgradeable ?? false;
const internetLive = Boolean(versions?.stable || versions?.dev);
const { send: sendSetPartition } = useRequest(
(partition: string) => callAction({ action: 'setPartition', param: partition }),
{ immediate: false }
).onError((error) => {
toast.error(String(error.error?.message || 'An error occurred'));
});
const { data, send: loadData, error } = useRequest(SystemApi.readSystemStatus);
const { send: sendUploadURL } = useRequest(
(url: string) => callAction({ action: 'uploadURL', param: url }),
{ immediate: false }
);
const { send: sendAPI } = useRequest((data: APIcall) => API(data), {
immediate: false
});
const [upgradeImportantMessageType, setUpgradeImportantMessageType] =
useState<number>(0);
const { send: checkUpgradeImportantMessages } = useRequest(
(version: string) =>
callAction({ action: 'upgradeImportantMessages', param: version }),
{
immediate: false
}
)
.onSuccess((event) => {
const upgradeImportantMessageType_n = (
event.data as { upgradeImportantMessageType: number }
).upgradeImportantMessageType;
setUpgradeImportantMessageType(upgradeImportantMessageType_n);
})
.onError((error) => {
toast.error(String(error.error?.message || 'An error occurred'));
});
const platform = data ? getPlatform(data) : '';
const otherPartitions =
data?.partitions.filter((p) => p.partition !== data.partition) ?? [];
const setPartitionVersionInfo = (partition: string) => {
setShowVersionInfo(3);
const partitionData = data?.partitions.find((p) => p.partition === partition);
if (partitionData) {
setPartitionVersion({
version: partitionData.version,
date: partitionData.install_date ?? ''
});
setPartition(partitionData.partition);
setFirmwareSize(partitionData.size);
}
};
const doRestart = async () => {
setConfirmRestart(false);
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
(error: Error) => {
toast.error(error.message);
}
);
setRestarting(true);
};
const doFormat = async () => {
await sendAPI({ device: 'system', cmd: 'format', id: 0 }).then(() => {
setRestarting(true);
setConfirmFactoryReset(false);
});
};
const handleFactoryResetClose = () => setConfirmFactoryReset(false);
const handleFactoryResetClick = () => setConfirmFactoryReset(true);
const handleRestartClose = () => setConfirmRestart(false);
const handleRestartClick = () => setConfirmRestart(true);
const installFirmwareURL = async (url: string) => {
await sendUploadURL(url).catch((error: Error) => {
toast.error(error.message);
});
await doRestart();
};
const installPartitionFirmware = async (partition: string) => {
await sendSetPartition(partition).catch((error: Error) => {
toast.error(error.message);
});
setRestarting(true);
};
const showPartitionDialog = (
version: string,
partition: string,
install_date: string
) => {
setOpenInstallPartitionDialog(true);
setPartitionVersion({ version: version, date: install_date });
setPartition(partition);
};
const showFirmwareDialog = (useDevVersion: boolean) => {
setFetchDevVersion(useDevVersion);
const targetVersion = useDevVersion
? latestDevVersion?.version
: latestVersion?.version;
if (targetVersion) {
void checkUpgradeImportantMessages(targetVersion);
}
setOpenInstallDialog(true);
};
const closeInstallDialog = () => setOpenInstallDialog(false);
const closeInstallPartitionDialog = () => setOpenInstallPartitionDialog(false);
const handleVersionInfoClose = () => {
setShowVersionInfo(0);
setPartitionVersion(undefined);
setPartition('');
};
useLayoutTitle('EMS-ESP Firmware');
const showButtons = (showingDev: boolean) => {
const choice = showingDev
? !usingDevVersion
? LL.SWITCH_RELEASE_TYPE(LL.DEVELOPMENT())
: devUpgradeAvailable
? LL.UPDATE_AVAILABLE()
: undefined
: usingDevVersion
? LL.SWITCH_RELEASE_TYPE(LL.STABLE())
: stableUpgradeAvailable
? LL.UPDATE_AVAILABLE()
: undefined;
if (!choice) {
return (
<>
<CheckIcon
color="success"
sx={{ verticalAlign: 'middle', ml: 0.5, mr: 0.5 }}
/>
<span style={{ color: '#66bb6a', fontSize: '0.8em' }}>
{LL.LATEST_VERSION(usingDevVersion ? LL.DEVELOPMENT() : LL.STABLE())}
</span>
<Button
sx={{ ml: 1 }}
variant="outlined"
size="small"
onClick={() => showFirmwareDialog(showingDev)}
>
{LL.REINSTALL()}
</Button>
</>
);
}
if (!me.admin) return null;
const isUpdateAvailable = choice === LL.UPDATE_AVAILABLE();
return (
<Button
sx={{ ml: 1 }}
variant="outlined"
color={isUpdateAvailable ? 'success' : 'warning'}
size="small"
onClick={() => showFirmwareDialog(showingDev)}
>
{choice}
{isUpdateAvailable && (
<Box
component="span"
aria-label="update available"
sx={{
display: 'inline-block',
width: 8,
height: 8,
ml: 1,
verticalAlign: 'middle',
borderRadius: '50%',
backgroundColor: '#ffeb3b',
boxShadow: '0 0 6px rgba(255, 235, 59, 0.8)'
}}
/>
)}
</Button>
);
};
if (restarting) {
return <SystemMonitor />;
}
if (!data) {
return (
<SectionContent>
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
</SectionContent>
);
}
return (
<SectionContent>
<Box sx={{ p: 2, border: '1px solid #565656', borderRadius: 2 }}>
<Typography sx={{ mb: 1 }} variant="h6" color="primary">
{LL.THIS_VERSION()}
</Typography>
<Grid
container
direction="row"
sx={{
justifyContent: 'flex-start',
alignItems: 'baseline'
}}
>
<Grid size={{ xs: 4, md: 2 }}>
<Typography color="secondary">{LL.VERSION()}</Typography>
</Grid>
<Grid size={{ xs: 8, md: 10 }}>
<Typography>
{data.emsesp_version}
{data.build_flags && (
<Typography variant="caption">
&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">
<WarningIcon color="warning" sx={{ verticalAlign: 'middle', mr: 2 }} />
{LL.INTERNET_CONNECTION_REQUIRED()}
</Typography>
)}
{me.admin && (
<>
<VersionInfoDialog
showVersionInfo={showVersionInfo}
latestVersion={latestVersion}
latestDevVersion={latestDevVersion}
partitionVersion={partitionVersion}
locale={locale}
partition={partition}
currentPartition={data?.partition ?? ''}
size={firmwareSize}
LL={LL}
onClose={handleVersionInfoClose}
/>
<InstallDialog
openInstallDialog={openInstallDialog}
fetchDevVersion={fetchDevVersion}
latestVersion={latestVersion}
latestDevVersion={latestDevVersion}
upgradeImportantMessageType={upgradeImportantMessageType}
platform={platform}
LL={LL}
onClose={closeInstallDialog}
onInstall={installFirmwareURL}
/>
<InstallPartitionDialog
openInstallPartitionDialog={openInstallPartitionDialog}
version={partitionVersion?.version || ''}
partition={partition}
LL={LL}
onClose={closeInstallPartitionDialog}
onInstall={installPartitionFirmware}
/>
<Typography sx={{ pt: 2, pb: 2 }} variant="h6" color="primary">
{LL.UPLOAD()}
</Typography>
<SingleUpload doRestart={doRestart} />
</>
)}
</Box>
{me.admin && (
<>
<Dialog
sx={dialogStyle}
open={confirmFactoryReset}
onClose={handleFactoryResetClose}
>
<DialogTitle>{LL.FACTORY_RESET()}</DialogTitle>
<DialogContent dividers>{LL.SYSTEM_FACTORY_TEXT_DIALOG()}</DialogContent>
<DialogActions>
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={handleFactoryResetClose}
color="secondary"
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<SettingsBackupRestoreIcon />}
variant="outlined"
onClick={doFormat}
color="error"
>
{LL.FACTORY_RESET()}
</Button>
</DialogActions>
</Dialog>
<Dialog
sx={dialogStyle}
open={confirmRestart}
onClose={handleRestartClose}
>
<DialogTitle>{LL.RESTART()}</DialogTitle>
<DialogContent dividers>{LL.RESTART_CONFIRM()}</DialogContent>
<DialogActions>
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={handleRestartClose}
color="secondary"
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<PowerSettingsNewIcon />}
variant="outlined"
onClick={doRestart}
color="error"
>
{LL.RESTART()}
</Button>
</DialogActions>
</Dialog>
<Box
sx={{
mt: 2,
display: 'flex',
justifyContent: 'flex-end',
flexWrap: 'nowrap',
whiteSpace: 'nowrap',
gap: 1
}}
>
<Button
startIcon={<PowerSettingsNewIcon />}
variant="outlined"
onClick={handleRestartClick}
color="error"
>
{LL.RESTART()}
</Button>
{!data.disable_reset && (
<Button
startIcon={<SettingsBackupRestoreIcon />}
variant="outlined"
onClick={handleFactoryResetClick}
color="error"
>
{LL.FACTORY_RESET()}
</Button>
)}
</Box>
</>
)}
</SectionContent>
);
};
export default memo(Version);
+15 -49
View File
@@ -1,12 +1,5 @@
import { memo, useCallback, useMemo, useState } from 'react'; import { memo, useState } from 'react';
import { import { Outlet, useMatch, useNavigate } from 'react-router';
Navigate,
Route,
Routes,
matchRoutes,
useLocation,
useNavigate
} from 'react-router';
import { Tab } from '@mui/material'; import { Tab } from '@mui/material';
@@ -14,52 +7,32 @@ import { RouterTabs, useLayoutTitle } from 'components';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import type { WiFiNetwork } from 'types'; import type { WiFiNetwork } from 'types';
import NetworkSettings from './NetworkSettings';
import { WiFiConnectionContext } from './WiFiConnectionContext'; import { WiFiConnectionContext } from './WiFiConnectionContext';
import WiFiNetworkScanner from './WiFiNetworkScanner';
const Network = () => { const Network = () => {
const { LL } = useI18nContext(); const { LL } = useI18nContext();
useLayoutTitle(LL.NETWORK(0)); useLayoutTitle(LL.NETWORK(0));
// this also works! const routerTab = useMatch('/settings/network/:tab')?.pathname || false;
// 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 navigate = useNavigate(); const navigate = useNavigate();
const [selectedNetwork, setSelectedNetwork] = useState<WiFiNetwork>(); const [selectedNetwork, setSelectedNetwork] = useState<WiFiNetwork>();
const selectNetwork = useCallback( const selectNetwork = (network: WiFiNetwork) => {
(network: WiFiNetwork) => { setSelectedNetwork(network);
setSelectedNetwork(network); void navigate('/settings/network/settings');
void navigate('/settings/network/settings'); };
},
[navigate]
);
const deselectNetwork = useCallback(() => { const deselectNetwork = () => {
setSelectedNetwork(undefined); setSelectedNetwork(undefined);
}, []); };
const contextValue = useMemo( const contextValue = {
() => ({ ...(selectedNetwork && { selectedNetwork }),
...(selectedNetwork && { selectedNetwork }), selectNetwork,
selectNetwork, deselectNetwork
deselectNetwork };
}),
[selectedNetwork, selectNetwork, deselectNetwork]
);
return ( return (
<WiFiConnectionContext.Provider value={contextValue}> <WiFiConnectionContext.Provider value={contextValue}>
@@ -70,14 +43,7 @@ const Network = () => {
/> />
<Tab value="/settings/network/scan" label={LL.NETWORK_SCAN()} /> <Tab value="/settings/network/scan" label={LL.NETWORK_SCAN()} />
</RouterTabs> </RouterTabs>
<Routes> <Outlet />
<Route path="scan" element={<WiFiNetworkScanner />} />
<Route path="settings" element={<NetworkSettings />} />
<Route
path="*"
element={<Navigate replace to="/settings/network/settings" />}
/>
</Routes>
</WiFiConnectionContext.Provider> </WiFiConnectionContext.Provider>
); );
}; };
@@ -1,5 +1,4 @@
import { memo, useCallback, useContext, useEffect, useState } from 'react'; import { memo, useCallback, useContext, useEffect, useState } from 'react';
import { toast } from 'react-toastify';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
import DeleteIcon from '@mui/icons-material/Delete'; import DeleteIcon from '@mui/icons-material/Delete';
@@ -26,7 +25,6 @@ import { API } from 'api/app';
import { updateState, useRequest } from 'alova/client'; import { updateState, useRequest } from 'alova/client';
import type { APIcall } from 'app/main/types'; import type { APIcall } from 'app/main/types';
import type { ValidateFieldsError } from 'async-validator';
import { import {
BlockFormControlLabel, BlockFormControlLabel,
BlockNavigation, BlockNavigation,
@@ -37,11 +35,13 @@ import {
ValidatedPasswordField, ValidatedPasswordField,
ValidatedTextField ValidatedTextField
} from 'components'; } from 'components';
import { toast } from 'components/toast';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import type { NetworkSettingsType } from 'types'; import type { NetworkSettingsType } from 'types';
import { updateValueDirty, useRest } from 'utils'; import { updateValueDirty, useRest } from 'utils';
import { ValidationError, validate } from 'validators'; import { ValidationError, validate } from 'validators';
import { createNetworkSettingsValidator } from 'validators/network'; import { createNetworkSettingsValidator } from 'validators/network';
import type { ValidateFieldsError } from 'validators/schema';
import SystemMonitor from '../../status/SystemMonitor'; import SystemMonitor from '../../status/SystemMonitor';
import { WiFiConnectionContext } from './WiFiConnectionContext'; import { WiFiConnectionContext } from './WiFiConnectionContext';
@@ -89,7 +89,7 @@ const NetworkSettings = () => {
static_ip_config: false, static_ip_config: false,
bandwidth20: false, bandwidth20: false,
tx_power: 0, tx_power: 0,
nosleep: false, nosleep: true,
enableMDNS: true, enableMDNS: true,
enableCORS: false, enableCORS: false,
CORSOrigin: '*' CORSOrigin: '*'
@@ -121,19 +121,19 @@ const NetworkSettings = () => {
deselectNetwork(); deselectNetwork();
}, [data, saveData, deselectNetwork]); }, [data, saveData, deselectNetwork]);
const setCancel = useCallback(async () => { const setCancel = async () => {
deselectNetwork(); deselectNetwork();
await loadData(); await loadData();
}, [deselectNetwork, loadData]); };
const doRestart = useCallback(async () => { const doRestart = async () => {
setRestarting(true); setRestarting(true);
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch( await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
(error: Error) => { (error: Error) => {
toast.error(error.message); toast.error(error.message);
} }
); );
}, [sendAPI]); };
const content = () => { const content = () => {
if (!data) { if (!data) {
@@ -173,7 +173,7 @@ const NetworkSettings = () => {
<ValidatedTextField <ValidatedTextField
fieldErrors={fieldErrors || {}} fieldErrors={fieldErrors || {}}
name="ssid" name="ssid"
label={'SSID (' + LL.NETWORK_BLANK_SSID() + ')'} label="SSID"
fullWidth fullWidth
variant="outlined" variant="outlined"
value={data.ssid} value={data.ssid}
@@ -1,4 +1,4 @@
import { memo, useCallback, useRef, useState } from 'react'; import { memo, useRef, useState } from 'react';
import PermScanWifiIcon from '@mui/icons-material/PermScanWifi'; import PermScanWifiIcon from '@mui/icons-material/PermScanWifi';
import { Button } from '@mui/material'; import { Button } from '@mui/material';
@@ -48,12 +48,12 @@ const WiFiNetworkScanner = () => {
} }
}); });
const renderNetworkScanner = useCallback(() => { const renderNetworkScanner = () => {
if (!networkList) { if (!networkList) {
return <FormLoader errorMessage={errorMessage || ''} />; return <FormLoader errorMessage={errorMessage || ''} />;
} }
return <WiFiNetworkSelector networkList={networkList} />; return <WiFiNetworkSelector networkList={networkList} />;
}, [networkList, errorMessage]); };
return ( return (
<SectionContent> <SectionContent>
@@ -1,4 +1,4 @@
import { memo, useCallback, useContext } from 'react'; import { memo, useContext } from 'react';
import LockIcon from '@mui/icons-material/Lock'; import LockIcon from '@mui/icons-material/Lock';
import LockOpenIcon from '@mui/icons-material/LockOpen'; import LockOpenIcon from '@mui/icons-material/LockOpen';
@@ -63,34 +63,31 @@ const WiFiNetworkSelector = ({ networkList }: { networkList: WiFiNetworkList })
const wifiConnectionContext = useContext(WiFiConnectionContext); const wifiConnectionContext = useContext(WiFiConnectionContext);
const renderNetwork = useCallback( const renderNetwork = (network: WiFiNetwork) => (
(network: WiFiNetwork) => ( <ListItem
<ListItem key={network.bssid}
key={network.bssid} onClick={() => wifiConnectionContext.selectNetwork(network)}
onClick={() => wifiConnectionContext.selectNetwork(network)} >
> <ListItemAvatar>
<ListItemAvatar> <Avatar>{isNetworkOpen(network) ? <LockOpenIcon /> : <LockIcon />}</Avatar>
<Avatar>{isNetworkOpen(network) ? <LockOpenIcon /> : <LockIcon />}</Avatar> </ListItemAvatar>
</ListItemAvatar> <ListItemText
<ListItemText primary={network.ssid}
primary={network.ssid} secondary={
secondary={ 'Security: ' +
'Security: ' + networkSecurityMode(network) +
networkSecurityMode(network) + ', Ch: ' +
', Ch: ' + network.channel +
network.channel + ', bssid: ' +
', bssid: ' + network.bssid
network.bssid }
} />
/> <ListItemIcon>
<ListItemIcon> <Badge badgeContent={network.rssi + 'dBm'}>
<Badge badgeContent={network.rssi + 'dBm'}> <WifiIcon sx={{ color: networkQualityHighlight(network, theme) }} />
<WifiIcon sx={{ color: networkQualityHighlight(network, theme) }} /> </Badge>
</Badge> </ListItemIcon>
</ListItemIcon> </ListItem>
</ListItem>
),
[wifiConnectionContext, theme]
); );
if (networkList.networks.length === 0) { if (networkList.networks.length === 0) {
@@ -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 { useBlocker } from 'react-router';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
@@ -55,16 +55,14 @@ const ManageUsers = () => {
const blocker = useBlocker(changed !== 0); const blocker = useBlocker(changed !== 0);
const { LL } = useI18nContext(); const { LL } = useI18nContext();
const table_theme = useMemo( const table_theme = useTheme({
() => Table: `
useTheme({
Table: `
--data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) minmax(120px, max-content) 120px; --data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) minmax(120px, max-content) 120px;
`, `,
BaseRow: ` BaseRow: `
font-size: 14px; font-size: 14px;
`, `,
HeaderRow: ` HeaderRow: `
text-transform: uppercase; text-transform: uppercase;
background-color: black; background-color: black;
color: #90CAF9; color: #90CAF9;
@@ -74,7 +72,7 @@ const ManageUsers = () => {
border-bottom: 1px solid #565656; border-bottom: 1px solid #565656;
} }
`, `,
Row: ` Row: `
.td { .td {
padding: 8px; padding: 8px;
border-top: 1px solid #565656; border-top: 1px solid #565656;
@@ -87,7 +85,7 @@ const ManageUsers = () => {
background-color: #1e1e1e; background-color: #1e1e1e;
} }
`, `,
BaseCell: ` BaseCell: `
&:nth-of-type(2) { &:nth-of-type(2) {
text-align: center; text-align: center;
} }
@@ -95,44 +93,36 @@ const ManageUsers = () => {
text-align: right; text-align: right;
} }
` `
}), });
[]
);
const noAdminConfigured = useCallback( const noAdminConfigured = () => !data?.users.find((u) => u.admin);
() => !data?.users.find((u) => u.admin),
[data]
);
const removeUser = useCallback( const removeUser = (toRemove: UserType) => {
(toRemove: UserType) => { if (!data) return;
if (!data) return; const users = data.users.filter((u) => u.username !== toRemove.username);
const users = data.users.filter((u) => u.username !== toRemove.username); updateDataValue({ ...data, users });
updateDataValue({ ...data, users }); setChanged(changed + 1);
setChanged(changed + 1); };
},
[data, updateDataValue, changed]
);
const createUser = useCallback(() => { const createUser = () => {
setCreating(true); setCreating(true);
setUser({ setUser({
username: '', username: '',
password: '', password: '',
admin: true admin: true
}); });
}, []); };
const editUser = useCallback((toEdit: UserType) => { const editUser = (toEdit: UserType) => {
setCreating(false); setCreating(false);
setUser({ ...toEdit }); setUser({ ...toEdit });
}, []); };
const cancelEditingUser = useCallback(() => { const cancelEditingUser = () => {
setUser(undefined); setUser(undefined);
}, []); };
const doneEditingUser = useCallback(() => { const doneEditingUser = () => {
if (user && data) { if (user && data) {
const users = [ const users = [
...data.users.filter( ...data.users.filter(
@@ -144,26 +134,26 @@ const ManageUsers = () => {
setUser(undefined); setUser(undefined);
setChanged(changed + 1); setChanged(changed + 1);
} }
}, [user, data, updateDataValue, changed]); };
const closeGenerateToken = useCallback(() => { const closeGenerateToken = useCallback(() => {
setGeneratingToken(undefined); setGeneratingToken(undefined);
}, []); }, []);
const generateTokenForUser = useCallback((username: string) => { const generateTokenForUser = (username: string) => {
setGeneratingToken(username); setGeneratingToken(username);
}, []); };
const onSubmit = useCallback(async () => { const onSubmit = async () => {
await saveData(); await saveData();
await authenticatedContext.refresh(); await authenticatedContext.refresh();
setChanged(0); setChanged(0);
}, [saveData, authenticatedContext]); };
const onCancelSubmit = useCallback(async () => { const onCancelSubmit = async () => {
await loadData(); await loadData();
setChanged(0); setChanged(0);
}, [loadData]); };
const content = () => { const content = () => {
if (!data) { if (!data) {
@@ -177,15 +167,10 @@ const ManageUsers = () => {
admin: boolean; admin: boolean;
} }
// add id to the type, needed for the table const user_table = data.users.map((u) => ({
const user_table = useMemo( ...u,
() => id: u.username
data.users.map((u) => ({ })) as UserType2[];
...u,
id: u.username
})) as UserType2[],
[data.users]
);
return ( return (
<> <>
@@ -1,35 +1,16 @@
import { memo, useMemo } from 'react'; import { memo } from 'react';
import { Navigate, Route, Routes, matchRoutes, useLocation } from 'react-router'; import { Outlet, useMatch } from 'react-router';
import { Tab } from '@mui/material'; import { Tab } from '@mui/material';
import { RouterTabs, useLayoutTitle } from 'components'; import { RouterTabs, useLayoutTitle } from 'components';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import ManageUsers from './ManageUsers';
import SecuritySettings from './SecuritySettings';
const Security = () => { const Security = () => {
const { LL } = useI18nContext(); const { LL } = useI18nContext();
useLayoutTitle(LL.SECURITY(0)); useLayoutTitle(LL.SECURITY(0));
const location = useLocation(); const routerTab = useMatch('/settings/security/:tab')?.pathname || false;
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;
return ( return (
<> <>
@@ -40,14 +21,7 @@ const Security = () => {
/> />
<Tab value="/settings/security/users" label={LL.MANAGE_USERS()} /> <Tab value="/settings/security/users" label={LL.MANAGE_USERS()} />
</RouterTabs> </RouterTabs>
<Routes> <Outlet />
<Route path="users" element={<ManageUsers />} />
<Route path="settings" element={<SecuritySettings />} />
<Route
path="*"
element={<Navigate replace to="/settings/security/settings" />}
/>
</Routes>
</> </>
); );
}; };
@@ -6,7 +6,6 @@ import { Button } from '@mui/material';
import * as SecurityApi from 'api/security'; import * as SecurityApi from 'api/security';
import type { ValidateFieldsError } from 'async-validator';
import { import {
BlockNavigation, BlockNavigation,
ButtonRow, ButtonRow,
@@ -20,6 +19,7 @@ import { useI18nContext } from 'i18n/i18n-react';
import type { SecuritySettingsType } from 'types'; import type { SecuritySettingsType } from 'types';
import { updateValueDirty, useRest } from 'utils'; import { updateValueDirty, useRest } from 'utils';
import { SECURITY_SETTINGS_VALIDATOR, ValidationError, validate } from 'validators'; import { SECURITY_SETTINGS_VALIDATOR, ValidationError, validate } from 'validators';
import type { ValidateFieldsError } from 'validators/schema';
const SecuritySettings = () => { const SecuritySettings = () => {
const { LL } = useI18nContext(); const { LL } = useI18nContext();
@@ -79,7 +79,7 @@ const SecuritySettings = () => {
onChange={updateFormValue} onChange={updateFormValue}
margin="normal" margin="normal"
/> />
<MessageBox level="info" message={LL.SU_TEXT()} mt={1} /> <MessageBox level="info" message={LL.SU_TEXT()} sx={{ mt: 1 }} />
{dirtyFlags && dirtyFlags.length !== 0 && ( {dirtyFlags && dirtyFlags.length !== 0 && (
<ButtonRow> <ButtonRow>
<Button <Button
+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 type { FC } from 'react';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
@@ -14,8 +14,6 @@ import {
} from '@mui/material'; } from '@mui/material';
import { dialogStyle } from 'CustomTheme'; import { dialogStyle } from 'CustomTheme';
import type Schema from 'async-validator';
import type { ValidateFieldsError } from 'async-validator';
import { import {
BlockFormControlLabel, BlockFormControlLabel,
ValidatedPasswordField, ValidatedPasswordField,
@@ -25,6 +23,8 @@ import { useI18nContext } from 'i18n/i18n-react';
import type { UserType } from 'types'; import type { UserType } from 'types';
import { updateValue } from 'utils'; import { updateValue } from 'utils';
import { ValidationError, validate } from 'validators'; import { ValidationError, validate } from 'validators';
import type Schema from 'validators/schema';
import type { ValidateFieldsError } from 'validators/schema';
interface UserFormProps { interface UserFormProps {
creating: boolean; creating: boolean;
@@ -62,7 +62,7 @@ const User: FC<UserFormProps> = ({
} }
}, [open]); }, [open]);
const validateAndDone = useCallback(async () => { const validateAndDone = async () => {
if (user) { if (user) {
try { try {
setFieldErrors(undefined); setFieldErrors(undefined);
@@ -72,7 +72,7 @@ const User: FC<UserFormProps> = ({
setFieldErrors((error as ValidationError).fieldErrors); setFieldErrors((error as ValidationError).fieldErrors);
} }
} }
}, [user, validator, onDoneEditing]); };
return ( return (
<Dialog <Dialog
+25 -33
View File
@@ -1,5 +1,3 @@
import { useCallback, useMemo } from 'react';
import { import {
Body, Body,
Cell, Cell,
@@ -36,16 +34,14 @@ const SystemActivity = () => {
useLayoutTitle(LL.DATA_TRAFFIC()); useLayoutTitle(LL.DATA_TRAFFIC());
const stats_theme = tableTheme( const stats_theme = tableTheme({
useMemo( Table: `
() => ({
Table: `
--data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) 90px 90px 80px; --data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) 90px 90px 80px;
`, `,
BaseRow: ` BaseRow: `
font-size: 14px; font-size: 14px;
`, `,
HeaderRow: ` HeaderRow: `
text-transform: uppercase; text-transform: uppercase;
background-color: black; background-color: black;
color: #90CAF9; color: #90CAF9;
@@ -55,7 +51,7 @@ const SystemActivity = () => {
border-bottom: 1px solid #565656; border-bottom: 1px solid #565656;
} }
`, `,
Row: ` Row: `
.td { .td {
padding: 8px; padding: 8px;
border-top: 1px solid #565656; border-top: 1px solid #565656;
@@ -69,26 +65,20 @@ const SystemActivity = () => {
background-color: #1e1e1e; background-color: #1e1e1e;
} }
`, `,
BaseCell: ` BaseCell: `
&:not(:first-of-type) { &:not(:first-of-type) {
text-align: center; text-align: center;
} }
` `
}), });
[]
)
);
const showName = useCallback( const showName = (id: number) => {
(id: number) => { const name: keyof Translation['STATUS_NAMES'] =
const name: keyof Translation['STATUS_NAMES'] = id.toString() as keyof Translation['STATUS_NAMES'];
id.toString() as keyof Translation['STATUS_NAMES']; return LL.STATUS_NAMES[name]();
return LL.STATUS_NAMES[name](); };
},
[LL]
);
const showQuality = useCallback((stat: Stat) => { const showQuality = (stat: Stat) => {
if (stat.q === 0 || stat.s + stat.f === 0) { if (stat.q === 0 || stat.s + stat.f === 0) {
return; return;
} }
@@ -100,14 +90,18 @@ const SystemActivity = () => {
} else { } else {
return <div style={{ color: QUALITY_COLORS.POOR }}>{stat.q}%</div>; 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 ( return (
<SectionContent>
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
</SectionContent>
);
}
return (
<SectionContent>
<Table <Table
data={{ nodes: data.stats }} data={{ nodes: data.stats }}
theme={stats_theme} theme={stats_theme}
@@ -136,10 +130,8 @@ const SystemActivity = () => {
</> </>
)} )}
</Table> </Table>
); </SectionContent>
}, [data, loadData, error?.message, stats_theme, LL, showName, showQuality]); );
return <SectionContent>{content}</SectionContent>;
}; };
export default SystemActivity; 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 AutoAwesomeMotionIcon from '@mui/icons-material/AutoAwesomeMotion';
import DeviceHubIcon from '@mui/icons-material/DeviceHub'; import DeviceHubIcon from '@mui/icons-material/DeviceHub';
@@ -127,16 +127,15 @@ const MqttStatus = () => {
void loadData(); void loadData();
}); });
// Memoize error message separately to avoid re-renders on error object changes
const errorMessage = error?.message || ''; const errorMessage = error?.message || '';
const mqttStatusText = useMemo(() => { const mqttStatusText = !data
if (!data) return ''; ? ''
if (!data.enabled) return LL.NOT_ENABLED(); : !data.enabled
return data.connected ? LL.NOT_ENABLED()
? `${LL.CONNECTED(0)} (${data.connect_count})` : data.connected
: `${LL.DISCONNECTED()} (${data.connect_count})`; ? `${LL.CONNECTED(0)} (${data.connect_count})`
}, [data, LL]); : `${LL.DISCONNECTED()} (${data.connect_count})`;
if (!data) { if (!data) {
return ( return (
+11 -11
View File
@@ -1,5 +1,3 @@
import { useMemo } from 'react';
import AccessTimeIcon from '@mui/icons-material/AccessTime'; import AccessTimeIcon from '@mui/icons-material/AccessTime';
import DnsIcon from '@mui/icons-material/Dns'; import DnsIcon from '@mui/icons-material/Dns';
import SwapVerticalCircleIcon from '@mui/icons-material/SwapVerticalCircle'; import SwapVerticalCircleIcon from '@mui/icons-material/SwapVerticalCircle';
@@ -67,12 +65,16 @@ const NTPStatus = () => {
} }
}; };
const content = useMemo(() => { if (!data) {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
}
return ( return (
<SectionContent>
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
</SectionContent>
);
}
return (
<SectionContent>
<List> <List>
<ListItem> <ListItem>
<ListItemAvatar> <ListItemAvatar>
@@ -121,10 +123,8 @@ const NTPStatus = () => {
</ListItem> </ListItem>
<Divider variant="inset" component="li" /> <Divider variant="inset" component="li" />
</List> </List>
); </SectionContent>
}, [data, error, loadData, LL, theme]); );
return <SectionContent>{content}</SectionContent>;
}; };
export default NTPStatus; export default NTPStatus;
+15 -15
View File
@@ -1,5 +1,3 @@
import { useMemo } from 'react';
import DeviceHubIcon from '@mui/icons-material/DeviceHub'; import DeviceHubIcon from '@mui/icons-material/DeviceHub';
import DnsIcon from '@mui/icons-material/Dns'; import DnsIcon from '@mui/icons-material/Dns';
import GiteIcon from '@mui/icons-material/Gite'; import GiteIcon from '@mui/icons-material/Gite';
@@ -124,16 +122,20 @@ const NetworkStatus = () => {
const theme = useTheme(); const theme = useTheme();
const content = useMemo(() => { if (!data) {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
}
const statusText = getNetworkStatusText(data.status, data.reconnect_count, LL);
const statusColor = networkStatusHighlight(data, theme);
const qualityColor = networkQualityHighlight(data, theme);
return ( return (
<SectionContent>
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
</SectionContent>
);
}
const statusText = getNetworkStatusText(data.status, data.reconnect_count, LL);
const statusColor = networkStatusHighlight(data, theme);
const qualityColor = networkQualityHighlight(data, theme);
return (
<SectionContent>
<List> <List>
<ListItem> <ListItem>
<ListItemAvatar> <ListItemAvatar>
@@ -227,10 +229,8 @@ const NetworkStatus = () => {
</> </>
)} )}
</List> </List>
); </SectionContent>
}, [data, error, loadData, LL, theme]); );
return <SectionContent>{content}</SectionContent>;
}; };
export default NetworkStatus; export default NetworkStatus;
+108 -273
View File
@@ -1,25 +1,16 @@
import { useCallback, useContext, useMemo, useState } from 'react'; import { useContext } from 'react';
import { toast } from 'react-toastify';
import AccessTimeIcon from '@mui/icons-material/AccessTime'; import AccessTimeIcon from '@mui/icons-material/AccessTime';
import BuildIcon from '@mui/icons-material/Build';
import CancelIcon from '@mui/icons-material/Cancel';
import DeviceHubIcon from '@mui/icons-material/DeviceHub'; import DeviceHubIcon from '@mui/icons-material/DeviceHub';
import DirectionsBusIcon from '@mui/icons-material/DirectionsBus'; import DirectionsBusIcon from '@mui/icons-material/DirectionsBus';
import LogoDevIcon from '@mui/icons-material/LogoDev'; import LogoDevIcon from '@mui/icons-material/LogoDev';
import MemoryIcon from '@mui/icons-material/Memory'; import MemoryIcon from '@mui/icons-material/Memory';
import MonitorHeartIcon from '@mui/icons-material/MonitorHeart'; import MonitorHeartIcon from '@mui/icons-material/MonitorHeart';
import PowerSettingsNewIcon from '@mui/icons-material/PowerSettingsNew';
import RouterIcon from '@mui/icons-material/Router'; import RouterIcon from '@mui/icons-material/Router';
import SettingsInputAntennaIcon from '@mui/icons-material/SettingsInputAntenna'; import SettingsInputAntennaIcon from '@mui/icons-material/SettingsInputAntenna';
import WifiIcon from '@mui/icons-material/Wifi'; import WifiIcon from '@mui/icons-material/Wifi';
import { import {
Avatar, Avatar,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
List, List,
ListItem, ListItem,
ListItemAvatar, ListItemAvatar,
@@ -27,12 +18,10 @@ import {
useTheme useTheme
} from '@mui/material'; } from '@mui/material';
import { API } from 'api/app';
import { readSystemStatus } from 'api/system'; import { readSystemStatus } from 'api/system';
import { dialogStyle } from 'CustomTheme';
import { useRequest } from 'alova/client'; import { useRequest } from 'alova/client';
import { type APIcall, busConnectionStatus } from 'app/main/types'; import { busConnectionStatus } from 'app/main/types';
import { FormLoader, SectionContent, useLayoutTitle } from 'components'; import { FormLoader, SectionContent, useLayoutTitle } from 'components';
import ListMenuItem from 'components/layout/ListMenuItem'; import ListMenuItem from 'components/layout/ListMenuItem';
import { AuthenticatedContext } from 'contexts/authentication'; import { AuthenticatedContext } from 'contexts/authentication';
@@ -41,9 +30,6 @@ import { NTPSyncStatus, NetworkConnectionStatus, SystemStatusCodes } from 'types
import { useInterval } from 'utils'; import { useInterval } from 'utils';
import { formatDateTime } from 'utils/time'; import { formatDateTime } from 'utils/time';
import SystemMonitor from './SystemMonitor';
// Pure functions moved outside component to avoid recreation on each render
const formatNumber = (num: number) => new Intl.NumberFormat().format(num); const formatNumber = (num: number) => new Intl.NumberFormat().format(num);
const formatDurationSec = ( const formatDurationSec = (
@@ -72,24 +58,7 @@ const SystemStatus = () => {
const { me } = useContext(AuthenticatedContext); const { me } = useContext(AuthenticatedContext);
const [confirmRestart, setConfirmRestart] = useState<boolean>(false); const { data, send: loadData, error } = useRequest(readSystemStatus);
const [restarting, setRestarting] = useState<boolean>();
const { send: sendAPI } = useRequest((data: APIcall) => API(data), {
immediate: false
});
const {
data,
send: loadData,
error
} = useRequest(readSystemStatus, {
async middleware(_, next) {
if (!restarting) {
await next();
}
}
});
useInterval(() => { useInterval(() => {
void loadData(); void loadData();
@@ -97,10 +66,8 @@ const SystemStatus = () => {
const theme = useTheme(); const theme = useTheme();
// Memoize derived status values to avoid recalculation on every render const busStatus = (() => {
const busStatus = useMemo(() => {
if (!data) return 'EMS state unknown'; if (!data) return 'EMS state unknown';
switch (data.bus_status) { switch (data.bus_status) {
case busConnectionStatus.BUS_STATUS_CONNECTED: case busConnectionStatus.BUS_STATUS_CONNECTED:
return `EMS ${LL.CONNECTED(0)} (${formatDurationSec(data.bus_uptime, LL)})`; return `EMS ${LL.CONNECTED(0)} (${formatDurationSec(data.bus_uptime, LL)})`;
@@ -111,12 +78,10 @@ const SystemStatus = () => {
default: default:
return 'EMS state unknown'; return 'EMS state unknown';
} }
}, [data?.bus_status, data?.bus_uptime, LL]); })();
// Memoize derived status values to avoid recalculation on every render const systemStatus = (() => {
const systemStatus = useMemo(() => {
if (!data) return '??'; if (!data) return '??';
switch (data.status) { switch (data.status) {
case SystemStatusCodes.SYSTEM_STATUS_PENDING_UPLOAD: case SystemStatusCodes.SYSTEM_STATUS_PENDING_UPLOAD:
case SystemStatusCodes.SYSTEM_STATUS_UPLOADING: case SystemStatusCodes.SYSTEM_STATUS_UPLOADING:
@@ -129,14 +94,12 @@ const SystemStatus = () => {
case SystemStatusCodes.SYSTEM_STATUS_INVALID_GPIO: case SystemStatusCodes.SYSTEM_STATUS_INVALID_GPIO:
return LL.GPIO_OF(LL.FAILED(0)); return LL.GPIO_OF(LL.FAILED(0));
default: default:
// SystemStatusCodes.SYSTEM_STATUS_NORMAL
return 'OK'; return 'OK';
} }
}, [data?.status, LL]); })();
const busStatusHighlight = useMemo(() => { const busStatusHighlight = (() => {
if (!data) return theme.palette.warning.main; if (!data) return theme.palette.warning.main;
switch (data.bus_status) { switch (data.bus_status) {
case busConnectionStatus.BUS_STATUS_TX_ERRORS: case busConnectionStatus.BUS_STATUS_TX_ERRORS:
return theme.palette.warning.main; return theme.palette.warning.main;
@@ -147,11 +110,10 @@ const SystemStatus = () => {
default: default:
return theme.palette.warning.main; return theme.palette.warning.main;
} }
}, [data?.bus_status, theme.palette]); })();
const ntpStatus = useMemo(() => { const ntpStatus = (() => {
if (!data) return LL.UNKNOWN(); if (!data) return LL.UNKNOWN();
switch (data.ntp_status) { switch (data.ntp_status) {
case NTPSyncStatus.NTP_DISABLED: case NTPSyncStatus.NTP_DISABLED:
return LL.NOT_ENABLED(); return LL.NOT_ENABLED();
@@ -164,11 +126,10 @@ const SystemStatus = () => {
default: default:
return LL.UNKNOWN(); return LL.UNKNOWN();
} }
}, [data?.ntp_status, data?.ntp_time, LL]); })();
const ntpStatusHighlight = useMemo(() => { const ntpStatusHighlight = (() => {
if (!data) return theme.palette.error.main; if (!data) return theme.palette.error.main;
switch (data.ntp_status) { switch (data.ntp_status) {
case NTPSyncStatus.NTP_DISABLED: case NTPSyncStatus.NTP_DISABLED:
return theme.palette.info.main; return theme.palette.info.main;
@@ -179,11 +140,10 @@ const SystemStatus = () => {
default: default:
return theme.palette.error.main; return theme.palette.error.main;
} }
}, [data?.ntp_status, theme.palette]); })();
const networkStatusHighlight = useMemo(() => { const networkStatusHighlight = (() => {
if (!data) return theme.palette.warning.main; if (!data) return theme.palette.warning.main;
switch (data.network_status) { switch (data.network_status) {
case NetworkConnectionStatus.WIFI_STATUS_IDLE: case NetworkConnectionStatus.WIFI_STATUS_IDLE:
case NetworkConnectionStatus.WIFI_STATUS_DISCONNECTED: case NetworkConnectionStatus.WIFI_STATUS_DISCONNECTED:
@@ -198,11 +158,10 @@ const SystemStatus = () => {
default: default:
return theme.palette.warning.main; return theme.palette.warning.main;
} }
}, [data?.network_status, theme.palette]); })();
const networkStatus = useMemo(() => { const networkStatus = (() => {
if (!data) return LL.UNKNOWN(); if (!data) return LL.UNKNOWN();
switch (data.network_status) { switch (data.network_status) {
case NetworkConnectionStatus.WIFI_STATUS_NO_SHIELD: case NetworkConnectionStatus.WIFI_STATUS_NO_SHIELD:
return LL.INACTIVE(1); return LL.INACTIVE(1);
@@ -223,227 +182,103 @@ const SystemStatus = () => {
default: default:
return LL.UNKNOWN(); return LL.UNKNOWN();
} }
}, [data?.network_status, data?.wifi_rssi, LL]); })();
const activeHighlight = useCallback( const activeHighlight = (value: boolean) =>
(value: boolean) => value ? theme.palette.success.main : theme.palette.info.main;
value ? theme.palette.success.main : theme.palette.info.main,
[theme.palette]
);
const doRestart = useCallback(async () => {
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 || ''} />;
}
if (!data || !LL) {
return ( return (
<> <SectionContent>
<List> <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
<ListMenuItem </SectionContent>
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}
</>
); );
}, [ }
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; export default SystemStatus;
+10 -18
View File
@@ -1,12 +1,4 @@
import { import { memo, useEffect, useLayoutEffect, useRef, useState } from 'react';
memo,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState
} from 'react';
import { toast } from 'react-toastify';
import DownloadIcon from '@mui/icons-material/GetApp'; import DownloadIcon from '@mui/icons-material/GetApp';
import PlayArrowIcon from '@mui/icons-material/PlayArrow'; import PlayArrowIcon from '@mui/icons-material/PlayArrow';
@@ -33,6 +25,7 @@ import {
SectionContent, SectionContent,
useLayoutTitle useLayoutTitle
} from 'components'; } from 'components';
import { toast } from 'components/toast';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import type { LogEntry, LogSettings } from 'types'; import type { LogEntry, LogSettings } from 'types';
import { LogLevel } from 'types'; import { LogLevel } from 'types';
@@ -185,8 +178,7 @@ const SystemLog = () => {
}; };
}, [data]); // Recalculate when data changes (in case layout shifts) }, [data]); // Recalculate when data changes (in case layout shifts)
// Memoize message handler to avoid recreating on every render const handleLogMessage = (message: { data: string }) => {
const handleLogMessage = useCallback((message: { data: string }) => {
const rawData = message.data; const rawData = message.data;
const logentry = JSON.parse(rawData) as LogEntry; const logentry = JSON.parse(rawData) as LogEntry;
setLogEntries((log) => { setLogEntries((log) => {
@@ -200,7 +192,7 @@ const SystemLog = () => {
const newLog = [...log, logentry]; const newLog = [...log, logentry];
return newLog; return newLog;
}); });
}, []); };
useSSE(fetchLogES, { useSSE(fetchLogES, {
immediate: true, immediate: true,
@@ -211,7 +203,7 @@ const SystemLog = () => {
toast.error('No connection to Log service'); toast.error('No connection to Log service');
}); });
const onDownload = useCallback(() => { const onDownload = () => {
const result = logEntries const result = logEntries
.map((i) => `${i.t} ${levelLabel(i.l)} ${i.i}: [${i.n}] ${i.m}`) .map((i) => `${i.t} ${levelLabel(i.l)} ${i.i}: [${i.n}] ${i.m}`)
.join('\n'); .join('\n');
@@ -225,11 +217,11 @@ const SystemLog = () => {
document.body.appendChild(a); document.body.appendChild(a);
a.click(); a.click();
document.body.removeChild(a); document.body.removeChild(a);
}, [logEntries]); };
const saveSettings = useCallback(async () => { const saveSettings = async () => {
await saveData(); await saveData();
}, [saveData]); };
// handle scrolling - optimized to only scroll when needed // handle scrolling - optimized to only scroll when needed
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
@@ -246,7 +238,7 @@ const SystemLog = () => {
} }
}, [logEntries.length, autoscroll]); }, [logEntries.length, autoscroll]);
const sendReadCommand = useCallback(() => { const sendReadCommand = () => {
if (readValue === '') { if (readValue === '') {
setReadOpen(!readOpen); setReadOpen(!readOpen);
return; return;
@@ -257,7 +249,7 @@ const SystemLog = () => {
setReadOpen(false); setReadOpen(false);
setReadValue(''); setReadValue('');
} }
}, [readValue, readOpen, send]); };
const content = () => { const content = () => {
if (!data) { if (!data) {
+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 CancelIcon from '@mui/icons-material/Cancel';
import { Box, Button, Typography } from '@mui/material'; import { Box, Button, Typography } from '@mui/material';
@@ -57,39 +57,31 @@ const SystemMonitor = () => {
void send(); void send();
}, 1000); // check every 1 second }, 1000); // check every 1 second
const { statusMessage, isUploading, progressValue } = useMemo(() => { const status = data?.status;
const status = data?.status;
const message = const statusMessage =
status && status >= SystemStatusCodes.SYSTEM_STATUS_UPLOADING status && status >= SystemStatusCodes.SYSTEM_STATUS_UPLOADING
? LL.WAIT_FIRMWARE() ? LL.WAIT_FIRMWARE()
: status === SystemStatusCodes.SYSTEM_STATUS_PENDING_RESTART : status === SystemStatusCodes.SYSTEM_STATUS_PENDING_RESTART
? LL.APPLICATION_RESTARTING() ? LL.APPLICATION_RESTARTING()
: status === SystemStatusCodes.SYSTEM_STATUS_NORMAL : status === SystemStatusCodes.SYSTEM_STATUS_NORMAL
? LL.RESTARTING_PRE() ? LL.RESTARTING_PRE()
: status === SystemStatusCodes.SYSTEM_STATUS_ERROR_UPLOAD : status === SystemStatusCodes.SYSTEM_STATUS_ERROR_UPLOAD
? 'Upload Failed' ? 'Upload Failed'
: LL.RESTARTING_POST(); : LL.RESTARTING_POST();
const uploading = const isUploading =
status !== undefined && status >= SystemStatusCodes.SYSTEM_STATUS_UPLOADING; status !== undefined && status >= SystemStatusCodes.SYSTEM_STATUS_UPLOADING;
const progress = const progressValue =
uploading && status isUploading && status
? Math.round(status - SystemStatusCodes.SYSTEM_STATUS_UPLOADING) ? Math.round(status - SystemStatusCodes.SYSTEM_STATUS_UPLOADING)
: 0; : 0;
return { const onCancel = async () => {
statusMessage: message,
isUploading: uploading,
progressValue: progress
};
}, [data?.status, LL]);
const onCancel = useCallback(async () => {
setErrorMessage(undefined); setErrorMessage(undefined);
await setSystemStatus(String(SystemStatusCodes.SYSTEM_STATUS_NORMAL)); await setSystemStatus(String(SystemStatusCodes.SYSTEM_STATUS_NORMAL));
document.location.href = '/'; document.location.href = '/';
}, [setSystemStatus]); };
return ( return (
<Box <Box
@@ -153,9 +145,21 @@ const SystemMonitor = () => {
{LL.PLEASE_WAIT()}&hellip; {LL.PLEASE_WAIT()}&hellip;
</Typography> </Typography>
{isUploading && ( {isUploading && (
<Box sx={{ width: '100%', pl: 2, pr: 2, py: 2 }}> <>
<LinearProgressWithLabel value={progressValue} /> <Box sx={{ width: '100%', pl: 2, pr: 2, py: 2 }}>
</Box> <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 CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined';
import ErrorIcon from '@mui/icons-material/Error'; import ErrorIcon from '@mui/icons-material/Error';
@@ -38,18 +38,17 @@ const MessageBox: FC<PropsWithChildren<MessageBoxProps>> = ({
}) => { }) => {
const theme = useTheme(); const theme = useTheme();
const { Icon, backgroundColor } = useMemo(() => { const Icon = LEVEL_ICONS[level];
const Icon = LEVEL_ICONS[level]; const palettePath = LEVEL_PALETTE_PATHS[level];
const palettePath = LEVEL_PALETTE_PATHS[level]; const [paletteKeyName, shade] = palettePath.split('.') as [
const [key, shade] = palettePath.split('.') as [ keyof typeof theme.palette,
keyof typeof theme.palette, string
string ];
]; const paletteKey = theme.palette[paletteKeyName] as unknown as Record<
const paletteKey = theme.palette[key] as unknown as Record<string, string>; string,
const backgroundColor = paletteKey[shade]; string
>;
return { Icon, backgroundColor }; const backgroundColor = paletteKey[shade];
}, [level, theme]);
return ( return (
<Box <Box
@@ -1,10 +1,10 @@
import { memo, useCallback, useContext, useMemo } from 'react'; import { memo, useContext } from 'react';
import type { ChangeEventHandler } from 'react'; import type { ChangeEventHandler } from 'react';
import type { CSSProperties } from 'react'; import type { CSSProperties } from 'react';
import { MenuItem, TextField } from '@mui/material'; 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 DEflag from 'i18n/DE.svg';
import FRflag from 'i18n/FR.svg'; import FRflag from 'i18n/FR.svg';
import GBflag from 'i18n/GB.svg'; import GBflag from 'i18n/GB.svg';
@@ -44,27 +44,14 @@ const LANGUAGE_OPTIONS: LanguageOption[] = [
const LanguageSelector = () => { const LanguageSelector = () => {
const { setLocale, locale, LL } = useContext(I18nContext); const { setLocale, locale, LL } = useContext(I18nContext);
const onLocaleSelected: ChangeEventHandler<HTMLInputElement> = useCallback( const onLocaleSelected: ChangeEventHandler<HTMLInputElement> = async ({
async ({ target }) => { target
const loc = target.value as Locales; }) => {
localStorage.setItem('lang', loc); const loc = target.value as Locales;
await loadLocaleAsync(loc); localStorage.setItem('lang', loc);
setLocale(loc); await loadLocaleAsync(loc);
}, setLocale(loc);
[setLocale] };
);
// Memoize menu items to prevent recreation on every render
const menuItems = useMemo(
() =>
LANGUAGE_OPTIONS.map(({ key, flag, label }) => (
<MenuItem key={key} value={key}>
<img src={flag} style={flagStyle} alt={label} />
&nbsp;{label}
</MenuItem>
)),
[]
);
return ( return (
<TextField <TextField
@@ -76,7 +63,12 @@ const LanguageSelector = () => {
size="small" size="small"
select select
> >
{menuItems} {LANGUAGE_OPTIONS.map(({ key, flag, label }) => (
<MenuItem key={key} value={key}>
<img src={flag} style={flagStyle} alt={label} />
&nbsp;{label}
</MenuItem>
))}
</TextField> </TextField>
); );
}; };
@@ -1,4 +1,4 @@
import { memo, useCallback, useState } from 'react'; import { memo, useState } from 'react';
import type { FC } from 'react'; import type { FC } from 'react';
import VisibilityIcon from '@mui/icons-material/Visibility'; import VisibilityIcon from '@mui/icons-material/Visibility';
@@ -13,9 +13,9 @@ type ValidatedPasswordFieldProps = Omit<ValidatedTextFieldProps, 'type'>;
const ValidatedPasswordField: FC<ValidatedPasswordFieldProps> = ({ ...props }) => { const ValidatedPasswordField: FC<ValidatedPasswordFieldProps> = ({ ...props }) => {
const [showPassword, setShowPassword] = useState<boolean>(false); const [showPassword, setShowPassword] = useState<boolean>(false);
const togglePasswordVisibility = useCallback(() => { const togglePasswordVisibility = () => {
setShowPassword((prev) => !prev); setShowPassword((prev) => !prev);
}, []); };
return ( return (
<ValidatedTextField <ValidatedTextField
@@ -4,7 +4,7 @@ import type { FC } from 'react';
import { FormHelperText, TextField } from '@mui/material'; import { FormHelperText, TextField } from '@mui/material';
import type { TextFieldProps } from '@mui/material'; import type { TextFieldProps } from '@mui/material';
import type { ValidateFieldsError } from 'async-validator'; import type { ValidateFieldsError } from 'validators/schema';
interface ValidatedFieldProps { interface ValidatedFieldProps {
fieldErrors?: ValidateFieldsError; fieldErrors?: ValidateFieldsError;
@@ -18,7 +18,6 @@ const LayoutComponent: FC<RequiredChildrenProps> = ({ children }) => {
const [title, setTitle] = useState(PROJECT_NAME); const [title, setTitle] = useState(PROJECT_NAME);
const { pathname } = useLocation(); const { pathname } = useLocation();
// Memoize drawer toggle handler to prevent unnecessary re-renders
const handleDrawerToggle = useCallback(() => { const handleDrawerToggle = useCallback(() => {
setMobileOpen((prev) => !prev); setMobileOpen((prev) => !prev);
}, []); }, []);
@@ -28,7 +27,6 @@ const LayoutComponent: FC<RequiredChildrenProps> = ({ children }) => {
setMobileOpen(false); setMobileOpen(false);
}, [pathname]); }, [pathname]);
// Memoize context value to prevent unnecessary re-renders
const contextValue = useMemo(() => ({ title, setTitle }), [title]); const contextValue = useMemo(() => ({ title, setTitle }), [title]);
return ( return (
@@ -1,4 +1,4 @@
import { memo, useCallback, useMemo } from 'react'; import { memo } from 'react';
import { Link, useLocation, useNavigate } from 'react-router'; import { Link, useLocation, useNavigate } from 'react-router';
import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import ArrowBackIcon from '@mui/icons-material/ArrowBack';
@@ -39,14 +39,11 @@ const LayoutAppBarComponent = ({ title, onToggleDrawer }: LayoutAppBarProps) =>
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const pathnames = useMemo( const pathnames = location.pathname.split('/').filter((x) => x);
() => location.pathname.split('/').filter((x) => x),
[location.pathname]
);
const handleBackClick = useCallback(() => { const handleBackClick = () => {
void navigate('/' + pathnames[0]); void navigate('/' + pathnames[0]);
}, [navigate, pathnames]); };
return ( return (
<AppBar position="fixed" sx={appBarStyles}> <AppBar position="fixed" sx={appBarStyles}>
@@ -1,7 +1,10 @@
import { memo, useMemo } from 'react'; import { memo } from 'react';
import { Box, Divider, Drawer, Toolbar, Typography, styled } from '@mui/material'; import { Box, Divider, Drawer, Toolbar, Typography, styled } from '@mui/material';
import { readSettings } from 'api/app';
import { useRequest } from 'alova/client';
import { PROJECT_NAME } from 'env'; import { PROJECT_NAME } from 'env';
import { DRAWER_WIDTH } from './Layout'; import { DRAWER_WIDTH } from './Layout';
@@ -24,22 +27,34 @@ interface LayoutDrawerProps {
} }
const LayoutDrawerComponent = ({ mobileOpen, onClose }: LayoutDrawerProps) => { const LayoutDrawerComponent = ({ mobileOpen, onClose }: LayoutDrawerProps) => {
// Memoize drawer content to prevent unnecessary re-renders const { data: settings } = useRequest(readSettings);
const drawer = useMemo( const system_name = settings?.system_name;
() => (
<> const drawer = (
<Toolbar disableGutters> <>
<Box sx={{ display: 'flex', alignItems: 'center', p: 2 }}> <Toolbar disableGutters>
<LayoutDrawerLogo src="/app/icon.png" alt={PROJECT_NAME} /> <Box sx={{ display: 'flex', alignItems: 'center', p: 2 }}>
<Typography variant="h6">{PROJECT_NAME}</Typography> <LayoutDrawerLogo src="/app/icon.png" alt={PROJECT_NAME} />
<Box
sx={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'center'
}}
>
<Typography>{PROJECT_NAME}</Typography>
{system_name && (
<Typography color="secondary" variant="body2">
{system_name}
</Typography>
)}
</Box> </Box>
<Divider absolute /> </Box>
</Toolbar> <Divider absolute />
<Divider /> </Toolbar>
<LayoutMenu /> <Divider />
</> <LayoutMenu />
), </>
[]
); );
return ( 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 AccountCircleIcon from '@mui/icons-material/AccountCircle';
import AssessmentIcon from '@mui/icons-material/Assessment'; 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 LiveHelpIcon from '@mui/icons-material/LiveHelp';
import MoreTimeIcon from '@mui/icons-material/MoreTime'; import MoreTimeIcon from '@mui/icons-material/MoreTime';
import PlaylistAddIcon from '@mui/icons-material/PlaylistAdd'; import PlaylistAddIcon from '@mui/icons-material/PlaylistAdd';
import PlaylistPlayIcon from '@mui/icons-material/PlaylistPlay';
import SensorsIcon from '@mui/icons-material/Sensors'; import SensorsIcon from '@mui/icons-material/Sensors';
import SettingsIcon from '@mui/icons-material/Settings'; import SettingsIcon from '@mui/icons-material/Settings';
import StarIcon from '@mui/icons-material/Star'; import StarIcon from '@mui/icons-material/Star';
@@ -18,13 +19,15 @@ import { AuthenticatedContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
const LayoutMenuComponent = () => { const LayoutMenuComponent = () => {
const { me } = useContext(AuthenticatedContext); const { me, versions } = useContext(AuthenticatedContext);
const { LL } = useI18nContext(); const { LL } = useI18nContext();
const [menuOpen, setMenuOpen] = useState(true); const [menuOpen, setMenuOpen] = useState(true);
const handleMenuToggle = useCallback(() => { const upgradeAvailable = versions?.current?.upgradeable ?? false;
const handleMenuToggle = () => {
setMenuOpen((prev) => !prev); setMenuOpen((prev) => !prev);
}, []); };
return ( return (
<> <>
@@ -78,6 +81,12 @@ const LayoutMenuComponent = () => {
disabled={!me.admin} disabled={!me.admin}
to={`/customizations`} to={`/customizations`}
/> />
<LayoutMenuItem
icon={PlaylistPlayIcon}
label={LL.COMMANDS()}
disabled={!me.admin}
to={`/commands`}
/>
<LayoutMenuItem <LayoutMenuItem
icon={MoreTimeIcon} icon={MoreTimeIcon}
label={LL.SCHEDULER()} label={LL.SCHEDULER()}
@@ -105,6 +114,7 @@ const LayoutMenuComponent = () => {
label={LL.SETTINGS(0)} label={LL.SETTINGS(0)}
disabled={!me.admin} disabled={!me.admin}
to="/settings" to="/settings"
badge={upgradeAvailable}
/> />
<LayoutMenuItem icon={LiveHelpIcon} label={LL.HELP()} to={`/help`} /> <LayoutMenuItem icon={LiveHelpIcon} label={LL.HELP()} to={`/help`} />
<Divider /> <Divider />
@@ -1,7 +1,7 @@
import { memo, useMemo } from 'react'; import { memo } from 'react';
import { Link, useLocation } from 'react-router'; 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 type { SvgIconProps, SxProps, Theme } from '@mui/material';
import { routeMatches } from 'utils'; import { routeMatches } from 'utils';
@@ -11,60 +11,52 @@ interface LayoutMenuItemProps {
label: string; label: string;
to: string; to: string;
disabled?: boolean; disabled?: boolean;
badge?: boolean;
} }
const LayoutMenuItemComponent = ({ const LayoutMenuItemComponent = ({
icon: Icon, icon: Icon,
label, label,
to, to,
disabled disabled,
badge
}: LayoutMenuItemProps) => { }: LayoutMenuItemProps) => {
const { pathname } = useLocation(); const { pathname } = useLocation();
const selected = useMemo(() => routeMatches(to, pathname), [to, pathname]); const selected = routeMatches(to, pathname);
// Memoize dynamic styles based on selected state const buttonStyles: SxProps<Theme> = {
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',
transition: 'all 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)', borderRadius: '8px',
backgroundColor: selected ? 'rgba(144, 202, 249, 0.1)' : 'transparent', margin: '2px 8px',
borderRadius: '8px', '&:hover': {
margin: '2px 8px', backgroundColor: 'rgba(68, 82, 211, 0.39)'
'&:hover': { },
backgroundColor: 'rgba(68, 82, 211, 0.39)' '&::before': {
}, content: '""',
'&::before': { position: 'absolute',
content: '""', left: 0,
position: 'absolute', top: 0,
left: 0, bottom: 0,
top: 0, width: selected ? '3px' : '0px',
bottom: 0, backgroundColor: '#90caf9',
width: selected ? '3px' : '0px', transition: 'width 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)'
backgroundColor: '#90caf9', }
transition: 'width 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)' };
}
}),
[selected]
);
const iconStyles: SxProps<Theme> = useMemo( const iconStyles: SxProps<Theme> = {
() => ({ color: selected ? '#90caf9' : '#9e9e9e',
color: selected ? '#90caf9' : '#9e9e9e', transition: 'color 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)',
transition: 'color 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)', transform: selected ? 'scale(1.1)' : 'scale(1)',
transform: selected ? 'scale(1.1)' : 'scale(1)', transitionProperty: 'color, transform'
transitionProperty: 'color, transform' };
}),
[selected]
);
const textStyles: SxProps<Theme> = useMemo( const textStyles: SxProps<Theme> = {
() => ({ color: selected ? '#90caf9' : '#f5f5f5',
color: selected ? '#90caf9' : '#f5f5f5', transition: 'color 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)',
transition: 'color 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)', transitionProperty: 'color, font-weight'
transitionProperty: 'color, font-weight' };
}),
[selected]
);
return ( return (
<ListItemButton <ListItemButton
@@ -78,6 +70,20 @@ const LayoutMenuItemComponent = ({
<Icon /> <Icon />
</ListItemIcon> </ListItemIcon>
<ListItemText sx={textStyles}>{label}</ListItemText> <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> </ListItemButton>
); );
}; };
@@ -5,6 +5,7 @@ import { Link } from 'react-router';
import NavigateNextIcon from '@mui/icons-material/NavigateNext'; import NavigateNextIcon from '@mui/icons-material/NavigateNext';
import { import {
Avatar, Avatar,
Box,
ListItem, ListItem,
ListItemAvatar, ListItemAvatar,
ListItemButton, ListItemButton,
@@ -20,6 +21,7 @@ interface ListMenuItemProps {
text: string; text: string;
to?: string; to?: string;
disabled?: boolean; disabled?: boolean;
badge?: boolean;
} }
const iconStyles: CSSProperties = { const iconStyles: CSSProperties = {
@@ -28,15 +30,40 @@ const iconStyles: CSSProperties = {
verticalAlign: 'middle' 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( const RenderIcon = memo(
({ icon: Icon, bgcolor, label, text }: ListMenuItemProps) => ( ({ icon: Icon, bgcolor, label, text, badge }: ListMenuItemProps) => (
<> <>
<ListItemAvatar> <ListItemAvatar>
<Avatar sx={{ bgcolor, color: 'white' }}> <Avatar sx={{ bgcolor, color: 'white' }}>
<Icon /> <Icon />
</Avatar> </Avatar>
</ListItemAvatar> </ListItemAvatar>
<ListItemText primary={label} secondary={text} /> <ListItemText
primary={
<>
{label}
{badge && <Badge />}
</>
}
secondary={text}
/>
</> </>
) )
); );
@@ -47,7 +74,8 @@ const LayoutMenuItem = ({
label, label,
text, text,
to, to,
disabled disabled,
badge
}: ListMenuItemProps) => ( }: ListMenuItemProps) => (
<> <>
{to && !disabled ? ( {to && !disabled ? (
@@ -65,6 +93,7 @@ const LayoutMenuItem = ({
{...(bgcolor && { bgcolor })} {...(bgcolor && { bgcolor })}
label={label} label={label}
text={text} text={text}
{...(badge && { badge })}
/> />
</ListItemButton> </ListItemButton>
</ListItem> </ListItem>
@@ -75,6 +104,7 @@ const LayoutMenuItem = ({
{...(bgcolor && { bgcolor })} {...(bgcolor && { bgcolor })}
label={label} label={label}
text={text} text={text}
{...(badge && { badge })}
/> />
</ListItem> </ListItem>
)} )}
@@ -1,4 +1,4 @@
import { memo, useCallback } from 'react'; import { memo } from 'react';
import type { Blocker } from 'react-router'; import type { Blocker } from 'react-router';
import { import {
@@ -15,13 +15,13 @@ import { useI18nContext } from 'i18n/i18n-react';
const BlockNavigation = ({ blocker }: { blocker: Blocker }) => { const BlockNavigation = ({ blocker }: { blocker: Blocker }) => {
const { LL } = useI18nContext(); const { LL } = useI18nContext();
const handleReset = useCallback(() => { const handleReset = () => {
blocker.reset?.(); blocker.reset?.();
}, [blocker]); };
const handleProceed = useCallback(() => { const handleProceed = () => {
blocker.proceed?.(); blocker.proceed?.();
}, [blocker]); };
return ( return (
<Dialog sx={dialogStyle} open={blocker.state === 'blocked'}> <Dialog sx={dialogStyle} open={blocker.state === 'blocked'}>
@@ -1,17 +1,14 @@
import { memo, useContext } from 'react'; import { memo, useContext } from 'react';
import type { FC } from 'react'; import { Navigate, Outlet } from 'react-router';
import { Navigate } from 'react-router';
import { AuthenticatedContext } from 'contexts/authentication'; import { AuthenticatedContext } from 'contexts/authentication';
import type { RequiredChildrenProps } from 'utils';
const RequireAdmin: FC<RequiredChildrenProps> = ({ children }) => { // Layout-route guard: renders nested admin routes only for admins, otherwise
const authenticatedContext = useContext(AuthenticatedContext); // redirects home. Must be used inside the authenticated route subtree so that
return authenticatedContext.me.admin ? ( // AuthenticatedContext (and `me`) is available.
<>{children}</> const RequireAdmin = () => {
) : ( const { me } = useContext(AuthenticatedContext);
<Navigate replace to="/" /> return me.admin ? <Outlet /> : <Navigate replace to="/" />;
);
}; };
export default memo(RequireAdmin); 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 type { FC } from 'react';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
@@ -16,12 +16,9 @@ const RouterTabs: FC<RouterTabsProps> = ({ value, children }) => {
const theme = useTheme(); const theme = useTheme();
const smallDown = useMediaQuery(theme.breakpoints.down('sm')); const smallDown = useMediaQuery(theme.breakpoints.down('sm'));
const handleTabChange = useCallback( const handleTabChange = (_event: unknown, path: string) => {
(_event: unknown, path: string) => { void navigate(path);
void navigate(path); };
},
[navigate]
);
return ( return (
<Tabs <Tabs
@@ -2,3 +2,4 @@ export { default as RouterTabs } from './RouterTabs';
export { default as RequireAdmin } from './RequireAdmin'; export { default as RequireAdmin } from './RequireAdmin';
export { default as RequireAuthenticated } from './RequireAuthenticated'; export { default as RequireAuthenticated } from './RequireAuthenticated';
export { default as RequireUnauthenticated } from './RequireUnauthenticated'; 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 useState
} from 'react'; } from 'react';
import { Link } from 'react-router'; import { Link } from 'react-router';
import { toast } from 'react-toastify';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
import CloudUploadIcon from '@mui/icons-material/CloudUpload'; import CloudUploadIcon from '@mui/icons-material/CloudUpload';
@@ -28,6 +27,7 @@ import { callAction } from 'api/app';
import { dialogStyle } from '@/CustomTheme'; import { dialogStyle } from '@/CustomTheme';
import { useRequest } from 'alova/client'; import { useRequest } from 'alova/client';
import { toast } from 'components/toast';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
const DocumentUploader = styled(Box)<{ active?: boolean }>(({ theme, active }) => ({ const DocumentUploader = styled(Box)<{ active?: boolean }>(({ theme, active }) => ({
@@ -91,7 +91,9 @@ const DragNdrop = ({ text, onFileSelected }: DragNdropProps) => {
).upgradeImportantMessageType; ).upgradeImportantMessageType;
setUpgradeImportantMessageType(upgradeImportantMessageType_n); setUpgradeImportantMessageType(upgradeImportantMessageType_n);
if (upgradeImportantMessageType_n === 0) { if (upgradeImportantMessageType_n === 0) {
onFileSelected(file); if (file) {
onFileSelected(file);
}
} }
}) })
.onError((error) => { .onError((error) => {
@@ -1,5 +1,4 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { toast } from 'react-toastify';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
import { Box, Button, Typography } from '@mui/material'; 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 * as SystemApi from 'api/system';
import { useRequest } from 'alova/client'; import { useRequest } from 'alova/client';
import { toast } from 'components/toast';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import DragNdrop from './DragNdrop'; import DragNdrop from './DragNdrop';
@@ -1,36 +1,55 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import type { FC } from 'react'; import type { FC } from 'react';
import { redirect } from 'react-router'; import { useNavigate } from 'react-router';
import { toast } from 'react-toastify';
import { callAction } from 'api/app';
import { ACCESS_TOKEN } from 'api/endpoints'; import { ACCESS_TOKEN } from 'api/endpoints';
import * as AuthenticationApi from 'components/routing/authentication'; import * as AuthenticationApi from 'components/routing/authentication';
import { useRequest } from 'alova/client'; import { useRequest } from 'alova/client';
import { LoadingSpinner } from 'components'; import { LoadingSpinner } from 'components';
import { verifyAuthorization } from 'components/routing/authentication'; import { verifyAuthorization } from 'components/routing/authentication';
import { toast } from 'components/toast';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import type { Me } from 'types'; import type { Me, VersionsResponse } from 'types';
import type { RequiredChildrenProps } from 'utils'; import type { RequiredChildrenProps } from 'utils';
import { AuthenticationContext } from './context'; import { AuthenticationContext } from './context';
const Authentication: FC<RequiredChildrenProps> = ({ children }) => { const Authentication: FC<RequiredChildrenProps> = ({ children }) => {
const { LL } = useI18nContext(); const { LL } = useI18nContext();
const navigate = useNavigate();
const [initialized, setInitialized] = useState<boolean>(false); const [initialized, setInitialized] = useState<boolean>(false);
const [me, setMe] = useState<Me>(); const [me, setMe] = useState<Me>();
const [versions, setVersions] = useState<VersionsResponse>();
const { send: sendVerifyAuthorization } = useRequest(verifyAuthorization(), { const { send: sendVerifyAuthorization } = useRequest(verifyAuthorization(), {
immediate: false immediate: false
}); });
const { send: sendGetVersions } = useRequest(
() => callAction({ action: 'getVersions' }),
{ immediate: false }
)
.onSuccess((event) => {
setVersions(event.data as VersionsResponse);
})
.onError(() => {
setVersions(undefined);
});
const refreshVersions = useCallback(async () => {
await sendGetVersions().catch(() => undefined);
}, []);
const signIn = (accessToken: string) => { const signIn = (accessToken: string) => {
try { try {
AuthenticationApi.getStorage().setItem(ACCESS_TOKEN, accessToken); AuthenticationApi.getStorage().setItem(ACCESS_TOKEN, accessToken);
const decodedMe = AuthenticationApi.decodeMeJWT(accessToken); const decodedMe = AuthenticationApi.decodeMeJWT(accessToken);
setMe(decodedMe); setMe(decodedMe);
toast.success(LL.LOGGED_IN({ name: decodedMe.username })); toast.success(LL.LOGGED_IN({ name: decodedMe.username }));
void refreshVersions();
} catch { } catch {
setMe(undefined); setMe(undefined);
throw new Error('Failed to parse JWT'); throw new Error('Failed to parse JWT');
@@ -40,8 +59,9 @@ const Authentication: FC<RequiredChildrenProps> = ({ children }) => {
const signOut = (doRedirect: boolean) => { const signOut = (doRedirect: boolean) => {
AuthenticationApi.clearAccessToken(); AuthenticationApi.clearAccessToken();
setMe(undefined); setMe(undefined);
setVersions(undefined);
if (doRedirect) { if (doRedirect) {
redirect('/'); void navigate('/', { replace: true });
} }
}; };
@@ -49,8 +69,9 @@ const Authentication: FC<RequiredChildrenProps> = ({ children }) => {
const accessToken = AuthenticationApi.getStorage().getItem(ACCESS_TOKEN); const accessToken = AuthenticationApi.getStorage().getItem(ACCESS_TOKEN);
if (accessToken) { if (accessToken) {
await sendVerifyAuthorization() await sendVerifyAuthorization()
.then(() => { .then(async () => {
setMe(AuthenticationApi.decodeMeJWT(accessToken)); setMe(AuthenticationApi.decodeMeJWT(accessToken));
await refreshVersions();
setInitialized(true); setInitialized(true);
}) })
.catch(() => { .catch(() => {
@@ -67,15 +88,16 @@ const Authentication: FC<RequiredChildrenProps> = ({ children }) => {
void refresh(); void refresh();
}, [refresh]); }, [refresh]);
// cache object to prevent re-renders
const obj = useMemo( const obj = useMemo(
() => ({ () => ({
signIn, signIn,
signOut, signOut,
refresh, refresh,
...(me && { me }) refreshVersions,
...(me && { me }),
...(versions && { versions })
}), }),
[signIn, signOut, me, refresh] [signIn, signOut, me, refresh, refreshVersions, versions]
); );
if (initialized) { if (initialized) {
@@ -1,12 +1,14 @@
import { createContext } from 'react'; import { createContext } from 'react';
import type { Me } from 'types'; import type { Me, VersionsResponse } from 'types';
export interface AuthenticationContextValue { export interface AuthenticationContextValue {
refresh: () => Promise<void>; refresh: () => Promise<void>;
signIn: (accessToken: string) => void; signIn: (accessToken: string) => void;
signOut: (redirect: boolean) => void; signOut: (redirect: boolean) => void;
me?: Me; me?: Me;
versions?: VersionsResponse;
refreshVersions: () => Promise<void>;
} }
const AuthenticationContextDefaultValue = {} as AuthenticationContextValue; const AuthenticationContextDefaultValue = {} as AuthenticationContextValue;

Before

Width:  |  Height:  |  Size: 207 B

After

Width:  |  Height:  |  Size: 207 B

+13 -10
View File
@@ -31,8 +31,8 @@ const cs: Translation = {
ID_OF: '{0} ID', ID_OF: '{0} ID',
DEVICE: 'Zařízení', DEVICE: 'Zařízení',
PRODUCT: 'Produkt', PRODUCT: 'Produkt',
VERSION: 'Verze',
BRAND: 'Značka', BRAND: 'Značka',
VERSION: 'Verze',
ENTITY_NAME: 'Název entity', ENTITY_NAME: 'Název entity',
VALUE: '{{value|Value}}', VALUE: '{{value|Value}}',
DEVICES: 'Zařízení', DEVICES: 'Zařízení',
@@ -102,8 +102,8 @@ const cs: Translation = {
PHY_TYPE: 'Typ Eth PHY', PHY_TYPE: 'Typ Eth PHY',
DISABLED: 'zakázáno', DISABLED: 'zakázáno',
TX_MODE: 'EMS Tx režim', TX_MODE: 'EMS Tx režim',
EMS_BUS: '{{Bus|EMS Bus}}',
HARDWARE: 'Hardware', HARDWARE: 'Hardware',
EMS_BUS: '{{BUS|EMS BUS}}',
GENERAL_OPTIONS: 'Obecné možnosti', GENERAL_OPTIONS: 'Obecné možnosti',
LANGUAGE_ENTITIES: 'Jazyk (pro entity zařízení)', LANGUAGE_ENTITIES: 'Jazyk (pro entity zařízení)',
HIDE_LED: 'Skrýt LED', HIDE_LED: 'Skrýt LED',
@@ -247,8 +247,7 @@ const cs: Translation = {
TIME_ZONE: 'Časová zóna', TIME_ZONE: 'Časová zóna',
ACCESS_POINT: 'Přístupový bod', ACCESS_POINT: 'Přístupový bod',
AP_PROVIDE: 'Povolit přístupový bod', AP_PROVIDE: 'Povolit přístupový bod',
AP_PROVIDE_TEXT_1: 'Vždy', AP_PROVIDE_TEXT_2: 'Když je síťové připojení stratené',
AP_PROVIDE_TEXT_2: 'Když je WiFi odpojena',
AP_PROVIDE_TEXT_3: 'Nikdy', AP_PROVIDE_TEXT_3: 'Nikdy',
AP_PREFERRED_CHANNEL: 'Preferovaný kanál', AP_PREFERRED_CHANNEL: 'Preferovaný kanál',
AP_HIDE_SSID: 'Skrýt SSID', AP_HIDE_SSID: 'Skrýt SSID',
@@ -262,7 +261,6 @@ const cs: Translation = {
SCAN_AGAIN: 'Skenovat znovu', SCAN_AGAIN: 'Skenovat znovu',
NETWORK_SCANNER: 'Síťový skener', NETWORK_SCANNER: 'Síťový skener',
NETWORK_NO_WIFI: 'Nenalezeny žádné WiFi sítě', 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', NETWORK_BLANK_BSSID: 'ponechte prázdné pokud použijete jen SSID',
TX_POWER: 'Vysílací výkon', TX_POWER: 'Vysílací výkon',
HOSTNAME: 'Název hostitele', HOSTNAME: 'Název hostitele',
@@ -288,14 +286,13 @@ const cs: Translation = {
STAY: 'Zůstat', STAY: 'Zůstat',
LEAVE: 'Odejít', LEAVE: 'Odejít',
SCHEDULER: 'Plánovač', 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_1: 'Automatizujte příkazy přidáním naplánovaných událostí níže',
SCHEDULER_HELP_2: 'Použijte 00:00 pro spuštění při startu', SCHEDULER_HELP_2: 'při startu',
SCHEDULE: 'Harmonogram', SCHEDULE: 'Harmonogram',
TIME: 'Čas', TIME: 'Čas',
TIMER: 'Časovač', TIMER: 'Časovač',
ONCHANGE: 'Při změně', ONCHANGE: 'Při změně',
CONDITION: 'Podmínka', CONDITION: 'Podmínka',
IMMEDIATE: 'Ihned',
SCHEDULE_UPDATED: 'Harmonogram aktualizován', SCHEDULE_UPDATED: 'Harmonogram aktualizován',
SCHEDULE_TIMER_1: 'při startu', SCHEDULE_TIMER_1: 'při startu',
SCHEDULE_TIMER_2: 'každou minutu', SCHEDULE_TIMER_2: 'každou minutu',
@@ -343,6 +340,7 @@ const cs: Translation = {
AUTO_SCROLL: 'Automatické rolování', AUTO_SCROLL: 'Automatické rolování',
DASHBOARD: 'Dashboard', DASHBOARD: 'Dashboard',
DEVELOPER_MODE: 'Režim vývojáře', DEVELOPER_MODE: 'Režim vývojáře',
DISABLE_RESET: 'Zakázat obnovení továrního nastavení',
BYTES: 'Bajty', BYTES: 'Bajty',
BITMASK: 'Bit Mask', BITMASK: 'Bit Mask',
DUPLICATE: 'Duplikát', DUPLICATE: 'Duplikát',
@@ -364,8 +362,13 @@ const cs: Translation = {
UPGRADE_IMPORTANT_MESSAGES: 'Aktualizovat důležité zprávy', 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_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.', 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_UPDATED: 'Příkazy byly aktualizovány',
COMMANDS_HELP_1: 'Definujte vlastní příkazy pro magistrali EMS',
EXECUTE_COMMAND_SENT: 'Příkaz odeslán',
}; };
export default cs; export default cs;

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