Compare commits

..
186 Commits
Author SHA1 Message Date
Proddy 13c32192ff Merge pull request #3323 from proddy/dev
fix HA discovery not refreshed when entity customization is cleared
2026-10-27 22:10:34 +01:00
proddy 63935c4748 fix HA discovery not refreshed when entity customization is cleared 2026-10-27 22:09:16 +01:00
Proddy 33df92fdba Merge branch 'emsesp:dev' into dev 2026-10-26 21:25:21 +01:00
Proddy 8f7049b47a Merge pull request #3316 from neonll/fix/c6-default-board-pins 2026-10-24 10:00:26 +02:00
Alexander DandClaude Opus 5.5 e1db103470 fix: give ESP32-C6 valid default GPIOs on first start
The C6 autodetect picked the CUSTOM profile, but load_board_profile() has
no entry for it, so Rx, Tx and the button stayed at the placeholder 99.
The startup settings load then failed the GPIO check and fell back to an
empty settings object (blank Board Profile, Tx Mode and Bus ID), and every
Application settings save returned 400 until valid pins were sent together
with CUSTOM through the API.

Start a C6 without a profile as CUSTOM with pins for the Seeed XIAO
ESP32-C6 (the c6_xiao_4M build): Rx GPIO20 (D9), Tx GPIO19 (D8), button
GPIO9 (BOOT), no LED, no Dallas sensor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 19:39:06 +00:00
proddy 6dbb53e5af add off to RC300/RC310 nofrostmode #3311 2026-10-21 23:27:01 +02:00
Proddy dc2823c1de Merge pull request #3309 from proddy/dev
update time zone strings to IANA tz 2026e
2026-10-20 22:07:04 +02:00
Proddy 304eae34bd Merge branch 'dev' into dev 2026-10-20 22:04:53 +02:00
Proddy 960e7376fe Merge pull request #3301 from csutcliff/pr/mqtt-task-stack
fix(mqtt): 8 KB MQTT task stack when TLS verifies a CA certificate
2026-10-20 21:20:57 +02:00
proddy d91cf08b1d pkg update 2026-10-20 21:18:28 +02:00
proddy 746e5c5ef3 migrate stored TZ formats from IANA labels on boot 2026-10-20 21:14:45 +02:00
CyberNative AI LLC 955a6d8c56 fix(ntp): update time zone strings to IANA tz 2026e
The TIME_ZONES table predates several tz releases, so 32 zones give the
wrong UTC offset today or within the next year when the device applies
the string with setenv("TZ"): Mexico (no DST since 2022), Egypt (DST
again since 2023), Kazakhstan, Morocco, Paraguay, Iran, Jordan, Syria,
Greenland, and from 1 November 2026 British Columbia, Alberta, the
Northwest Territories and Manitoba (permanent UTC-7, -6, -6 and -5).

Each value is now the POSIX TZ footer of the zone's TZif file in IANA
tz 2026e (PyPI tzdata 2026.5). The keys and their order are unchanged.
"UNK" abbreviations become the numeric <+01> form, which newlib accepts.

America/Godthab and America/Scoresbysund still show summer time from
1 January to the last Sunday in March on newlib: their rule time is
negative (M3.5.0/-1) and newlib's tzset reads it as unsigned. That is
a newlib bug, not a table error.
2026-10-20 21:12:14 +02:00
proddy 19187431ce fix 2026-10-20 18:17:59 +02:00
Chris Sutcliff cdf6375c71 fix(mqtt): 8 KB MQTT task stack when TLS verifies a CA certificate
On boards with PSRAM the MQTT client runs in its own task with a 5120
byte stack. Verifying an EC certificate chain against a CA, such as
Let's Encrypt ISRG Root X2, needs about 6 KB, so the task overflowed
and the board boot looped on every connect.

The espMqttClient constructors now take the stack size, and
startClient() passes EMSESP_MQTT_STACKSIZE_CA (8192) when TLS is on
with a CA, keeping the 5120 default for insecure TLS and plain MQTT.
The client is recreated when that changes. The stack usage log
reports against the size the task was created with.
2026-10-20 12:40:59 +01:00
Proddy 2a72d48468 Merge pull request #3290 from proddy/dev
Fixes and new enhancements
2026-10-20 13:08:34 +02:00
Proddy ed0aedcce0 Merge branch 'dev' into dev 2026-10-20 13:07:40 +02:00
proddy fe7bc1c40f fix SoftAP fallback to keep retrying WiFi STA after cold boot 2026-10-20 13:06:26 +02:00
proddy 65385dfa3c pkg update 2026-10-20 12:38:48 +02:00
proddy b95873169e more EMSESP_GATEWAY_TX_HOLD tests 2026-10-20 12:38:44 +02:00
proddy 90fab51af3 update 2026-10-20 12:37:31 +02:00
proddy ac6de5bb44 update 2026-10-20 12:37:26 +02:00
proddy 4cfead4f58 update scripts for md5 check 2026-10-20 12:37:19 +02:00
Proddy f20c064a2c Merge pull request #3306 from csutcliff/pr/upload-406-response
fix(upload): send the 406 response for a rejected upload
2026-10-19 22:45:27 +02:00
Proddy d449d62b9f Merge pull request #3305 from csutcliff/pr/upload-error-free
fix(upload): don't crash when an upload is rejected
2026-10-19 22:44:37 +02:00
Chris Sutcliff 8cd23c3265 fix(upload): send the 406 response for a rejected upload 2026-10-19 21:07:40 +01:00
Chris Sutcliff 05575a28d5 fix(upload): don't crash when an upload is rejected 2026-10-19 20:52:21 +01:00
Proddy c1395f29c1 Merge pull request #3304 from csutcliff/pr/upload-restart
fix(upload): restart after a firmware upload when the client closes the connection
2026-10-19 21:24:50 +02:00
proddy 4fa3319caf #3297 2026-10-18 16:14:49 +02:00
proddy a52c98e789 fix negative values - #3297 2026-10-18 16:11:58 +02:00
proddy 9ff4dadedd remove dot 2026-10-17 15:24:53 +02:00
proddy 93a986be7f updates with recent changes in dev-19 2026-10-17 15:23:07 +02:00
proddy 88be02aae2 update 2026-10-17 15:10:21 +02:00
proddy 4d43307e9a #3290 2026-10-17 15:07:25 +02:00
proddy 65fb1ea299 update 2026-10-17 14:35:40 +02:00
proddy d49a6447e3 chore: update generated files for v3.9.0-dev.19 2026-10-17 12:33:02 +00:00
proddy 674a554eae #3296 2026-10-17 14:22:06 +02:00
proddy 921807211b pkg update 2026-10-17 13:50:00 +02:00
proddy 4d779cd972 more tests for #3094 2026-10-17 13:49:11 +02:00
proddy cbe8ea21af update 2026-10-17 13:45:13 +02:00
proddy 66d30570ac custom min/max applied when limits read 2026-10-17 13:39:20 +02:00
proddy caedc0709c remove obsolete 2026-10-17 13:39:05 +02:00
proddy f653ef447b chore: update generated files for v3.9.0-dev.19 2026-10-15 19:50:18 +00:00
proddy b829b2df5b updates 2026-10-15 21:41:33 +02:00
proddy d51a2a4481 #3273 2026-10-15 21:37:41 +02:00
proddy 5657aacb8b formatting 2026-10-15 21:21:50 +02:00
proddy d7b8c72ac2 fixes #3224 2026-10-15 21:00:15 +02:00
proddy f63dd2a030 use latest core libs 2026-10-14 12:06:26 +02:00
proddy da6c11197f pkg update 2026-10-14 10:35:32 +02:00
proddy ed4b82b962 update custom names 2026-10-14 10:35:26 +02:00
Chris Sutcliff 69a58e64ca fix(upload): restart after a firmware upload when the client closes the connection
uploadComplete() leaves _is_firmware / _is_filesystem set after a
successful upload. When the client then closes the connection,
handleDisconnect() sees the flags, treats it as an aborted upload and
sets the status back to NORMAL, cancelling the pending restart. The WebUI
gets away with it because its status poll turns the pending restart into
a requested one first; an upload from curl or a script is accepted
(md5_ok) but the device keeps running the old firmware until something
else restarts it.

Clear the flags once the upload has completed.
2026-10-14 00:52:28 +01:00
proddy 68d7f6e444 New Entity: Cooling status for free cooling
Fixes #3097
2026-10-13 22:40:09 +02:00
proddy 8693d27a02 show reset button after change 2026-10-13 21:07:56 +02:00
proddy 4515d1de85 pkg update 2026-10-13 19:55:22 +02:00
proddy b1abbd0f28 formatting 2026-10-13 19:55:15 +02:00
proddy 88239ce0e0 formatting 2026-10-13 19:54:53 +02:00
proddy 6a0e8ebc48 Show a warning when a customization reset clears min/max until restart 2026-10-13 19:52:55 +02:00
proddy bbacd7021a another fix for #3283 2026-10-13 18:37:39 +02:00
proddy e9bb68eeb5 update 2026-10-13 08:03:59 +02:00
proddy cdd64f1de0 moderately smaller WiFi/LWIP/ETH buffers for internal heap 2026-10-13 08:03:59 +02:00
Proddy 8ee23dd8dc Merge branch 'emsesp:dev' into dev 2026-10-13 07:51:57 +02:00
Proddy 6e9ed5e516 Merge pull request #3291 from hkuehl/claude/intelligent-curie-mm0eyf
fix: restart from WebUI no longer needs the browser tab to stay open
2026-10-13 07:51:32 +02:00
hkuehl 4861d50c21 fix: restart from WebUI no longer needs the browser tab to stay open
A restart requested from the WebUI (Restart button, firmware upload,
firmware update from URL, settings or customization upload) is set to
PENDING_RESTART and was only escalated to RESTART_REQUESTED when the
WebUI's SystemMonitor polled /rest/systemStatus. If the tab was closed,
nothing ever escalated it and the generic 5 minute status timeout reset
it back to NORMAL, so the restart was dropped and an uploaded firmware
was never booted.

- System::loop() now escalates a pending restart itself if it hasn't been
  picked up within 10 seconds (the SystemMonitor polls every second)
- the generic status reset timeout skips PENDING_RESTART, and its timer is
  cleared whenever the status is back to normal, so a start time left over
  from an earlier status can't reset a later one (e.g. a pending restart
  right after a firmware download) immediately
2026-10-13 05:15:13 +00:00
Proddy 705bab92d8 Merge branch 'emsesp:dev' into dev 2026-10-13 00:08:57 +02:00
proddy 540a87f465 #3273 2026-10-12 23:19:16 +02:00
proddy 229ba81301 #3284 2026-10-12 22:23:05 +02:00
proddy 4fb5d366d9 fixes #3283 2026-10-12 19:56:55 +02:00
proddy 9328c946d5 fixes #3280 2026-10-12 19:54:16 +02:00
proddy f1e158d2a6 update 2026-10-12 19:50:50 +02:00
proddy aad2854e4c chore: update generated files for v3.9.0-dev.19 2026-10-12 17:45:52 +00:00
proddy 721866c528 fix for #3258 2026-10-12 19:37:45 +02:00
proddy 77ede5cea2 use latest arduino libs 2026-10-12 19:37:30 +02:00
proddy e9b85559e6 update 2026-10-12 19:37:13 +02:00
proddy 0e0b75dc6b dev-19 2026-10-12 19:37:07 +02:00
proddy ff2fc3d514 update docs 2026-10-12 19:37:01 +02:00
Proddy de9a082668 Merge pull request #3282 from emsesp/dependabot/github_actions/github-actions-6de4297576
Bump SonarSource/sonarqube-scan-action from 8.2.2 to 8.3.0 in the github-actions group
2026-10-11 16:50:41 +02:00
proddy 5ae89714c2 chore: update generated files for v3.9.0-dev.18 2026-10-11 14:33:31 +00:00
dependabot[bot] 84060449cd Bump SonarSource/sonarqube-scan-action
Bumps the github-actions group with 1 update: [SonarSource/sonarqube-scan-action](https://github.com/sonarsource/sonarqube-scan-action).


Updates `SonarSource/sonarqube-scan-action` from 8.2.2 to 8.3.0
- [Release notes](https://github.com/sonarsource/sonarqube-scan-action/releases)
- [Commits](https://github.com/sonarsource/sonarqube-scan-action/compare/v8.2.2...v8.3.0)

---
updated-dependencies:
- dependency-name: SonarSource/sonarqube-scan-action
  dependency-version: 8.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-11 14:27:10 +00:00
Proddy f9c9496c71 Merge pull request #3281 from proddy/dev
Recovery App and new Arduino custom-built libs
2026-10-11 16:25:10 +02:00
Proddy 546e07a534 Merge branch 'dev' into dev 2026-10-11 16:25:01 +02:00
proddy 956f7ab098 show recovery app in Version page 2026-10-11 16:23:08 +02:00
Proddy c6157dee22 Merge pull request #3279 from gnakh/disinfect-duration-3278
Add heat pump dhw/disinfectduration (0xEA offset 15), #3278
2026-10-11 11:57:37 +02:00
proddy 9934d05c5a use latest esp32-arduino-lib-builder 2026-10-11 11:55:33 +02:00
proddy 33a38c30fe optimize build 2026-10-11 10:43:47 +02:00
proddy 43778328e1 remove 2026-10-11 10:37:43 +02:00
proddy e7fd8bcdf0 don't run on forlks 2026-10-11 10:35:41 +02:00
proddy 229187d912 check GH action updates 2026-10-11 10:35:27 +02:00
proddy fe4fe7ef28 add comments 2026-10-11 10:35:14 +02:00
proddy 0094df41d9 package update 2026-10-11 10:34:39 +02:00
gnakhandClaude Opus 5.5 0c8e496296 Merge branch 'dev' of https://github.com/emsesp/EMS-ESP32 into disinfect-duration-3278
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:48:05 +02:00
proddy ec49237bd9 chore: update generated files for v3.9.0-dev.18 2026-10-10 11:41:25 +00:00
proddy b730716791 update runners 2026-10-10 13:33:08 +02:00
proddy 62f2987b56 update changelog, use new core libs 2026-10-10 12:59:46 +02:00
proddyandCursor e10983cca1 Merge branch 'feat/custom-sdkconfig' into dev
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-10 12:34:51 +02:00
Proddy c3894bab5c Merge pull request #3269 from proddy/feat/recovery-partition
Feat/recovery partition - #3268
2026-10-10 12:30:06 +02:00
proddy b7c795e320 pkg update 2026-10-10 12:28:49 +02:00
proddy 0719340f46 formatting 2026-10-10 12:18:07 +02:00
proddy 8c835a9e8b add factory reset to Recovery 2026-10-10 11:31:44 +02:00
gnakhandClaude Opus 5.5 84bc0afeb7 add heat pump dhw/disinfectduration (0xEA offset 15), #3278
The DHW thermal disinfection duration (time allowed to reach the
disinfection temperature; on timeout HPC4x0 reports A01(5284)) is
sent in UBAParameterWWPlus 0xEA offset 15 in steps of 10 min, but was
not decoded. Add boiler/dhw/disinfectduration for heat pumps,
readable and writeable, range 120-240 min as on the HPC410 menu.

Tested on a Bosch Compress 3400AW with HPC410: changing the duration
on the controller writes 0xEA offset 15 (150 min -> 0F, 180 -> 12),
and writing 0B 08 EA 0F 11 from EMS-ESP made the HPC410 menu show
170 min without errors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 11:09:03 +02:00
proddy 04d373b93c 3.9.0-dev.18 2026-10-10 00:00:00 +02:00
proddy c959cb1e8a #3275 2026-10-09 23:55:15 +02:00
proddy 09aa40e604 use new arduino core lib 2026-10-09 18:10:33 +02:00
proddy e3b1eb6c51 test new esp32-arduino-lib-builder packages 2026-10-09 17:47:01 +02:00
Proddy 673c7462f5 Merge branch 'emsesp:dev' into feat/custom-sdkconfig 2026-10-09 16:20:14 +02:00
Proddy e19a45f5b8 Merge branch 'emsesp:dev' into feat/recovery-partition 2026-10-09 16:20:04 +02:00
Proddy 981b14a768 Merge pull request #3274 from MichaelDvP/dev
fixes for #3262, #3265, #3272, #3273
2026-10-09 16:19:24 +02:00
MichaelDvP 90eee92560 chore: update generated files for v3.9.0-dev.16 2026-10-09 11:06:05 +00:00
MichaelDvP 037451ace8 Merge remote-tracking branch 'emsesp/dev' into dev 2026-10-09 11:59:08 +02:00
MichaelDvP 7fc094d6f7 changelog 2026-10-09 11:55:39 +02:00
MichaelDvP 410e92e4d0 limits for dhw diff temperature, fix #3273 2026-10-09 11:54:37 +02:00
MichaelDvP 6472a1a424 show temperature for 0x40, fix #3272 2026-10-09 11:54:10 +02:00
proddy eaaea504df rename tasmota, fix includes 2026-10-09 08:36:15 +02:00
proddy cefd5a14cf fix include conflicts 2026-10-09 08:35:59 +02:00
Proddy 46f2090b30 Fix platformio.ini: remove duplicated content from previous commit 2026-10-08 23:34:11 +02:00
Proddy 07514260f4 Move custom_sdkconfig trial env from s3_16M_P to s_16M_P 2026-10-08 23:32:23 +02:00
Proddy dfe009f5c3 Add s3_16M_P_custom env to trial a trimmed framework via custom_sdkconfig 2026-10-08 23:28:40 +02:00
Proddy c5caedd303 Merge branch 'emsesp:dev' into feat/recovery-partition 2026-10-08 18:25:12 +02:00
proddy 30c55c4f0e update 2026-10-08 18:24:51 +02:00
proddy 105ef8cf40 update dev-16 2026-10-08 18:21:37 +02:00
proddy 3f038e5ba0 Ack polls during gateway Tx hold (#3094) 2026-10-08 18:21:07 +02:00
Proddy af3c05247c Merge pull request #3270 from proddy/dev
A few changes for 3094, 3258, 3257
2026-10-08 18:08:26 +02:00
proddy 6fa08808dd update dev-16 2026-10-08 18:03:46 +02:00
proddy 613e3c87ae pkg update 2026-10-08 18:03:38 +02:00
proddy 988ad0006b remove test docs 2026-10-08 17:48:51 +02:00
proddy ec6184333e update flash script 2026-10-08 17:34:14 +02:00
proddy 7e96c1ea0a update 2026-10-08 13:40:01 +02:00
proddy 7851ae86c7 pkg update 2026-10-08 13:37:56 +02:00
MichaelDvP 61177d1fa1 tx-mode 0 selectable for developers 2026-10-08 13:11:46 +02:00
MichaelDvP 8856d8f9c9 fix maxHeat #3262, #3265 2026-10-08 10:34:32 +02:00
MichaelDvP 987710ac27 fix CUTOM board serial terminal issue 2026-10-08 10:33:14 +02:00
MichaelDvP a61a0cc64c rgb-led timing: more exact 2026-10-08 10:32:17 +02:00
MichaelDvP c290543c7f update pkg 2026-10-08 10:31:39 +02:00
MichaelDvP 49ac5cd9b6 Merge remote-tracking branch 'emsesp/dev' into dev 2026-10-08 07:56:11 +02:00
proddy 92e2a4b6c3 build recovery 2026-10-08 00:01:02 +02:00
proddy 965d93661a crashmode 2026-10-07 23:00:14 +02:00
proddyandCursor 6411b6ffbd add recovery firmware with OTA updater and recovery web interface
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-06 22:57:25 +02:00
proddy f8e7dd9244 Ack polls during gateway Tx hold (#3094) 2026-10-06 21:42:51 +02:00
proddy 1375f87163 add precision to voltage in HA 2026-10-04 14:29:17 +02:00
proddy 4b3f6015ee tests for gateway speeds 2026-10-04 14:28:53 +02:00
proddy ca79e2e09b skip duplicate reads 2026-10-04 14:28:25 +02:00
proddy b51d153dc5 show status in guest mode 2026-10-04 14:28:12 +02:00
proddy 79c2e5e8a1 formatting type column 2026-10-04 14:28:02 +02:00
Proddy 597939c896 Merge branch 'emsesp:dev' into dev 2026-10-04 10:18:48 +02:00
Proddy eb4232d6c8 Merge pull request #3261 from RaHehl/fix/de-heatpump-sensor-names
fix: German names of heat pump sensors TC0, TC1, TC3 and TA4
2026-10-04 10:08:31 +02:00
proddy 3ea25eba43 EMSESP_TX_PRE_DELAY 2026-10-04 09:56:34 +02:00
proddy 61eaee2ffa enable internal pull-up on the UART Rx 2026-10-04 09:22:40 +02:00
Raphael Hehl 3666f21d41 fix: German names of heat pump sensors TC0, TC1, TC3 and TA4
The English names of TC0, TC1 and TA4 are correct, the German
translations were not. Use the terms from the Bosch manuals.
2026-10-03 00:18:51 +02:00
proddy e4f9d4a96d #3257 summermode back and a test for #3094 2026-10-01 17:52:52 +02:00
proddy 635daa8ab8 chore: update generated files for v3.9.0-dev.16 2026-09-30 16:22:18 +00:00
proddy 52b24d5641 #3257 2026-09-30 18:04:34 +02:00
Proddy 619fe46a4e Merge pull request #3255 from proddy/dev
update GH actions
2026-09-27 15:31:48 +02:00
Proddy 115f5281f4 Merge branch 'emsesp:dev' into dev 2026-09-27 15:17:42 +02:00
proddy 333e2d83d7 update 2026-09-27 15:10:45 +02:00
proddy 3befcb638d update github actions 2026-09-27 14:02:09 +02:00
proddy 8a48eb75d2 formatting 2026-09-27 10:34:55 +02:00
proddy 74ff39b22e chore: update generated files for v3.9.0-dev.15 2026-09-27 08:05:03 +00:00
Proddy 960a7a554e Merge pull request #3254 from proddy/dev
minor updates
2026-09-27 09:48:36 +02:00
Proddy 4ffe8c04e9 Merge branch 'dev' into dev 2026-09-27 09:45:34 +02:00
Proddy ebd85f96a6 Merge pull request #3252 from Bingo2023/pool-signals
add heatpump pool on/off, pool active, MP100 shunt setpoint and pool …
2026-09-27 09:40:44 +02:00
proddy f768225533 revert back to Tasmota 2026.09.50 2026-09-27 09:40:12 +02:00
proddy 25d21f75ab pkg update 2026-09-27 09:39:54 +02:00
Proddy 1e49bd54a0 Merge branch 'dev' into pool-signals 2026-09-27 09:39:20 +02:00
proddy 7fe65b7f7b quicker restart 2026-09-27 09:28:49 +02:00
proddy 7c6101e819 prevent reboot on filesystem upload 2026-09-26 21:03:17 +02:00
proddy 684d6b132d tasmota 2026.09.50 2026-09-26 21:03:02 +02:00
proddy 15fc5e1333 pkg update 2026-09-26 21:02:35 +02:00
Bingo2023 f550503fe9 Update locale_translations.h
shorten
2026-09-25 18:50:26 +02:00
Bingo2023andClaude Opus 5.5 8cdd9f5d79 rename pool translations to MP100 block, shorter names
- block comment `// mixer pool` -> `// MP100 pool module`, hydrTemp moved to `// water`
- `poolpump` -> `poolrequest` (pool request, MP100 input contacts)
- shorter en/de texts: pool setpoint / Pool Soll, valve status / Ventil Status,
  valve actual / Ventil Ist, valve setpoint / Ventil Soll
- existing short names (poolsettemp, pooltemp, poolshuntstatus, poolshunt) unchanged

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013YrkDbvxEv3ZMS7SPSKCGd
2026-09-25 18:42:45 +02:00
proddy 8c496d8f77 chore: update generated files for v3.9.0-dev.15 2026-09-25 14:42:39 +00:00
proddy e5456293aa #3196 - heatingpumpflow fix 2026-09-25 16:24:48 +02:00
proddy a2f8f35285 remove void last bit 2026-09-25 16:24:25 +02:00
proddy fb8edfb6a7 change year 2025 to 2026 2026-09-25 08:44:57 +02:00
proddy b7a174ddea #3196 2026-09-25 08:44:42 +02:00
proddy 446738f0d9 remove condition, now handled in erase_command 2026-09-24 22:20:26 +02:00
proddy 8b1de52f55 fix possible lost pointers, could cause a crash 2026-09-24 22:12:19 +02:00
Proddy 491bb01088 Merge pull request #3253 from proddy/dev
replacement for rgbLedWrite() to support new v6 of the WS2812B driver
2026-09-24 18:44:32 +02:00
Proddy 37d571b6e9 Merge branch 'emsesp:dev' into dev 2026-09-24 18:43:33 +02:00
proddy f05105861e update doc 2026-09-24 18:43:02 +02:00
proddy 09e8ef5403 pkg update 2026-09-24 17:29:06 +02:00
Bingo2023andClaude Opus 5.5 1d2b916935 add heatpump pool on/off, pool active, MP100 shunt setpoint and pool pump
Tested with a heatpump (0x08) and MP100 pool module (0x11), custom entities
compared against RC300 operation and a power meter on the circulation pump.

- boiler `poolon` (0x48A offset 0), writable, RC300 writes 01/00
- boiler `hppoolon` (0x488 offset 0 bit 6), pool heating actually active
- pool `poolshuntset` (0x5C4 offset 0, heatpump -> MP100), shunt setpoint;
  `poolshunt` follows within ~2 min
- pool `poolpump` (0x5BA offset 4), 0 = running, 2 = off. This byte is only
  in MP100 -> heatpump telegrams, so the pool module now also handles
  telegrams it sends to other devices
- simulator: `test pool`

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013YrkDbvxEv3ZMS7SPSKCGd
2026-09-24 17:24:47 +02:00
proddy 100edcefe2 optimize LED and fix some edge cases 2026-09-24 17:21:41 +02:00
proddy eddc443d72 replacement for rgbLedWrite() to support new v6 of the WS2812B driver 2026-09-24 11:29:14 +02:00
proddy 40c4fdd2d1 move hardcoded consts to header files 2026-09-24 11:28:14 +02:00
MichaelDvP 44cb96a2c4 pkg 2026-09-24 08:41:41 +02:00
MichaelDvP ba2c552276 Merge remote-tracking branch 'emsesp/dev' into dev 2026-09-24 08:20:19 +02:00
MichaelDvP 00f648051e Merge remote-tracking branch 'emsesp/dev' into dev 2026-09-23 19:34:42 +02:00
MichaelDvP c3af7aa3ad pkg 2026-09-23 19:33:33 +02:00
233 changed files with 28769 additions and 5663 deletions
+1
View File
@@ -30,6 +30,7 @@ BreakConstructorInitializersBeforeComma: true # Always break constructor initial
ExperimentalAutoDetectBinPacking: false
KeepEmptyLinesAtTheStartOfBlocks: false
MaxEmptyLinesToKeep: 1
InsertNewlineAtEOF: true # clang-format 16+: one newline at end of file if missing
PenaltyBreakBeforeFirstCallParameter: 200
PenaltyExcessCharacter: 10
PointerAlignment: Middle
+10
View File
@@ -0,0 +1,10 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
groups:
github-actions:
patterns:
- "*"
+258 -27
View File
@@ -11,29 +11,209 @@ on:
permissions:
contents: write
# Parallel graph (wall-clock is ~max of the firmware jobs, not the sum):
# webui ──────┐
# ├─► firmware (s_4M / s_16M / s_16M_P / s3_16M_P) ─┐
# modbus ─────┤ ├─► release
# └─► standalone ───────────────────────────────────┘
#
# Extra -j on a single runner does not help: PlatformIO already compiles each
# environment with all CPUs. The win is separate GitHub runners.
jobs:
pre-release:
name: 'Build Dev Release'
webui:
name: Build webUI
if: github.event_name == 'workflow_dispatch' || github.repository == 'emsesp/EMS-ESP32'
runs-on: ubuntu-latest
steps:
- name: Install python 3.13
uses: actions/setup-python@v6
- name: Checkout repository
uses: actions/checkout@v7
- name: Install pnpm
uses: pnpm/action-setup@v6
with:
python-version: '3.13'
package_json_file: interface/package.json
- name: Install Node.js 26
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: 26
- name: Cache pnpm store
uses: actions/cache@v6
with:
path: ~/.local/share/pnpm/store
key: ${{ runner.os }}-pnpm-${{ hashFiles('interface/package.json') }}
restore-keys: |
${{ runner.os }}-pnpm-
- name: Build webUI
env:
CI: true
working-directory: interface
run: |
pnpm install
pnpm build-webUI
- name: Upload WWWData.h
uses: actions/upload-artifact@v7
with:
name: wwwdata
path: src/ESP32React/WWWData.h
if-no-files-found: error
modbus:
name: Build modbus
if: github.event_name == 'workflow_dispatch' || github.repository == 'emsesp/EMS-ESP32'
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Install python 3.13
uses: actions/setup-python@v7
with:
python-version: '3.13'
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
version: "latest"
enable-cache: false
- name: Install pioarduino
run: uv pip install --system pioarduino==6.2.0
- name: Build modbus
run: platformio run -e build_modbus
- name: Upload modbus header
uses: actions/upload-artifact@v7
with:
name: modbus-header
path: src/core/modbus_entity_parameters.hpp
if-no-files-found: error
standalone:
name: Build standalone
needs: modbus
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Install python 3.13
uses: actions/setup-python@v7
with:
python-version: '3.13'
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
version: "latest"
enable-cache: false
- name: Install pioarduino
run: uv pip install --system pioarduino==6.2.0
- name: Restore generated modbus header
uses: actions/download-artifact@v8
with:
name: modbus-header
path: src/core
- name: Build standalone
run: platformio run -e build_standalone
- name: Upload generated docs
uses: actions/upload-artifact@v7
with:
name: generated-docs
path: |
docs/dump_entities.csv
docs/dump_telegrams.csv
docs/Modbus-Entity-Registers.md
if-no-files-found: error
firmware:
name: Firmware ${{ matrix.env }}
needs: [webui, modbus]
runs-on: ubuntu-latest
strategy:
fail-fast: true
matrix:
# Keep in sync with platformio.ini default_envs
env: [s_4M, s_16M, s_16M_P, s3_16M_P]
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Install python 3.13
uses: actions/setup-python@v7
with:
python-version: '3.13'
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
version: "latest"
enable-cache: false
- name: Cache PlatformIO packages
uses: actions/cache@v6
with:
path: |
~/.platformio/.cache
~/.platformio/packages
~/.platformio/platforms
key: ${{ runner.os }}-pio-${{ matrix.env }}-${{ hashFiles('platformio.ini') }}
restore-keys: |
${{ runner.os }}-pio-${{ matrix.env }}-
${{ runner.os }}-pio-
- name: Install pioarduino
run: uv pip install --system pioarduino==6.2.0
- name: Restore WWWData.h
uses: actions/download-artifact@v8
with:
name: wwwdata
path: src/ESP32React
- name: Restore generated modbus header
uses: actions/download-artifact@v8
with:
name: modbus-header
path: src/core
- name: Setup ccache
uses: hendrikmuhs/ccache-action@v1.2.24
with:
key: firmware-${{ matrix.env }}
restore-keys: firmware-${{ matrix.env }}-
max-size: 1G
- name: Build ${{ matrix.env }}
env:
CCACHE_COMPILERCHECK: content
run: platformio run -e ${{ matrix.env }}
- name: Upload firmware
uses: actions/upload-artifact@v7
with:
name: firmware-${{ matrix.env }}
path: build/firmware
if-no-files-found: error
release:
name: Create GitHub Release
needs: [firmware, standalone]
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Install pnpm
run: npm install -g pnpm
- name: Get the EMS-ESP version
id: build_info
@@ -48,27 +228,77 @@ jobs:
prev=`git rev-parse -q --verify 'refs/tags/latest^{commit}' || git describe --tags --abbrev=0 --match 'v*'`
echo "REF=$prev" >> $GITHUB_OUTPUT
- name: Install PlatformIO
run: |
python -m pip install --upgrade pip
pip install -U platformio
python -m pip install intelhex
- name: Restore generated modbus header
uses: actions/download-artifact@v8
with:
name: modbus-header
path: src/core
- name: Build webUI
run: |
platformio run -e build-webUI
- name: Restore generated docs
uses: actions/download-artifact@v8
with:
name: generated-docs
path: .
- name: Build modbus
run: |
platformio run -e build_modbus
- name: Restore firmware binaries
uses: actions/download-artifact@v8
with:
pattern: firmware-*
merge-multiple: true
path: build/firmware
- name: Build standalone
run: |
platformio run -e build_standalone
- name: List firmware files
run: ls -la build/firmware
- name: Build all PIO target environments, from default_envs
# the recovery build needs PlatformIO, and pnpm + Node.js to build its own WebUI (recovery/WWWData.h)
- name: Install python 3.13
uses: actions/setup-python@v7
with:
python-version: '3.13'
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
version: "latest"
enable-cache: false
# falls back to the firmware jobs' caches, which hold the same toolchains
- name: Cache PlatformIO packages
uses: actions/cache@v6
with:
path: |
~/.platformio/.cache
~/.platformio/packages
~/.platformio/platforms
key: ${{ runner.os }}-pio-recovery-${{ hashFiles('platformio.ini') }}
restore-keys: |
${{ runner.os }}-pio-recovery-
${{ runner.os }}-pio-
- name: Install pioarduino
run: uv pip install --system pioarduino==6.2.0
- name: Install pnpm
uses: pnpm/action-setup@v6
with:
package_json_file: interface/package.json
- name: Install Node.js 26
uses: actions/setup-node@v7
with:
node-version: 26
- name: Cache pnpm store
uses: actions/cache@v6
with:
path: ~/.local/share/pnpm/store
key: ${{ runner.os }}-pnpm-${{ hashFiles('interface/package.json') }}
restore-keys: |
${{ runner.os }}-pnpm-
- name: Build the recovery firmware for the 16MB boards
run: |
platformio run
platformio run -e recovery_s_16M -e recovery_s_16M_P -e recovery_s3_16M_P
- name: Commit the generated files
uses: stefanzweifel/git-auto-commit-action@v7
@@ -97,6 +327,7 @@ jobs:
repo_token: '${{ secrets.GITHUB_TOKEN }}'
title: Development Build v${{steps.build_info.outputs.VERSION}}
body: ${{ steps.notes.outputs.BODY }}
generate_notes: false
automatic_release_tag: 'latest'
prerelease: true
files: |
@@ -11,10 +11,10 @@ jobs:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: GitHub Releases To Discord
uses: SethCohen/github-releases-to-discord@v1.13.1
uses: SethCohen/github-releases-to-discord@v1.20.0
with:
webhook_url: ${{ secrets.WEBHOOK_URL }}
color: '2105893'
+4 -4
View File
@@ -15,17 +15,17 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Install python 3.13
uses: actions/setup-python@v6
uses: actions/setup-python@v7
with:
python-version: '3.13'
- name: Install PlatformIO
- name: Install pioarduino
run: |
pip install wheel
pip install -U platformio
pip install -U "pioarduino==6.2.0"
- name: Run unit tests
run: |
+3 -3
View File
@@ -19,15 +19,15 @@ jobs:
BUILD_WRAPPER_OUT_DIR: bw-output
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Install Build Wrapper
uses: SonarSource/sonarqube-scan-action/install-build-wrapper@master
uses: SonarSource/sonarqube-scan-action/install-build-wrapper@v8.3.0
- name: Run Build Wrapper
run: build-wrapper-linux-x86-64 --out-dir ${{ env.BUILD_WRAPPER_OUT_DIR }} make all
- name: SonarQube Scan
uses: SonarSource/sonarqube-scan-action@master
uses: SonarSource/sonarqube-scan-action@v8.3.0
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
+238 -25
View File
@@ -9,28 +9,197 @@ on:
tags:
- 'v*'
# Parallel graph (wall-clock is ~max of the firmware jobs, not the sum):
# webui ──────┐
# ├─► firmware (s_4M / s_16M / s_16M_P / s3_16M_P) ─┐
# modbus ─────┤ ├─► release
# └─► standalone ───────────────────────────────────┘
#
# Extra -j on a single runner does not help: PlatformIO already compiles each
# environment with all CPUs. The win is separate GitHub runners.
jobs:
tagged-release:
name: 'Build Stable Release'
webui:
name: Build webUI
if: github.event_name == 'workflow_dispatch' || github.repository == 'emsesp/EMS-ESP32'
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Install python 3.13
uses: actions/setup-python@v6
- name: Install pnpm
uses: pnpm/action-setup@v6
with:
python-version: '3.13'
package_json_file: interface/package.json
- name: Install Node.js 26
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: 26
- name: Cache pnpm store
uses: actions/cache@v6
with:
path: ~/.local/share/pnpm/store
key: ${{ runner.os }}-pnpm-${{ hashFiles('interface/package.json') }}
restore-keys: |
${{ runner.os }}-pnpm-
- name: Build webUI
env:
CI: true
working-directory: interface
run: |
pnpm install
pnpm build-webUI
- name: Upload WWWData.h
uses: actions/upload-artifact@v7
with:
name: wwwdata
path: src/ESP32React/WWWData.h
if-no-files-found: error
modbus:
name: Build modbus
if: github.event_name == 'workflow_dispatch' || github.repository == 'emsesp/EMS-ESP32'
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Install pnpm
run: npm install -g pnpm
- name: Install python 3.13
uses: actions/setup-python@v7
with:
python-version: '3.13'
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
version: "latest"
enable-cache: false
- name: Install pioarduino
run: uv pip install --system pioarduino==6.2.0
- name: Build modbus
run: platformio run -e build_modbus
- name: Upload modbus header
uses: actions/upload-artifact@v7
with:
name: modbus-header
path: src/core/modbus_entity_parameters.hpp
if-no-files-found: error
standalone:
name: Build standalone
needs: modbus
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Install python 3.13
uses: actions/setup-python@v7
with:
python-version: '3.13'
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
version: "latest"
enable-cache: false
- name: Install pioarduino
run: uv pip install --system pioarduino==6.2.0
- name: Restore generated modbus header
uses: actions/download-artifact@v8
with:
name: modbus-header
path: src/core
- name: Build standalone
run: platformio run -e build_standalone
firmware:
name: Firmware ${{ matrix.env }}
needs: [webui, modbus]
runs-on: ubuntu-latest
strategy:
fail-fast: true
matrix:
# Keep in sync with platformio.ini default_envs
env: [s_4M, s_16M, s_16M_P, s3_16M_P]
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Install python 3.13
uses: actions/setup-python@v7
with:
python-version: '3.13'
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
version: "latest"
enable-cache: false
- name: Cache PlatformIO packages
uses: actions/cache@v6
with:
path: |
~/.platformio/.cache
~/.platformio/packages
~/.platformio/platforms
key: ${{ runner.os }}-pio-${{ matrix.env }}-${{ hashFiles('platformio.ini') }}
restore-keys: |
${{ runner.os }}-pio-${{ matrix.env }}-
${{ runner.os }}-pio-
- name: Install pioarduino
run: uv pip install --system pioarduino==6.2.0
- name: Restore WWWData.h
uses: actions/download-artifact@v8
with:
name: wwwdata
path: src/ESP32React
- name: Restore generated modbus header
uses: actions/download-artifact@v8
with:
name: modbus-header
path: src/core
- name: Setup ccache
uses: hendrikmuhs/ccache-action@v1.2.24
with:
key: firmware-${{ matrix.env }}
restore-keys: firmware-${{ matrix.env }}-
max-size: 1G
- name: Build ${{ matrix.env }}
env:
CCACHE_COMPILERCHECK: content
run: platformio run -e ${{ matrix.env }}
- name: Upload firmware
uses: actions/upload-artifact@v7
with:
name: firmware-${{ matrix.env }}
path: build/firmware
if-no-files-found: error
release:
name: Create GitHub Release
needs: [firmware, standalone]
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Get the EMS-ESP version
id: build_info
@@ -38,27 +207,71 @@ jobs:
version=`grep -E '^#define EMSESP_APP_VERSION' ./src/emsesp_version.h | awk -F'"' '{print $2}'`
echo "VERSION=$version" >> $GITHUB_OUTPUT
- name: Install PlatformIO
run: |
python -m pip install --upgrade pip
pip install -U platformio
python -m pip install intelhex
- name: Restore generated modbus header
uses: actions/download-artifact@v8
with:
name: modbus-header
path: src/core
- name: Build webUI
run: |
platformio run -e build-webUI
- name: Restore firmware binaries
uses: actions/download-artifact@v8
with:
pattern: firmware-*
merge-multiple: true
path: build/firmware
- name: Build modbus
run: |
platformio run -e build_modbus
# the recovery build needs PlatformIO, and pnpm + Node.js to build its own WebUI (recovery/WWWData.h)
- name: Install python 3.13
uses: actions/setup-python@v7
with:
python-version: '3.13'
- name: Build standalone
run: |
platformio run -e build_standalone
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
version: "latest"
enable-cache: false
- name: Build all PIO target environments, from default_envs
# falls back to the firmware jobs' caches, which hold the same toolchains
- name: Cache PlatformIO packages
uses: actions/cache@v6
with:
path: |
~/.platformio/.cache
~/.platformio/packages
~/.platformio/platforms
key: ${{ runner.os }}-pio-recovery-${{ hashFiles('platformio.ini') }}
restore-keys: |
${{ runner.os }}-pio-recovery-
${{ runner.os }}-pio-
- name: Install pioarduino
run: uv pip install --system pioarduino==6.2.0
- name: Install pnpm
uses: pnpm/action-setup@v6
with:
package_json_file: interface/package.json
- name: Install Node.js 26
uses: actions/setup-node@v7
with:
node-version: 26
- name: Cache pnpm store
uses: actions/cache@v6
with:
path: ~/.local/share/pnpm/store
key: ${{ runner.os }}-pnpm-${{ hashFiles('interface/package.json') }}
restore-keys: |
${{ runner.os }}-pnpm-
- name: Build the recovery firmware for the 16MB boards
run: |
platformio run
platformio run -e recovery_s_16M -e recovery_s_16M_P -e recovery_s3_16M_P
- name: List firmware files
run: ls -la build/firmware
- name: Create GitHub Release
uses: emsesp/action-automatic-releases@v1.0.1
+3 -1
View File
@@ -10,9 +10,11 @@ on:
jobs:
stale:
name: Mark or close stale issues and PRs
if: github.repository == 'emsesp/EMS-ESP32'
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v10
- uses: actions/stale@v11
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-stale: 30
-75
View File
@@ -1,75 +0,0 @@
name: 'Build test release'
on:
workflow_dispatch:
push:
branches:
- 'test'
permissions:
contents: read
jobs:
pre-release:
name: 'Build Test Release'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Install python 3.13
uses: actions/setup-python@v6
with:
python-version: '3.13'
- name: Install Node.js 26
uses: actions/setup-node@v6
with:
node-version: 26
- name: Checkout repository
uses: actions/checkout@v6
- name: Install pnpm
run: npm install -g pnpm
- name: Get the EMS-ESP version
id: build_info
run: |
version=`grep -E '^#define EMSESP_APP_VERSION' ./src/emsesp_version.h | awk -F'"' '{print $2}'`
echo "VERSION=$version" >> $GITHUB_OUTPUT
- name: Install PlatformIO
run: |
python -m pip install --upgrade pip
pip install -U platformio
python -m pip install intelhex
- name: Build webUI
run: |
platformio run -e build-webUI
- name: Build modbus
run: |
platformio run -e build_modbus
- name: Build standalone
run: |
platformio run -e build_standalone
- name: Build all PIO target environments, from default_envs
run: |
platformio run
- name: Create GitHub Release
id: 'automatic_releases'
uses: emsesp/action-automatic-releases@v1.0.1
with:
repo_token: '${{ secrets.GITHUB_TOKEN }}'
title: Test Build v${{steps.build_info.outputs.VERSION}}
automatic_release_tag: 'test'
prerelease: true
files: |
CHANGELOG_LATEST.md
./build/firmware/*.*
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Get and Send EMS-ESP version to Cloudflare
env:
+2
View File
@@ -32,6 +32,8 @@ analyse.html
interface/vite.config.ts.timestamp*
*.local
src/ESP32React/WWWData.h
recovery/WWWData.h
/interface/dist-recovery
# i18n generated files
interface/src/i18n/i18n-react.tsx
+24
View File
@@ -5,6 +5,30 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [3.9.0] Unreleased
## Fixed
- RC300/RC310 `nofrostmode` missing `off` state [#3311](https://github.com/emsesp/EMS-ESP32/issues/3311)
## [3.8.6] 24 September 2026
## Added
- own rgb_led library supporting WS2812B V5 and the newer V6 RGB LEDs
## [3.8.5] 22 September 2026
## Added
- Change webUI message when upgrading to a major version of the firmware, in preparation for the upcoming 3.9.0 release.
## [3.8.4] 4 August 2026
## Fixed
- Allow auto-download and install of firmware versions which are smaller to allow update to the latest 3.9.0-development version.
## [3.8.3] 1 August 2026
## Added
+40 -2
View File
@@ -4,7 +4,7 @@ For more details go to [emsesp.org](https://emsesp.org/).
## [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.
This release is based on the latest Espressif/Arduino core version 3. It brings in many memory and performance optimizations, for example 25% more available memory and 30% less PSRAM fragmentation.
## Added
@@ -19,9 +19,23 @@ This release is based on the latest Espressif/Arduino core version 3. It brings
- `show system` reports the Ethernet status on boards with a PHY, so a failed initialisation or a link that never came up is visible instead of Ethernet being omitted completely. Also shows the loop task's lowest free stack, to help diagnose reboots
- new Application Setting "Automatically check for firmware updates", off by default. When enabled EMS-ESP asks emsesp.org for the latest version once a day on its own, otherwise it only asks when the WebUI wants to show it, so an unattended system never contacts emsesp.org
- Automatically check and convert older 3.8.x filesystem to the new 3.9.0 format
- heatpump pool: `poolon` (switch, 0x48A), `hppoolon` (pool heating active, 0x488), MP100 `poolshuntset` (0x5C4) and `poolrequest` (0x5BA), shorter pool names
- EMS-ESP Recovery, a small firmware for the factory (`boot`) partition on 16MB boards. It starts a `<AP name>-recovery` access point and connects to the network with the saved EMS-ESP settings (Ethernet if configured, otherwise WiFi, using the static IP if one is set), and has a WebUI to sign in with an existing admin user, upload a new firmware into either OTA partition, or start one of the installed firmwares. It only reads the EMS-ESP settings, never changes them. Released as `EMS-ESP-Recovery-<version>-<chip>-16MB[+].bin`, and installed by uploading it in the EMS-ESP WebUI like a normal firmware, or flashed with `pio run -e recovery_s3_16M_P -t upload`
- firmware rollback: a newly installed firmware is only kept once it has run for 2 minutes. If it crashes or restarts before then, the device goes back to the previous firmware, or to the Recovery if there is none
- on 16MB boards with the Recovery installed, EMS-ESP switches to it after crashing 5 times in a row. The Recovery shows why it was started and which firmware failed to start
- the Version page shows the installed Recovery version on 16MB boards
- crash test builds to try out the rollback and recovery, see `[env:crashtest_s3_16M_P]` in `platformio.ini`
- 16MB builds use EMS-ESP's own Arduino/IDF libs from [esp32-arduino-lib-builder](https://github.com/emsesp/esp32-arduino-lib-builder) instead of Tasmota's prebuilt cores
- optional `ccache` wrapping when it is installed (`scripts/enable_ccache.py`) to speed up CI
- heat pump `dhw/disinfectduration`, the time allowed to reach the thermal disinfection temperature (0xEA offset 15, 120-240 min) [#3278](https://github.com/emsesp/EMS-ESP32/issues/3278)
## Fixed
- ESP32-C6 boards without a board profile got invalid GPIOs (99), so on every start the settings were rejected and fell back to empty values (blank Board Profile, EMS Tx Mode and Bus ID), and saving any Application setting failed with a 400 error. A new C6 now starts with the CUSTOM profile and pins for the Seeed XIAO ESP32-C6: Rx GPIO20 (D9), Tx GPIO19 (D8), button GPIO9
- heat-pump operating state (`hpoperatingstate`: heating/off/cooling) is published for every heating circuit. On BC400/UI800 it was only filled for hc1, because hc2 never answers telegram 0x472 while the state is still broadcast in 0x2A6 offset 20 [#3097](https://github.com/emsesp/EMS-ESP32/issues/3097)
- restart from the WebUI (Restart button, firmware/settings upload) only happened while the browser tab stayed open. If the tab was closed the restart was silently dropped after 5 minutes, leaving a new firmware uninstalled. EMS-ESP now restarts on its own if the WebUI hasn't picked up the pending restart within 10 seconds
- a firmware upload from a script or `curl` was accepted (`md5_ok`) but EMS-ESP didn't restart into it: the client closing the connection after the upload was treated as an aborted upload, which cancelled the pending restart
- EMS-ESP crashed and restarted when an upload was rejected, for example an empty or invalid MD5 file. The error code was kept in the request's `_tempObject`, which the web server frees as heap memory when the request ends
- shunting yard show json
- `system/sendmail` called from a Command or the Scheduler computed its value twice, which stripped the quotes from literal text and then failed on characters like `!`. Also shunting yard treated a `?` inside a quoted string as a ternary, so text like `"too hot?"` returned nothing
- memory leak when using different timezones for ems-esp and thermostat[#3184](https://github.com/emsesp/EMS-ESP32/issues/3184)
@@ -36,15 +50,36 @@ This release is based on the latest Espressif/Arduino core version 3. It brings
- HA Discovery warning on Uptime after EMS-ESP boot due to NTP not ready
- hc/control setting for UI800 thermostats [#3181](https://github.com/emsesp/EMS-ESP32/discussions/3181)
- solar module switches [#3223](https://github.com/emsesp/EMS-ESP32/issues/3223)
- UI800/HMI800.2 thermostats without telegram 0x470 reported `summertemp` as 0 and lost the per-hc heat/cool delays. `summermode` is now also shown for UI800 heatpumps [#3257](https://github.com/emsesp/EMS-ESP32/issues/3257)
- possible crash when the WebUI asked for version information while the versions.json refresh was rewriting the cache from the main loop task
- HTTP client could write past a fixed 64 byte buffer when a URL had a hostname of 64 characters or more, could busy-spin for the whole read budget if the socket reported bytes it wouldn't return, and buffered responses without any size limit
- the "Ethernet clock mode (GPIO16/17) conflicts with PSRAM" error was never logged because Ethernet had already been skipped by the time the check ran
- heat limit entities (`maxheatcomp`, `maxheatheat`, `dhw.maxheat`) read as unknown on heat pumps with an auxiliary heater larger than 9 kW - `enum_maxHeat` was missing the 12 kW and 15 kW steps
- add 0kW to heat limits [#3262](https://github.com/emsesp/EMS-ESP32/issues/3262), [#3265](https://github.com/emsesp/EMS-ESP32/issues/3265)
- show temperature value for sensor 0x40 [#3272](https://github.com/emsesp/EMS-ESP32/issues/3272)
- no EMS bus on custom boards whose interface relies on a pull-up on the Rx line. The newer ESP-IDF no longer enables the internal pull-up on the UART Rx pin, so it is now set explicitly as it was in 3.8 [#3258](https://github.com/emsesp/EMS-ESP32/discussions/3258)
- no EMS bus after restart when Rx uses a pin that boots with another IO_MUX function (e.g. GPIO43/44 = UART0 on ESP32-S3). IDF 5 `uart_set_pin()` no longer selects the GPIO function on the Rx pad when routing via the matrix, so the pin is reset before UART setup [#3258](https://github.com/emsesp/EMS-ESP32/discussions/3258)
- Tx mode Auto preferred HT3 pacing over EMS+ when the bus used the HT3 address mask. Some Bosch heat pumps (e.g. Compress 7000i AW, CS5800i) need EMS+ timing; Auto now selects EMS+ when boiler monitor 0xE4 is seen (not merely any extended telegram, which Junkers HT3 also uses) [#3283](https://github.com/emsesp/EMS-ESP32/issues/3283), [#3245](https://github.com/emsesp/EMS-ESP32/issues/3245)
- Guru Meditation (cache error) on HybridCompile builds when the EMS or console UART ISR ran from flash during a SPI flash read (WiFi power-down, mDNS, NVS). The UART ISR and ringbuf ISR helpers are now in IRAM
- compile errors against Arduino 3.3.12 from Tasmota/Arduino headers named `console.h`, `network.h` and `httpClient.h`. Those EMS-ESP headers are now `emsesp_console.h`, `emsesp_network.h` and `emsesp_http_client.h`
- HTTP OTA from WSL/mDNS stalled on IPv6 and used tiny write chunks. Uploads now resolve IPv4 and stream in 64 KB blocks (`scripts/upload.py`, `scripts/upload_cli.py`)
- BC400/UI800/HMC310 `dhw.circmode` labels: `auto` was "According to DHW time program" and `own prog` was the circulation's own Auto program. Renamed to match the CS5800i UI [#3296](https://github.com/emsesp/EMS-ESP32/issues/3296)
- resetting a customized min/max now restores the original limits immediately. The register limits are kept and the custom range is applied only when the limits are read, so a restart is no longer needed
- a device's custom name and brand were wiped when saving an entity customization, and the name was gone after the next restart. Saving entities now keeps the name and brand stored in the same record
- `dhw/comfdiff`, `dhw/ecodiff` and `dhw/ecoplusdiff` now accept a minimum of 4 K instead of 6 K, matching the CS5800i [#3273](https://github.com/emsesp/EMS-ESP32/issues/3273)
- syslog dropped messages at DEBUG because the queue held only 10 lines, less than one bus burst. The queue is now 100. Syslog success and failure counts are included in the MQTT heartbeat, and the Activity page rounds the quality percentage instead of truncating it [#3284](https://github.com/emsesp/EMS-ESP32/issues/3284)
- a read or write aimed at EMS-ESP's own bus id was left unanswered, so the sender waited for a timeout. EMS-ESP now sends an empty reply to those reads and acknowledges those writes [#3094](https://github.com/emsesp/EMS-ESP32/issues/3094)
- a second boiler monitor `0xE4` carrying `0xFFFF` cleared a valid `pc0Flow`. That empty value is now ignored [#3224](https://github.com/emsesp/EMS-ESP32/issues/3224)
- writing a negative custom entity value was always 1 too high because float-to-int rounding always added +0.5. Negative values now round the same way as positive ones [#3297](https://github.com/emsesp/EMS-ESP32/issues/3297)
- after a cold boot, SoftAP fallback no longer abandons STA forever. If a home WiFi SSID is configured, EMS-ESP keeps retrying STA while the captive portal is up so a late-booting router (e.g. after a power outage) can still be joined; SoftAP stops once STA gets an IP
- NTP time zone strings updated to IANA tz 2026e. About 32 zones had the wrong UTC offset now or within the next year (Mexico no longer uses DST, Egypt does again, Morocco is permanent UTC+0, Kazakhstan, and from 1 November 2026 western Canada stays on permanent offsets). On boot EMS-ESP rewrites the stored POSIX string from the saved IANA label so existing installs pick up the new rules without re-selecting the zone [#3308](https://github.com/emsesp/EMS-ESP32/pull/3308)
- MQTT with TLS and a CA certificate crashed on every connect (a boot loop) on boards with PSRAM when the broker's certificate chain is EC, such as Let's Encrypt ISRG Root X2: verifying it needs about 6 KB of stack and the MQTT task only had 5120 bytes. The MQTT task now gets an 8 KB stack when TLS verifies a CA certificate, and keeps 5120 bytes for insecure TLS and plain MQTT
## Changed
- various memory optimizations [#3083](https://github.com/emsesp/EMS-ESP32/issues/3083)
- Scheduler name is now mandatory
- Access Point provision mode label renamed from "When network connection is lost" to "When network connection not found" (all languages)
- network fallback to AP only after start [#3090](https://github.com/emsesp/EMS-ESP32/issues/3090)
- replaced Web async-validator with custom validator and toast with native snackbar to reduce bundle size
- Gateway and Connect devices are shown in the Devices page, but disabled [3126](https://github.com/emsesp/EMS-ESP32/discussions/3126)
@@ -56,4 +91,7 @@ This release is based on the latest Espressif/Arduino core version 3. It brings
- multiple optimization in network, power down WiFi when Ethernet and WiFi nosleep is instant when selected [#3213](https://github.com/emsesp/EMS-ESP32/pull/3213)
- TLS buffers (~17KB per HTTPS request) are taken from PSRAM on boards that have it, instead of fragmenting the internal heap
- failed versions.json fetches back off from 5 up to 160 minutes instead of retrying every 5 minutes, as each attempt blocks the main loop for as long as the DNS, connect and read timeouts allow
- optimized the LED library to support WS2812B V5 and the newer V6 RGB LEDs
- hold Tx while a K30RF/KM200 gateway is syncing [#3094](https://github.com/emsesp/EMS-ESP32/issues/3094)
- Tasmota platform 2026.05.50 (Arduino 3.3.8 / IDF 5.5.4) to 2026.09.50 (Arduino 3.3.12 / IDF 5.5.5). Building that platform needs PlatformIO Core ≥6.2.0; install `pioarduino==6.2.0` (official `platformio` is still 6.1.19). See CONTRIBUTING.md
- CI dev-release firmware jobs run in parallel (webUI, firmware targets, standalone) instead of one serial job
+34
View File
@@ -10,6 +10,40 @@ Everybody is welcome and invited to contribute to the EMS-ESP Project by:
This document describes rules that are in effect for this repository, meant for handling issues by contributors in the issue tracker and PRs.
## Building the firmware
Arduino/IDF 3.3.12 (Tasmota platform `2026.09.50` in `platformio.ini`) needs **PlatformIO Core ≥6.2.0**. The official `platformio` package on PyPI is still 6.1.19, so `pio upgrade` and `pip install -U platformio` stay on 6.1.19 and you get:
`IncompatiblePlatform: Development platform 'espressif32' is not compatible with PlatformIO Core v6.1.19 and depends on PlatformIO Core >=6.2.0.`
Install **pioarduino** 6.2.0 (drop-in Core fork). It provides the same `pio` / `platformio` commands.
**VS Code / Cursor** (PlatformIO IDE uses `~/.platformio/penv`):
Linux / macOS:
```bash
~/.platformio/penv/bin/pip install -U "pioarduino==6.2.0"
~/.platformio/penv/bin/pio --version
```
Windows (PowerShell):
```powershell
& "$env:USERPROFILE\.platformio\penv\Scripts\pip.exe" install -U "pioarduino==6.2.0"
& "$env:USERPROFILE\.platformio\penv\Scripts\pio.exe" --version
```
You should see `PlatformIO Core, version 6.2.0`. Reload the window (or restart the IDE), then build again.
**CLI-only / CI:**
```bash
pip install -U "pioarduino==6.2.0"
# or: uv pip install --system pioarduino==6.2.0
platformio --version
```
## Opening New Issues
1. Opening an issue means that a problem exists in the code and should be addressed by the project contributors.
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -21,7 +21,7 @@
"arduino",
"espidf"
],
"name": "Tasmota ESP32-S3 32M Flash OPI PSRAM, 4608KB Code/OTA, 2MB FS",
"name": "ESP32-S3 32M Flash OPI PSRAM, 4608KB Code/OTA, 2MB FS",
"upload": {
"flash_size": "32MB",
"maximum_ram_size": 327680,
+1 -1
View File
@@ -18,7 +18,7 @@
"arduino",
"espidf"
],
"name": "Tasmota ESP32 16M Flash, 4608KB Code/OTA, 2MB FS",
"name": "ESP32 16M Flash, 4608KB Code/OTA, 2MB FS",
"upload": {
"flash_size": "16MB",
"maximum_ram_size": 327680,
+1 -1
View File
@@ -19,7 +19,7 @@
"arduino",
"espidf"
],
"name": "Tasmota ESP32 16M Flash DIO PSRAM, 4608KB Code/OTA, 2MB FS",
"name": "ESP32 16M Flash DIO PSRAM, 4608KB Code/OTA, 2MB FS",
"upload": {
"flash_size": "16MB",
"maximum_ram_size": 327680,
+1 -1
View File
@@ -17,7 +17,7 @@
"arduino",
"espidf"
],
"name": "Tasmota ESP32 4M Flash, 4608KB Code/OTA, 2MB FS",
"name": "ESP32 4M Flash, 4608KB Code/OTA, 2MB FS",
"upload": {
"flash_size": "4MB",
"maximum_ram_size": 327680,
+11 -8
View File
@@ -19,18 +19,11 @@
"lib/espMqttClient",
"analyse.html",
"dist",
"**/*.csv",
"**/*.md",
"**/*.py",
"locale_translations.h",
"TZ.tsx",
"**/*.txt",
"build/**",
"**/i18n/**",
"/project-words.txt",
"Makefile",
"**/*.ini",
"**/*.json",
"src/core/modbus_entity_parameters.hpp",
"sdkconfig.*",
"managed_components/**",
@@ -39,6 +32,16 @@
"lib/esp32-psram/**",
"test/test_api/test_api.h",
"lib_standalone/**",
"**/*.js"
"pio_local.ini_example",
"tz_zones.h",
"**/i18n/**",
"**/*.js",
"**/*.csv",
"**/*.md",
"**/*.py",
"**/*.sh",
"**/*.ini",
"**/*.json",
"**/*.txt"
]
}
File diff suppressed because it is too large Load Diff
+1723 -1694
View File
File diff suppressed because it is too large Load Diff
+7 -4
View File
@@ -29,7 +29,7 @@ telegram_type_id,name,is_fetched
0x3B,Energy,
0x3D,RC35Set,
0x3E,RC35Monitor,
0x3F,RC35Timer,
0x3F,RC30Timer,
0x40,RC30Temp,
0x41,RC30Monitor,
0x42,RC35Timer2,
@@ -104,7 +104,7 @@ telegram_type_id,name,is_fetched
0x0292,HPMode,fetched
0x0293,HPMode,fetched
0x0294,HPMode,fetched
0x029B,RC300Curves,fetched
0x029B,RC300Curves,
0x029C,RC300Curves,
0x029D,RC300Curves,
0x029E,RC300Curves,
@@ -112,7 +112,7 @@ telegram_type_id,name,is_fetched
0x02A0,RC300Curves,
0x02A1,RC300Curves,
0x02A2,RC300Curves,
0x02A5,RC300Monitor,
0x02A5,CRFMonitor,
0x02A6,RC300Monitor,
0x02A7,RC300Monitor,
0x02A8,RC300Monitor,
@@ -200,7 +200,7 @@ telegram_type_id,name,is_fetched
0x04A2,HpInput,fetched
0x04A5,HPFan,fetched
0x04A7,HPPowerLimit,fetched
0x04AA,HPPower,
0x04AA,HPPower2,fetched
0x04AE,HPEnergy,fetched
0x04AF,HPMeters,fetched
0x055C,VentilationSet,fetched
@@ -209,6 +209,7 @@ telegram_type_id,name,is_fetched
0x0585,Blowerspeed,
0x0587,Bypass,
0x05BA,HpPoolStatus,fetched
0x05C4,HpPoolShunt,
0x05D9,Airquality,
0x0772,HIUSettings,
0x0779,HIUMonitor,
@@ -232,4 +233,6 @@ telegram_type_id,name,is_fetched
0x099A,HPStarts,
0x099B,HPFlowTemp,
0x099C,HPComp,
0x099D,HPComp2,
0x09A0,HPTemperature,
0x09A8,HPVolume,
1 telegram_type_id name is_fetched
29 0x3B Energy
30 0x3D RC35Set
31 0x3E RC35Monitor
32 0x3F RC35Timer RC30Timer
33 0x40 RC30Temp
34 0x41 RC30Monitor
35 0x42 RC35Timer2
104 0x0292 HPMode fetched
105 0x0293 HPMode fetched
106 0x0294 HPMode fetched
107 0x029B RC300Curves fetched
108 0x029C RC300Curves
109 0x029D RC300Curves
110 0x029E RC300Curves
112 0x02A0 RC300Curves
113 0x02A1 RC300Curves
114 0x02A2 RC300Curves
115 0x02A5 RC300Monitor CRFMonitor
116 0x02A6 RC300Monitor
117 0x02A7 RC300Monitor
118 0x02A8 RC300Monitor
200 0x04A2 HpInput fetched
201 0x04A5 HPFan fetched
202 0x04A7 HPPowerLimit fetched
203 0x04AA HPPower HPPower2 fetched
204 0x04AE HPEnergy fetched
205 0x04AF HPMeters fetched
206 0x055C VentilationSet fetched
209 0x0585 Blowerspeed
210 0x0587 Bypass
211 0x05BA HpPoolStatus fetched
212 0x05C4 HpPoolShunt
213 0x05D9 Airquality
214 0x0772 HIUSettings
215 0x0779 HIUMonitor
233 0x099A HPStarts
234 0x099B HPFlowTemp
235 0x099C HPComp
236 0x099D HPComp2
237 0x09A0 HPTemperature
238 0x09A8 HPVolume
+6072
View File
File diff suppressed because it is too large Load Diff
+238
View File
@@ -0,0 +1,238 @@
telegram_type_id,name,is_fetched
0x04,UBAFactory,fetched
0x06,RCTime,
0x0A,EasyMonitor,fetched
0x10,UBAErrorMessage1,
0x11,UBAErrorMessage2,
0x12,RCErrorMessage,
0x13,RCErrorMessage2,
0x14,UBATotalUptime,fetched
0x15,UBAMaintenanceData,
0x16,UBAParameters,fetched
0x18,UBAMonitorFast,
0x19,UBAMonitorSlow,
0x1A,UBASetPoints,
0x1C,UBAMaintenanceStatus,
0x1E,HydrTemp,
0x23,JunkersSetMixer,fetched
0x27,UBASettingsWW,fetched
0x28,WeatherComp,fetched
0x2A,MC110Status,
0x2E,Meters,
0x33,UBAParameterWW,fetched
0x34,UBAMonitorWW,
0x35,UBAFlags,
0x37,WWSettings,fetched
0x38,WWTimer,fetched
0x39,WWCircTimer,fetched
0x3A,RC30WWSettings,fetched
0x3B,Energy,
0x3D,RC35Set,
0x3E,RC35Monitor,
0x3F,RC30Timer,
0x40,RC30Temp,
0x41,RC30Monitor,
0x42,RC35Timer2,
0x47,RC35Set,
0x48,RC35Monitor,
0x49,RC35Timer,
0x4C,RC35Timer2,
0x51,RC35Set,
0x52,RC35Monitor,
0x53,RC35Timer,
0x56,RC35Timer2,
0x5B,RC35Set,
0x5C,RC35Monitor,
0x5D,RC35Timer,
0x60,RC35Timer2,
0x96,SM10Config,fetched
0x97,SM10Monitor,
0x9C,WM10MonitorMessage,
0x9D,WM10SetMessage,
0xA2,RCError,
0xA3,RCOutdoorTemp,
0xA5,IBASettings,fetched
0xA7,RC30Set,
0xA9,RC30Vacation,fetched
0xAA,MMConfigMessage,fetched
0xAB,MMStatusMessage,
0xAC,MMSetMessage,
0xAF,RC20Remote,
0xB0,RC10Set,
0xB1,RC10Monitor,
0xBB,HybridSettings,fetched
0xBF,ErrorMessage,
0xC0,RCErrorMessage,
0xC2,UBAErrorMessage3,
0xC6,UBAErrorMessage3,
0xD1,UBAOutdoorTemp,
0xE3,UBAMonitorSlowPlus2,
0xE4,UBAMonitorFastPlus,
0xE5,UBAMonitorSlowPlus,
0xE6,UBAParametersPlus,fetched
0xE9,UBAMonitorWWPlus,
0xEA,UBAParameterWWPlus,fetched
0xEB,PumpKick,fetched
0x0101,ISM1Set,fetched
0x0103,ISM1StatusMessage,fetched
0x0104,ISM2StatusMessage,
0x010C,IPMStatusMessage,
0x011E,JunkersDisp,fetched
0x012E,HPEnergy1,
0x013B,HPEnergy2,
0x0165,JunkersSet,
0x0166,JunkersSet,
0x0167,JunkersSet,
0x0168,JunkersSet,
0x016E,Absent,fetched
0x016F,JunkersMonitor,
0x0170,JunkersMonitor,
0x0171,JunkersMonitor,
0x0172,JunkersMonitor,
0x0179,JunkersSet,
0x017A,JunkersSet,
0x017B,JunkersSet,
0x017C,JunkersSet,
0x01D3,JunkersDhw,fetched
0x023A,RC300OutdoorTemp,fetched
0x023E,PVSettings,fetched
0x0240,RC300Settings,fetched
0x0241,RC300Settings,fetched
0x0267,RC300Floordry,
0x0269,RC300Holiday,fetched
0x0291,HPMode,fetched
0x0292,HPMode,fetched
0x0293,HPMode,fetched
0x0294,HPMode,fetched
0x029B,RC300Curves,
0x029C,RC300Curves,
0x029D,RC300Curves,
0x029E,RC300Curves,
0x029F,RC300Curves,
0x02A0,RC300Curves,
0x02A1,RC300Curves,
0x02A2,RC300Curves,
0x02A5,CRFMonitor,
0x02A6,RC300Monitor,
0x02A7,RC300Monitor,
0x02A8,RC300Monitor,
0x02A9,RC300Monitor,
0x02AA,RC300Monitor,
0x02AB,RC300Monitor,
0x02AC,RC300Monitor,
0x02AF,RC300Summer,
0x02B0,RC300Summer,
0x02B1,RC300Summer,
0x02B2,RC300Summer,
0x02B3,RC300Summer,
0x02B4,RC300Summer,
0x02B5,RC300Summer,
0x02B6,RC300Summer,
0x02B9,RC300Set,
0x02BA,RC300Set,
0x02BB,RC300Set,
0x02BC,RC300Set,
0x02BD,RC300Set,
0x02BE,RC300Set,
0x02BF,RC300Set,
0x02C0,RC300Set,
0x02CC,HPPressure,fetched
0x02CD,MMPLUSConfigMessage,
0x02D6,HPPump2,fetched
0x02D7,MMPLUSStatusMessage,
0x02E0,UBASetPoints,
0x02F5,RC300WWmode,fetched
0x02F6,RC300WW2mode,fetched
0x0313,MMPLUSConfigMessage_WWC,fetched
0x031B,RC300WWtemp,fetched
0x031D,RC300WWmode2,
0x031E,RC300WWmode2,
0x0331,MMPLUSStatusMessage_WWC,
0x0358,SM100SystemConfig,fetched
0x035A,SM100CircuitConfig,fetched
0x035C,SM100HeatAssist,fetched
0x035D,SM100Circuit2Config,fetched
0x035F,SM100Config1,fetched
0x0361,SM100Differential,fetched
0x0362,SM100Monitor,
0x0363,SM100Monitor2,
0x0364,SM100Status,
0x0366,SM100Config,
0x036A,SM100Status2,
0x0380,SM100CollectorConfig,fetched
0x038E,SM100Energy,fetched
0x0391,SM100Time,fetched
0x0421,RC300Set2,
0x0422,RC300Set2,
0x0423,RC300Set2,
0x0424,RC300Set2,
0x043F,CRHolidays,fetched
0x0467,HPSet,
0x0468,HPSet,
0x0469,HPSet,
0x046A,HPSet,
0x0470,RC300Summer3,fetched
0x0471,RC300Summer2,
0x0472,RC300Summer2,
0x0473,RC300Summer2,
0x0474,RC300Summer2,
0x0475,RC300Summer2,
0x0476,RC300Summer2,
0x0477,RC300Summer2,
0x0478,RC300Summer2,
0x047B,HP2,
0x0484,HPSilentMode,fetched
0x0485,HpCooling,fetched
0x0486,HpInConfig,fetched
0x0488,HPValve,fetched
0x0489,HPpassive,
0x048A,HpPool,fetched
0x048B,HPPumps,fetched
0x048D,HpPower,fetched
0x048F,HpTemperatures,
0x0491,HPAdditionalHeater,fetched
0x0492,HpHeaterConfig,fetched
0x0494,UBAEnergySupplied,
0x0495,UBAInformation,
0x0499,HPDhwSettings,fetched
0x049C,HPSettings2,fetched
0x049D,HPSettings3,fetched
0x04A2,HpInput,fetched
0x04A5,HPFan,fetched
0x04A7,HPPowerLimit,fetched
0x04AA,HPPower2,fetched
0x04AE,HPEnergy,fetched
0x04AF,HPMeters,fetched
0x055C,VentilationSet,fetched
0x056B,VentilationMode,fetched
0x0583,VentilationMonitor,
0x0585,Blowerspeed,
0x0587,Bypass,
0x05BA,HpPoolStatus,fetched
0x05C4,HpPoolShunt,
0x05D9,Airquality,
0x0772,HIUSettings,
0x0779,HIUMonitor,
0x07A5,SM100wwCirc,fetched
0x07A6,SM100wwParam,fetched
0x07AA,SM100wwStatus,
0x07AB,SM100wwCommand,
0x07AC,SM100wwParam1,
0x07AD,SM100ValveStatus,
0x07AE,SM100wwKeepWarm,fetched
0x07D6,SM100wwTemperature,
0x07E0,SM100wwStatus2,fetched
0x0935,EM100SetMessage,fetched
0x0936,EM100OutMessage,
0x0937,EM100TempMessage,
0x0938,EM100InputMessage,
0x0939,EM100MonitorMessage,
0x093A,EM100ConfigMessage,
0x0998,HPSettings,fetched
0x0999,HPFunctionTest,fetched
0x099A,HPStarts,
0x099B,HPFlowTemp,
0x099C,HPComp,
0x099D,HPComp2,
0x09A0,HPTemperature,
0x09A8,HPVolume,
1 telegram_type_id name is_fetched
2 0x04 UBAFactory fetched
3 0x06 RCTime
4 0x0A EasyMonitor fetched
5 0x10 UBAErrorMessage1
6 0x11 UBAErrorMessage2
7 0x12 RCErrorMessage
8 0x13 RCErrorMessage2
9 0x14 UBATotalUptime fetched
10 0x15 UBAMaintenanceData
11 0x16 UBAParameters fetched
12 0x18 UBAMonitorFast
13 0x19 UBAMonitorSlow
14 0x1A UBASetPoints
15 0x1C UBAMaintenanceStatus
16 0x1E HydrTemp
17 0x23 JunkersSetMixer fetched
18 0x27 UBASettingsWW fetched
19 0x28 WeatherComp fetched
20 0x2A MC110Status
21 0x2E Meters
22 0x33 UBAParameterWW fetched
23 0x34 UBAMonitorWW
24 0x35 UBAFlags
25 0x37 WWSettings fetched
26 0x38 WWTimer fetched
27 0x39 WWCircTimer fetched
28 0x3A RC30WWSettings fetched
29 0x3B Energy
30 0x3D RC35Set
31 0x3E RC35Monitor
32 0x3F RC30Timer
33 0x40 RC30Temp
34 0x41 RC30Monitor
35 0x42 RC35Timer2
36 0x47 RC35Set
37 0x48 RC35Monitor
38 0x49 RC35Timer
39 0x4C RC35Timer2
40 0x51 RC35Set
41 0x52 RC35Monitor
42 0x53 RC35Timer
43 0x56 RC35Timer2
44 0x5B RC35Set
45 0x5C RC35Monitor
46 0x5D RC35Timer
47 0x60 RC35Timer2
48 0x96 SM10Config fetched
49 0x97 SM10Monitor
50 0x9C WM10MonitorMessage
51 0x9D WM10SetMessage
52 0xA2 RCError
53 0xA3 RCOutdoorTemp
54 0xA5 IBASettings fetched
55 0xA7 RC30Set
56 0xA9 RC30Vacation fetched
57 0xAA MMConfigMessage fetched
58 0xAB MMStatusMessage
59 0xAC MMSetMessage
60 0xAF RC20Remote
61 0xB0 RC10Set
62 0xB1 RC10Monitor
63 0xBB HybridSettings fetched
64 0xBF ErrorMessage
65 0xC0 RCErrorMessage
66 0xC2 UBAErrorMessage3
67 0xC6 UBAErrorMessage3
68 0xD1 UBAOutdoorTemp
69 0xE3 UBAMonitorSlowPlus2
70 0xE4 UBAMonitorFastPlus
71 0xE5 UBAMonitorSlowPlus
72 0xE6 UBAParametersPlus fetched
73 0xE9 UBAMonitorWWPlus
74 0xEA UBAParameterWWPlus fetched
75 0xEB PumpKick fetched
76 0x0101 ISM1Set fetched
77 0x0103 ISM1StatusMessage fetched
78 0x0104 ISM2StatusMessage
79 0x010C IPMStatusMessage
80 0x011E JunkersDisp fetched
81 0x012E HPEnergy1
82 0x013B HPEnergy2
83 0x0165 JunkersSet
84 0x0166 JunkersSet
85 0x0167 JunkersSet
86 0x0168 JunkersSet
87 0x016E Absent fetched
88 0x016F JunkersMonitor
89 0x0170 JunkersMonitor
90 0x0171 JunkersMonitor
91 0x0172 JunkersMonitor
92 0x0179 JunkersSet
93 0x017A JunkersSet
94 0x017B JunkersSet
95 0x017C JunkersSet
96 0x01D3 JunkersDhw fetched
97 0x023A RC300OutdoorTemp fetched
98 0x023E PVSettings fetched
99 0x0240 RC300Settings fetched
100 0x0241 RC300Settings fetched
101 0x0267 RC300Floordry
102 0x0269 RC300Holiday fetched
103 0x0291 HPMode fetched
104 0x0292 HPMode fetched
105 0x0293 HPMode fetched
106 0x0294 HPMode fetched
107 0x029B RC300Curves
108 0x029C RC300Curves
109 0x029D RC300Curves
110 0x029E RC300Curves
111 0x029F RC300Curves
112 0x02A0 RC300Curves
113 0x02A1 RC300Curves
114 0x02A2 RC300Curves
115 0x02A5 CRFMonitor
116 0x02A6 RC300Monitor
117 0x02A7 RC300Monitor
118 0x02A8 RC300Monitor
119 0x02A9 RC300Monitor
120 0x02AA RC300Monitor
121 0x02AB RC300Monitor
122 0x02AC RC300Monitor
123 0x02AF RC300Summer
124 0x02B0 RC300Summer
125 0x02B1 RC300Summer
126 0x02B2 RC300Summer
127 0x02B3 RC300Summer
128 0x02B4 RC300Summer
129 0x02B5 RC300Summer
130 0x02B6 RC300Summer
131 0x02B9 RC300Set
132 0x02BA RC300Set
133 0x02BB RC300Set
134 0x02BC RC300Set
135 0x02BD RC300Set
136 0x02BE RC300Set
137 0x02BF RC300Set
138 0x02C0 RC300Set
139 0x02CC HPPressure fetched
140 0x02CD MMPLUSConfigMessage
141 0x02D6 HPPump2 fetched
142 0x02D7 MMPLUSStatusMessage
143 0x02E0 UBASetPoints
144 0x02F5 RC300WWmode fetched
145 0x02F6 RC300WW2mode fetched
146 0x0313 MMPLUSConfigMessage_WWC fetched
147 0x031B RC300WWtemp fetched
148 0x031D RC300WWmode2
149 0x031E RC300WWmode2
150 0x0331 MMPLUSStatusMessage_WWC
151 0x0358 SM100SystemConfig fetched
152 0x035A SM100CircuitConfig fetched
153 0x035C SM100HeatAssist fetched
154 0x035D SM100Circuit2Config fetched
155 0x035F SM100Config1 fetched
156 0x0361 SM100Differential fetched
157 0x0362 SM100Monitor
158 0x0363 SM100Monitor2
159 0x0364 SM100Status
160 0x0366 SM100Config
161 0x036A SM100Status2
162 0x0380 SM100CollectorConfig fetched
163 0x038E SM100Energy fetched
164 0x0391 SM100Time fetched
165 0x0421 RC300Set2
166 0x0422 RC300Set2
167 0x0423 RC300Set2
168 0x0424 RC300Set2
169 0x043F CRHolidays fetched
170 0x0467 HPSet
171 0x0468 HPSet
172 0x0469 HPSet
173 0x046A HPSet
174 0x0470 RC300Summer3 fetched
175 0x0471 RC300Summer2
176 0x0472 RC300Summer2
177 0x0473 RC300Summer2
178 0x0474 RC300Summer2
179 0x0475 RC300Summer2
180 0x0476 RC300Summer2
181 0x0477 RC300Summer2
182 0x0478 RC300Summer2
183 0x047B HP2
184 0x0484 HPSilentMode fetched
185 0x0485 HpCooling fetched
186 0x0486 HpInConfig fetched
187 0x0488 HPValve fetched
188 0x0489 HPpassive
189 0x048A HpPool fetched
190 0x048B HPPumps fetched
191 0x048D HpPower fetched
192 0x048F HpTemperatures
193 0x0491 HPAdditionalHeater fetched
194 0x0492 HpHeaterConfig fetched
195 0x0494 UBAEnergySupplied
196 0x0495 UBAInformation
197 0x0499 HPDhwSettings fetched
198 0x049C HPSettings2 fetched
199 0x049D HPSettings3 fetched
200 0x04A2 HpInput fetched
201 0x04A5 HPFan fetched
202 0x04A7 HPPowerLimit fetched
203 0x04AA HPPower2 fetched
204 0x04AE HPEnergy fetched
205 0x04AF HPMeters fetched
206 0x055C VentilationSet fetched
207 0x056B VentilationMode fetched
208 0x0583 VentilationMonitor
209 0x0585 Blowerspeed
210 0x0587 Bypass
211 0x05BA HpPoolStatus fetched
212 0x05C4 HpPoolShunt
213 0x05D9 Airquality
214 0x0772 HIUSettings
215 0x0779 HIUMonitor
216 0x07A5 SM100wwCirc fetched
217 0x07A6 SM100wwParam fetched
218 0x07AA SM100wwStatus
219 0x07AB SM100wwCommand
220 0x07AC SM100wwParam1
221 0x07AD SM100ValveStatus
222 0x07AE SM100wwKeepWarm fetched
223 0x07D6 SM100wwTemperature
224 0x07E0 SM100wwStatus2 fetched
225 0x0935 EM100SetMessage fetched
226 0x0936 EM100OutMessage
227 0x0937 EM100TempMessage
228 0x0938 EM100InputMessage
229 0x0939 EM100MonitorMessage
230 0x093A EM100ConfigMessage
231 0x0998 HPSettings fetched
232 0x0999 HPFunctionTest fetched
233 0x099A HPStarts
234 0x099B HPFlowTemp
235 0x099C HPComp
236 0x099D HPComp2
237 0x09A0 HPTemperature
238 0x09A8 HPVolume
+1
View File
@@ -1,6 +1,7 @@
node_modules/
build/
dist/
dist-recovery/
src/i18n/*
.prettierrc
+1
View File
@@ -18,6 +18,7 @@ export default defineConfig(
{
ignores: [
'dist/*',
'dist-recovery/*',
'*.mjs',
'build/*',
'*.js',
+15 -13
View File
@@ -18,6 +18,8 @@
"standalone": "concurrently -c \"auto\" \"typesafe-i18n\" \"pnpm:mock-rest\" \"vite dev\"",
"typesafe-i18n": "typesafe-i18n --no-watch",
"build-webUI": "typesafe-i18n --no-watch && vite build && node progmem-generator.js",
"recovery": "vite dev --mode recovery",
"build-recovery": "vite build --mode recovery && node progmem-generator.js --src ./dist-recovery --out ../recovery/WWWData.h --index recovery.html",
"format": "prettier -l -w '**/*.{ts,tsx,js,css,json,md}'",
"lint": "eslint . --fix",
"standalone-devcontainer": "concurrently -c \"auto\" \"typesafe-i18n\" \"pnpm:mock-rest\" \"vite --host\""
@@ -26,37 +28,37 @@
"@alova/adapter-xhr": "2.3.3",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^9.4.0",
"@mui/material": "^9.4.0",
"@mui/icons-material": "^9.5.0",
"@mui/material": "^9.5.0",
"@table-library/react-table-library": "4.1.15",
"alova": "^3.5.5",
"jwt-decode": "^4.0.0",
"preact": "^10.29.8",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"preact": "^11.0.2",
"react": "^19.3.1",
"react-dom": "^19.3.1",
"react-icons": "^5.7.0",
"react-router": "^8.4.0",
"react-router": "^8.5.0",
"typesafe-i18n": "^5.27.1"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@preact/preset-vite": "^2.10.6",
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
"@types/node": "^26.6.2",
"@types/node": "^26.6.5",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"concurrently": "^10.0.5",
"eslint": "^10.11.0",
"concurrently": "^10.0.6",
"eslint": "^10.13.0",
"eslint-config-prettier": "^10.1.8",
"etag": "^1.8.1",
"mime-types": "^3.0.2",
"prettier": "^3.9.8",
"prettier": "^3.9.10",
"rollup-plugin-visualizer": "^7.1.1",
"terser": "^5.51.2",
"typescript": "^6.0.3",
"typescript-eslint": "^8.70.1",
"vite": "^8.3.0",
"typescript-eslint": "^8.72.1",
"vite": "^8.3.5",
"vite-plugin-imagemin": "^0.6.1"
},
"packageManager": "pnpm@12.6.0"
"packageManager": "pnpm@12.15.0"
}
+525 -518
View File
File diff suppressed because it is too large Load Diff
+13 -3
View File
@@ -10,10 +10,17 @@ import mime from 'mime-types';
import { relative, resolve, sep } from 'path';
import zlib from 'zlib';
// optional args: --src <dir> --out <file> --index <html file served as /index.html>
const getArg = (name, fallback) => {
const i = process.argv.indexOf(name);
return i !== -1 && process.argv[i + 1] ? process.argv[i + 1] : fallback;
};
const ARDUINO_INCLUDES = '#include <Arduino.h>\n\n';
const INDENT = ' ';
const outputPath = '../src/ESP32React/WWWData.h';
const sourcePath = './dist';
const outputPath = getArg('--out', '../src/ESP32React/WWWData.h');
const sourcePath = getArg('--src', './dist');
const indexFile = getArg('--index', 'index.html');
const bytesPerLine = 20;
let totalSize = 0;
let bundleStats = {
@@ -129,7 +136,10 @@ const writeFile = (relativeFilePath, buffer) => {
bundleStats[fileType].compressed += zipBuffer.length;
fileInfo.push({
uri: '/' + relativeFilePath.replace(sep, '/'),
uri:
relativeFilePath === indexFile
? '/index.html'
: '/' + relativeFilePath.replace(sep, '/'),
mimeType,
variable,
size,
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" />
<link rel="stylesheet" href="/css/roboto.css" />
<title>EMS-ESP Recovery</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/recovery/index.tsx"></script>
</body>
</html>
+168 -16
View File
@@ -11,6 +11,7 @@ import WarningIcon from '@mui/icons-material/Warning';
import {
Box,
Button,
Checkbox,
Dialog,
DialogActions,
DialogContent,
@@ -91,6 +92,8 @@ const Customizations = () => {
const [deviceEntities, setDeviceEntities] = useState<DeviceEntity[]>([]);
const [confirmReset, setConfirmReset] = useState<boolean>(false);
const [selectedFilters, setSelectedFilters] = useState<number>(0);
const [modifiedOnly, setModifiedOnly] = useState<boolean>(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [search, setSearch] = useState('');
const [selectedDeviceEntity, setSelectedDeviceEntity] = useState<DeviceEntity>();
const [dialogOpen, setDialogOpen] = useState<boolean>(false);
@@ -173,7 +176,7 @@ const Customizations = () => {
const entities_theme = 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: 32px 156px repeat(1, minmax(80px, 1fr)) 45px minmax(45px, auto) minmax(120px, auto);
`,
BaseRow: `
font-size: 14px;
@@ -182,10 +185,10 @@ const Customizations = () => {
}
`,
BaseCell: `
&:nth-of-type(3) {
&:nth-of-type(4) {
text-align: right;
}
&:nth-of-type(4) {
&:nth-of-type(5) {
text-align: right;
}
&:last-of-type {
@@ -221,13 +224,13 @@ const Customizations = () => {
}
`,
Cell: `
&:nth-of-type(2) {
&:nth-of-type(3) {
padding: 8px;
}
&:nth-of-type(3) {
&:nth-of-type(4) {
padding-right: 4px;
}
&:nth-of-type(4) {
&:nth-of-type(5) {
padding-right: 4px;
}
&:last-of-type {
@@ -245,6 +248,11 @@ const Customizations = () => {
);
}
// Saved customization (c), or an edit that is not applied yet.
function isModified(de: DeviceEntity) {
return !!de.c || hasEntityChanged(de);
}
useEffect(() => {
if (deviceEntities.length) {
const changedEntities = deviceEntities.filter((de) => hasEntityChanged(de));
@@ -267,6 +275,7 @@ const Customizations = () => {
setSelectedDeviceBrand(device.b);
}
setNumChanges(0);
setSelectedIds(new Set());
setRestartNeeded(false);
}
}
@@ -283,6 +292,13 @@ const Customizations = () => {
return value as string;
}
function formatLimit(value: number | null | undefined) {
if (value === null) {
return '';
}
return formatValue(value);
}
const isCommand = (de: DeviceEntity) => {
return de.n && de.n[0] === '!';
};
@@ -331,24 +347,64 @@ const Customizations = () => {
const filter_entity = (de: DeviceEntity) =>
(de.m & selectedFilters || !selectedFilters) &&
(!modifiedOnly || isModified(de)) &&
formatName(de, true).toLowerCase().includes(search.toLowerCase());
// Checked rows when any are checked, otherwise every row the filter is showing.
const inBulkTarget = (de: DeviceEntity) =>
selectedIds.size > 0 ? selectedIds.has(de.id) : filter_entity(de);
const maskDisabled = (set: boolean) => {
const excludeMask =
DeviceEntityMask.DV_API_MQTT_EXCLUDE | DeviceEntityMask.DV_WEB_EXCLUDE;
setDeviceEntities((prev) =>
prev.map((de) => {
if (filter_entity(de)) {
const excludeMask =
DeviceEntityMask.DV_API_MQTT_EXCLUDE | DeviceEntityMask.DV_WEB_EXCLUDE;
return {
...de,
m: set ? de.m | excludeMask : de.m & ~excludeMask
};
if (!inBulkTarget(de)) {
return de;
}
return de;
return {
...de,
m: set ? de.m | excludeMask : de.m & ~excludeMask
};
})
);
};
const maskReadonly = (set: boolean) => {
setDeviceEntities((prev) =>
prev.map((de) => {
if (!inBulkTarget(de) || !de.w) {
return de;
}
return {
...de,
m: set
? de.m | DeviceEntityMask.DV_READONLY
: de.m & ~DeviceEntityMask.DV_READONLY
};
})
);
};
const resetSelected = () => {
setDeviceEntities((prev) =>
prev.map((de) => {
if (!selectedIds.has(de.id)) {
return de;
}
const next: DeviceEntity = { ...de, m: 0, cn: '', c: false };
if (de.mi !== undefined) {
next.mi = null;
}
if (de.ma !== undefined) {
next.ma = null;
}
return next;
})
);
setSelectedIds(new Set());
};
const resetCustomization = async () => {
try {
await sendResetCustomizations();
@@ -414,6 +470,8 @@ const Customizations = () => {
})
.then(() => {
toast.success(LL.CUSTOMIZATIONS_SAVED());
// Reload so cleared min/max fall back to the built-in limits and the modified flag matches the file.
void sendDeviceEntities(selectedDevice);
})
.catch((error: Error) => {
if (error.message === 'Reboot required') {
@@ -541,6 +599,34 @@ const Customizations = () => {
);
const filteredEntities = deviceEntities.filter((de) => filter_entity(de));
const allFilteredSelected =
filteredEntities.length > 0 &&
filteredEntities.every((de) => selectedIds.has(de.id));
const someFilteredSelected = filteredEntities.some((de) => selectedIds.has(de.id));
const toggleSelected = (id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
};
const toggleSelectFiltered = () => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (allFilteredSelected) {
filteredEntities.forEach((de) => next.delete(de.id));
} else {
filteredEntities.forEach((de) => next.add(de.id));
}
return next;
});
};
const renderDeviceData = () => {
return (
@@ -608,6 +694,17 @@ const Customizations = () => {
</ToggleButton>
</ToggleButtonGroup>
</Grid>
<Grid>
<Button
size="small"
sx={{ fontSize: 10 }}
variant="outlined"
color={modifiedOnly ? 'secondary' : 'inherit'}
onClick={() => setModifiedOnly((current) => !current)}
>
{LL.MODIFIED()}
</Button>
</Grid>
<Grid>
<Button
size="small"
@@ -634,6 +731,43 @@ const Customizations = () => {
<OptionIcon type="web_exclude" isSet={true} />
</Button>
</Grid>
<Grid>
<Button
size="small"
sx={{ fontSize: 10 }}
variant="outlined"
color="inherit"
onClick={() => maskReadonly(false)}
>
{LL.SET_ALL()}&nbsp;
<OptionIcon type="readonly" isSet={false} />
</Button>
</Grid>
<Grid>
<Button
size="small"
sx={{ fontSize: 10 }}
variant="outlined"
color="inherit"
onClick={() => maskReadonly(true)}
>
{LL.SET_ALL()}&nbsp;
<OptionIcon type="readonly" isSet={true} />
</Button>
</Grid>
{selectedIds.size > 0 && (
<Grid>
<Button
size="small"
sx={{ fontSize: 10 }}
variant="outlined"
color="error"
onClick={resetSelected}
>
{LL.RESET_SELECTED()} ({selectedIds.size})
</Button>
</Grid>
)}
<Grid>
<Typography variant="subtitle2" color="grey">
{LL.SHOWING()}&nbsp;{filteredEntities.length}/{deviceEntities.length}
@@ -650,6 +784,16 @@ const Customizations = () => {
<>
<Header>
<HeaderRow>
<HeaderCell stiff>
<Checkbox
size="small"
sx={{ p: 0, color: 'inherit' }}
checked={allFilteredSelected}
indeterminate={!allFilteredSelected && someFilteredSelected}
disabled={filteredEntities.length === 0}
onChange={toggleSelectFiltered}
/>
</HeaderCell>
<HeaderCell stiff>{LL.OPTIONS()}</HeaderCell>
<HeaderCell resize>{LL.NAME(1)}</HeaderCell>
<HeaderCell stiff>{LL.MIN()}</HeaderCell>
@@ -660,6 +804,14 @@ const Customizations = () => {
<Body>
{tableList.map((de: DeviceEntity) => (
<Row key={de.id} item={de} onClick={() => editDeviceEntity(de)}>
<Cell stiff onClick={(event) => event.stopPropagation()}>
<Checkbox
size="small"
sx={{ p: 0 }}
checked={selectedIds.has(de.id)}
onChange={() => toggleSelected(de.id)}
/>
</Cell>
<Cell stiff>
<EntityMaskToggle onUpdate={updateDeviceEntity} de={de} />
</Cell>
@@ -687,10 +839,10 @@ const Customizations = () => {
</span>
</Cell>
<Cell>
{!(de.m & DeviceEntityMask.DV_READONLY) && formatValue(de.mi)}
{!(de.m & DeviceEntityMask.DV_READONLY) && formatLimit(de.mi)}
</Cell>
<Cell>
{!(de.m & DeviceEntityMask.DV_READONLY) && formatValue(de.ma)}
{!(de.m & DeviceEntityMask.DV_READONLY) && formatLimit(de.ma)}
</Cell>
<Cell>{formatValue(de.v)}</Cell>
</Row>
@@ -3,6 +3,7 @@ import { memo, useEffect, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel';
import CloseIcon from '@mui/icons-material/Close';
import DoneIcon from '@mui/icons-material/Done';
import SettingsBackupRestoreIcon from '@mui/icons-material/SettingsBackupRestore';
import {
Box,
Button,
@@ -68,6 +69,15 @@ const CustomizationsDialog = ({
editItem.w &&
!(editItem.m & DeviceEntityMask.DV_READONLY);
// c is set by the firmware for a saved customization. Mask, name, and edited
// limits also count, including changes made in this dialog before they are saved.
const showReset =
!!editItem.c ||
editItem.m !== 0 ||
(editItem.cn ?? '') !== '' ||
editItem.mi !== editItem.o_mi ||
editItem.ma !== editItem.o_ma;
useEffect(() => {
if (open) {
setError(false);
@@ -101,6 +111,17 @@ const CustomizationsDialog = ({
setEditItem((prev) => ({ ...prev, m: updatedItem.m }));
};
const remove = () => {
const next: DeviceEntity = { ...editItem, m: 0, cn: '', c: false };
if (editItem.mi !== undefined) {
next.mi = null;
}
if (editItem.ma !== undefined) {
next.ma = null;
}
onSave(next);
};
return (
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
<DialogTitle>{`${LL.EDIT()} ${LL.ENTITY()}`}</DialogTitle>
@@ -141,7 +162,9 @@ const CustomizationsDialog = ({
<TextField
name="mi"
label={LL.MIN()}
value={numberValue(editItem.mi)}
value={
typeof editItem.mi === 'number' ? numberValue(editItem.mi) : ''
}
sx={{ width: '11ch' }}
type="number"
onChange={updateFormValue}
@@ -151,7 +174,9 @@ const CustomizationsDialog = ({
<TextField
name="ma"
label={LL.MAX()}
value={numberValue(editItem.ma)}
value={
typeof editItem.ma === 'number' ? numberValue(editItem.ma) : ''
}
sx={{ width: '11ch' }}
type="number"
onChange={updateFormValue}
@@ -169,6 +194,17 @@ const CustomizationsDialog = ({
</DialogContent>
<DialogActions>
{showReset && (
<Button
startIcon={<SettingsBackupRestoreIcon />}
variant="outlined"
onClick={remove}
color="error"
>
{LL.RESET()}
</Button>
)}
<Box sx={{ flexGrow: 1 }} />
<Button
startIcon={<CancelIcon />}
variant="outlined"
+1 -1
View File
@@ -174,7 +174,7 @@ const Devices = memo(() => {
}
`,
Table: `
--data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) 130px;
--data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) 170px;
`,
HeaderRow: `
.th {
+2
View File
@@ -1,5 +1,6 @@
import { memo, useContext } from 'react';
import LogoutIcon from '@mui/icons-material/Logout';
import PersonIcon from '@mui/icons-material/Person';
import {
Avatar,
@@ -53,6 +54,7 @@ const UserProfileComponent = () => {
</Box>
<Divider />
<Button
startIcon={<LogoutIcon />}
sx={{ mt: 2 }}
variant="outlined"
color="primary"
+3 -2
View File
@@ -172,8 +172,9 @@ export interface DeviceEntity {
t?: string; // tag for name
m: DeviceEntityMask; // mask
w: boolean; // writeable
mi?: number; // min value
ma?: number; // max value
mi?: number | null; // min value; null means cleared and the original returns after restart
ma?: number | null; // max value; null means cleared and the original returns after restart
c?: boolean; // true when a customization is saved (mask, name, or min/max)
o_m?: number; // original mask before edits
o_cn?: string; // original cn before edits
o_mi?: number; // original min value
@@ -2,6 +2,7 @@ import { useContext, useEffect, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel';
import PowerSettingsNewIcon from '@mui/icons-material/PowerSettingsNew';
import SendIcon from '@mui/icons-material/Send';
import WarningIcon from '@mui/icons-material/Warning';
import {
Box,
@@ -500,6 +501,7 @@ const ApplicationSettings = () => {
</Grid>
<Grid>
<Button
startIcon={<SendIcon />}
sx={{ mt: 3 }}
variant="outlined"
color="primary"
@@ -846,6 +848,7 @@ const ApplicationSettings = () => {
sx={{ width: '15ch' }}
select
>
{data.developer_mode && <MenuItem value={0}>{LL.OFF()}</MenuItem>}
<MenuItem value={1}>EMS</MenuItem>
<MenuItem value={2}>EMS+</MenuItem>
<MenuItem value={3}>HT3</MenuItem>
+28 -1
View File
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import AccessTimeIcon from '@mui/icons-material/AccessTime';
import CancelIcon from '@mui/icons-material/Cancel';
@@ -42,6 +42,7 @@ import type { ValidateFieldsError } from 'validators/schema';
import {
TIME_ZONES,
migrateNtpTimeZones,
selectedTimeZone,
timeZoneSelectItemsT,
useTimeZoneSelectItems
@@ -56,6 +57,7 @@ const NTPSettings = () => {
origData,
dirtyFlags,
setDirtyFlags,
setOrigData,
blocker,
saveData,
errorMessage
@@ -81,6 +83,31 @@ const NTPSettings = () => {
const [settingTime, setSettingTime] = useState<boolean>(false);
const [processing, setProcessing] = useState<boolean>(false);
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const migratingTz = useRef(false);
// If firmware has not yet rewritten tz_format from the label (or the user
// landed here before reboot), persist the current TIME_ZONES values quietly.
useEffect(() => {
if (!data || migratingTz.current || dirtyFlags.length > 0) {
return;
}
const migrated = migrateNtpTimeZones(data);
if (!migrated) {
return;
}
migratingTz.current = true;
void (async () => {
try {
updateDataValue(migrated);
await NTPApi.updateNTPSettings(migrated);
setOrigData(migrated);
} catch {
// Boot-time firmware migration still applies on the next restart.
} finally {
migratingTz.current = false;
}
})();
}, [data, dirtyFlags.length, setOrigData, updateDataValue]);
const { send: updateTime } = useRequest(
(local_time: Time) => NTPApi.updateTime(local_time),
+247 -216
View File
@@ -13,20 +13,20 @@ export const TIME_ZONES: Record<string, string> = {
'Africa/Blantyre': 'CAT-2',
'Africa/Brazzaville': 'WAT-1',
'Africa/Bujumbura': 'CAT-2',
'Africa/Cairo': 'EET-2',
'Africa/Casablanca': 'UNK-1',
'Africa/Cairo': 'EET-2EEST,M4.5.5/0,M10.5.4/24',
'Africa/Casablanca': '<+00>0',
'Africa/Ceuta': 'CET-1CEST,M3.5.0,M10.5.0/3',
'Africa/Conakry': 'GMT0',
'Africa/Dakar': 'GMT0',
'Africa/Dar_es_Salaam': 'EAT-3',
'Africa/Djibouti': 'EAT-3',
'Africa/Douala': 'WAT-1',
'Africa/El_Aaiun': 'UNK-1',
'Africa/El_Aaiun': '<+00>0',
'Africa/Freetown': 'GMT0',
'Africa/Gaborone': 'CAT-2',
'Africa/Harare': 'CAT-2',
'Africa/Johannesburg': 'SAST-2',
'Africa/Juba': 'EAT-3',
'Africa/Juba': 'CAT-2',
'Africa/Kampala': 'EAT-3',
'Africa/Khartoum': 'CAT-2',
'Africa/Kigali': 'CAT-2',
@@ -57,42 +57,42 @@ export const TIME_ZONES: Record<string, string> = {
'America/Anchorage': 'AKST9AKDT,M3.2.0,M11.1.0',
'America/Anguilla': 'AST4',
'America/Antigua': 'AST4',
'America/Araguaina': 'UNK3',
'America/Argentina/Buenos_Aires': 'UNK3',
'America/Argentina/Catamarca': 'UNK3',
'America/Argentina/Cordoba': 'UNK3',
'America/Argentina/Jujuy': 'UNK3',
'America/Argentina/La_Rioja': 'UNK3',
'America/Argentina/Mendoza': 'UNK3',
'America/Argentina/Rio_Gallegos': 'UNK3',
'America/Argentina/Salta': 'UNK3',
'America/Argentina/San_Juan': 'UNK3',
'America/Argentina/San_Luis': 'UNK3',
'America/Argentina/Tucuman': 'UNK3',
'America/Argentina/Ushuaia': 'UNK3',
'America/Araguaina': '<-03>3',
'America/Argentina/Buenos_Aires': '<-03>3',
'America/Argentina/Catamarca': '<-03>3',
'America/Argentina/Cordoba': '<-03>3',
'America/Argentina/Jujuy': '<-03>3',
'America/Argentina/La_Rioja': '<-03>3',
'America/Argentina/Mendoza': '<-03>3',
'America/Argentina/Rio_Gallegos': '<-03>3',
'America/Argentina/Salta': '<-03>3',
'America/Argentina/San_Juan': '<-03>3',
'America/Argentina/San_Luis': '<-03>3',
'America/Argentina/Tucuman': '<-03>3',
'America/Argentina/Ushuaia': '<-03>3',
'America/Aruba': 'AST4',
'America/Asuncion': 'UNK4UNK,M10.1.0/0,M3.4.0/0',
'America/Asuncion': '<-03>3',
'America/Atikokan': 'EST5',
'America/Bahia': 'UNK3',
'America/Bahia_Banderas': 'CST6CDT,M4.1.0,M10.5.0',
'America/Bahia': '<-03>3',
'America/Bahia_Banderas': 'CST6',
'America/Barbados': 'AST4',
'America/Belem': 'UNK3',
'America/Belem': '<-03>3',
'America/Belize': 'CST6',
'America/Blanc-Sablon': 'AST4',
'America/Boa_Vista': 'UNK4',
'America/Bogota': 'UNK5',
'America/Boa_Vista': '<-04>4',
'America/Bogota': '<-05>5',
'America/Boise': 'MST7MDT,M3.2.0,M11.1.0',
'America/Cambridge_Bay': 'MST7MDT,M3.2.0,M11.1.0',
'America/Campo_Grande': 'UNK4',
'America/Campo_Grande': '<-04>4',
'America/Cancun': 'EST5',
'America/Caracas': 'UNK4',
'America/Cayenne': 'UNK3',
'America/Caracas': '<-04>4',
'America/Cayenne': '<-03>3',
'America/Cayman': 'EST5',
'America/Chicago': 'CST6CDT,M3.2.0,M11.1.0',
'America/Chihuahua': 'MST7MDT,M4.1.0,M10.5.0',
'America/Chihuahua': 'CST6',
'America/Costa_Rica': 'CST6',
'America/Creston': 'MST7',
'America/Cuiaba': 'UNK4',
'America/Cuiaba': '<-04>4',
'America/Curacao': 'AST4',
'America/Danmarkshavn': 'GMT0',
'America/Dawson': 'MST7',
@@ -100,20 +100,20 @@ export const TIME_ZONES: Record<string, string> = {
'America/Denver': 'MST7MDT,M3.2.0,M11.1.0',
'America/Detroit': 'EST5EDT,M3.2.0,M11.1.0',
'America/Dominica': 'AST4',
'America/Edmonton': 'MST7MDT,M3.2.0,M11.1.0',
'America/Eirunepe': 'UNK5',
'America/Edmonton': 'CST6',
'America/Eirunepe': '<-05>5',
'America/El_Salvador': 'CST6',
'America/Fort_Nelson': 'MST7',
'America/Fortaleza': 'UNK3',
'America/Fortaleza': '<-03>3',
'America/Glace_Bay': 'AST4ADT,M3.2.0,M11.1.0',
'America/Godthab': 'UNK3UNK,M3.5.0/-2,M10.5.0/-1',
'America/Godthab': '<-02>2<-01>,M3.5.0/-1,M10.5.0/0',
'America/Goose_Bay': 'AST4ADT,M3.2.0,M11.1.0',
'America/Grand_Turk': 'EST5EDT,M3.2.0,M11.1.0',
'America/Grenada': 'AST4',
'America/Guadeloupe': 'AST4',
'America/Guatemala': 'CST6',
'America/Guayaquil': 'UNK5',
'America/Guyana': 'UNK4',
'America/Guayaquil': '<-05>5',
'America/Guyana': '<-04>4',
'America/Halifax': 'AST4ADT,M3.2.0,M11.1.0',
'America/Havana': 'CST5CDT,M3.2.0/0,M11.1.0/1',
'America/Hermosillo': 'MST7',
@@ -125,63 +125,63 @@ export const TIME_ZONES: Record<string, string> = {
'America/Indiana/Vevay': 'EST5EDT,M3.2.0,M11.1.0',
'America/Indiana/Vincennes': 'EST5EDT,M3.2.0,M11.1.0',
'America/Indiana/Winamac': 'EST5EDT,M3.2.0,M11.1.0',
'America/Inuvik': 'MST7MDT,M3.2.0,M11.1.0',
'America/Inuvik': 'CST6',
'America/Iqaluit': 'EST5EDT,M3.2.0,M11.1.0',
'America/Jamaica': 'EST5',
'America/Juneau': 'AKST9AKDT,M3.2.0,M11.1.0',
'America/Kentucky/Louisville': 'EST5EDT,M3.2.0,M11.1.0',
'America/Kentucky/Monticello': 'EST5EDT,M3.2.0,M11.1.0',
'America/Kralendijk': 'AST4',
'America/La_Paz': 'UNK4',
'America/Lima': 'UNK5',
'America/La_Paz': '<-04>4',
'America/Lima': '<-05>5',
'America/Los_Angeles': 'PST8PDT,M3.2.0,M11.1.0',
'America/Lower_Princes': 'AST4',
'America/Maceio': 'UNK3',
'America/Maceio': '<-03>3',
'America/Managua': 'CST6',
'America/Manaus': 'UNK4',
'America/Manaus': '<-04>4',
'America/Marigot': 'AST4',
'America/Martinique': 'AST4',
'America/Matamoros': 'CST6CDT,M3.2.0,M11.1.0',
'America/Mazatlan': 'MST7MDT,M4.1.0,M10.5.0',
'America/Mazatlan': 'MST7',
'America/Menominee': 'CST6CDT,M3.2.0,M11.1.0',
'America/Merida': 'CST6CDT,M4.1.0,M10.5.0',
'America/Merida': 'CST6',
'America/Metlakatla': 'AKST9AKDT,M3.2.0,M11.1.0',
'America/Mexico_City': 'CST6CDT,M4.1.0,M10.5.0',
'America/Miquelon': 'UNK3UNK,M3.2.0,M11.1.0',
'America/Mexico_City': 'CST6',
'America/Miquelon': '<-03>3<-02>,M3.2.0,M11.1.0',
'America/Moncton': 'AST4ADT,M3.2.0,M11.1.0',
'America/Monterrey': 'CST6CDT,M4.1.0,M10.5.0',
'America/Montevideo': 'UNK3',
'America/Monterrey': 'CST6',
'America/Montevideo': '<-03>3',
'America/Montreal': 'EST5EDT,M3.2.0,M11.1.0',
'America/Montserrat': 'AST4',
'America/Nassau': 'EST5EDT,M3.2.0,M11.1.0',
'America/New_York': 'EST5EDT,M3.2.0,M11.1.0',
'America/Nipigon': 'EST5EDT,M3.2.0,M11.1.0',
'America/Nome': 'AKST9AKDT,M3.2.0,M11.1.0',
'America/Noronha': 'UNK2',
'America/Noronha': '<-02>2',
'America/North_Dakota/Beulah': 'CST6CDT,M3.2.0,M11.1.0',
'America/North_Dakota/Center': 'CST6CDT,M3.2.0,M11.1.0',
'America/North_Dakota/New_Salem': 'CST6CDT,M3.2.0,M11.1.0',
'America/Ojinaga': 'MST7MDT,M3.2.0,M11.1.0',
'America/Ojinaga': 'CST6CDT,M3.2.0,M11.1.0',
'America/Panama': 'EST5',
'America/Pangnirtung': 'EST5EDT,M3.2.0,M11.1.0',
'America/Paramaribo': 'UNK3',
'America/Paramaribo': '<-03>3',
'America/Phoenix': 'MST7',
'America/Port-au-Prince': 'EST5EDT,M3.2.0,M11.1.0',
'America/Port_of_Spain': 'AST4',
'America/Porto_Velho': 'UNK4',
'America/Porto_Velho': '<-04>4',
'America/Puerto_Rico': 'AST4',
'America/Punta_Arenas': 'UNK3',
'America/Rainy_River': 'CST6CDT,M3.2.0,M11.1.0',
'America/Punta_Arenas': '<-03>3',
'America/Rainy_River': 'EST5',
'America/Rankin_Inlet': 'CST6CDT,M3.2.0,M11.1.0',
'America/Recife': 'UNK3',
'America/Recife': '<-03>3',
'America/Regina': 'CST6',
'America/Resolute': 'CST6CDT,M3.2.0,M11.1.0',
'America/Rio_Branco': 'UNK5',
'America/Santarem': 'UNK3',
'America/Santiago': 'UNK4UNK,M9.1.6/24,M4.1.6/24',
'America/Rio_Branco': '<-05>5',
'America/Santarem': '<-03>3',
'America/Santiago': '<-04>4<-03>,M9.1.6/24,M4.1.6/24',
'America/Santo_Domingo': 'AST4',
'America/Sao_Paulo': 'UNK3',
'America/Scoresbysund': 'UNK1UNK,M3.5.0/0,M10.5.0/1',
'America/Sao_Paulo': '<-03>3',
'America/Scoresbysund': '<-02>2<-01>,M3.5.0/-1,M10.5.0/0',
'America/Sitka': 'AKST9AKDT,M3.2.0,M11.1.0',
'America/St_Barthelemy': 'AST4',
'America/St_Johns': 'NST3:30NDT,M3.2.0,M11.1.0',
@@ -196,156 +196,156 @@ export const TIME_ZONES: Record<string, string> = {
'America/Tijuana': 'PST8PDT,M3.2.0,M11.1.0',
'America/Toronto': 'EST5EDT,M3.2.0,M11.1.0',
'America/Tortola': 'AST4',
'America/Vancouver': 'PST8PDT,M3.2.0,M11.1.0',
'America/Vancouver': 'MST7',
'America/Whitehorse': 'MST7',
'America/Winnipeg': 'CST6CDT,M3.2.0,M11.1.0',
'America/Winnipeg': 'EST5',
'America/Yakutat': 'AKST9AKDT,M3.2.0,M11.1.0',
'America/Yellowknife': 'MST7MDT,M3.2.0,M11.1.0',
'Antarctica/Casey': 'UNK-8',
'Antarctica/Davis': 'UNK-7',
'Antarctica/DumontDUrville': 'UNK-10',
'Antarctica/Macquarie': 'UNK-11',
'Antarctica/Mawson': 'UNK-5',
'America/Yellowknife': 'CST6',
'Antarctica/Casey': '<+08>-8',
'Antarctica/Davis': '<+07>-7',
'Antarctica/DumontDUrville': '<+10>-10',
'Antarctica/Macquarie': 'AEST-10AEDT,M10.1.0,M4.1.0/3',
'Antarctica/Mawson': '<+05>-5',
'Antarctica/McMurdo': 'NZST-12NZDT,M9.5.0,M4.1.0/3',
'Antarctica/Palmer': 'UNK3',
'Antarctica/Rothera': 'UNK3',
'Antarctica/Syowa': 'UNK-3',
'Antarctica/Troll': 'UNK0UNK-2,M3.5.0/1,M10.5.0/3',
'Antarctica/Vostok': 'UNK-6',
'Antarctica/Palmer': '<-03>3',
'Antarctica/Rothera': '<-03>3',
'Antarctica/Syowa': '<+03>-3',
'Antarctica/Troll': '<+00>0<+02>-2,M3.5.0/1,M10.5.0/3',
'Antarctica/Vostok': '<+05>-5',
'Arctic/Longyearbyen': 'CET-1CEST,M3.5.0,M10.5.0/3',
'Asia/Aden': 'UNK-3',
'Asia/Almaty': 'UNK-6',
'Asia/Amman': 'EET-2EEST,M3.5.4/24,M10.5.5/1',
'Asia/Anadyr': 'UNK-12',
'Asia/Aqtau': 'UNK-5',
'Asia/Aqtobe': 'UNK-5',
'Asia/Ashgabat': 'UNK-5',
'Asia/Atyrau': 'UNK-5',
'Asia/Baghdad': 'UNK-3',
'Asia/Bahrain': 'UNK-3',
'Asia/Baku': 'UNK-4',
'Asia/Bangkok': 'UNK-7',
'Asia/Barnaul': 'UNK-7',
'Asia/Aden': '<+03>-3',
'Asia/Almaty': '<+05>-5',
'Asia/Amman': '<+03>-3',
'Asia/Anadyr': '<+12>-12',
'Asia/Aqtau': '<+05>-5',
'Asia/Aqtobe': '<+05>-5',
'Asia/Ashgabat': '<+05>-5',
'Asia/Atyrau': '<+05>-5',
'Asia/Baghdad': '<+03>-3',
'Asia/Bahrain': '<+03>-3',
'Asia/Baku': '<+04>-4',
'Asia/Bangkok': '<+07>-7',
'Asia/Barnaul': '<+07>-7',
'Asia/Beirut': 'EET-2EEST,M3.5.0/0,M10.5.0/0',
'Asia/Bishkek': 'UNK-6',
'Asia/Brunei': 'UNK-8',
'Asia/Chita': 'UNK-9',
'Asia/Choibalsan': 'UNK-8',
'Asia/Colombo': 'UNK-5:30',
'Asia/Damascus': 'EET-2EEST,M3.5.5/0,M10.5.5/0',
'Asia/Dhaka': 'UNK-6',
'Asia/Dili': 'UNK-9',
'Asia/Dubai': 'UNK-4',
'Asia/Dushanbe': 'UNK-5',
'Asia/Bishkek': '<+06>-6',
'Asia/Brunei': '<+08>-8',
'Asia/Chita': '<+09>-9',
'Asia/Choibalsan': '<+08>-8',
'Asia/Colombo': '<+0530>-5:30',
'Asia/Damascus': '<+03>-3',
'Asia/Dhaka': '<+06>-6',
'Asia/Dili': '<+09>-9',
'Asia/Dubai': '<+04>-4',
'Asia/Dushanbe': '<+05>-5',
'Asia/Famagusta': 'EET-2EEST,M3.5.0/3,M10.5.0/4',
'Asia/Gaza': 'EET-2EEST,M3.5.5/0,M10.5.6/1',
'Asia/Hebron': 'EET-2EEST,M3.5.5/0,M10.5.6/1',
'Asia/Ho_Chi_Minh': 'UNK-7',
'Asia/Gaza': 'EET-2EEST,M3.4.4/50,M10.4.4/50',
'Asia/Hebron': 'EET-2EEST,M3.4.4/50,M10.4.4/50',
'Asia/Ho_Chi_Minh': '<+07>-7',
'Asia/Hong_Kong': 'HKT-8',
'Asia/Hovd': 'UNK-7',
'Asia/Irkutsk': 'UNK-8',
'Asia/Hovd': '<+07>-7',
'Asia/Irkutsk': '<+08>-8',
'Asia/Jakarta': 'WIB-7',
'Asia/Jayapura': 'WIT-9',
'Asia/Jerusalem': 'IST-2IDT,M3.4.4/26,M10.5.0',
'Asia/Kabul': 'UNK-4:30',
'Asia/Kamchatka': 'UNK-12',
'Asia/Kabul': '<+0430>-4:30',
'Asia/Kamchatka': '<+12>-12',
'Asia/Karachi': 'PKT-5',
'Asia/Kathmandu': 'UNK-5:45',
'Asia/Khandyga': 'UNK-9',
'Asia/Kathmandu': '<+0545>-5:45',
'Asia/Khandyga': '<+09>-9',
'Asia/Kolkata': 'IST-5:30',
'Asia/Krasnoyarsk': 'UNK-7',
'Asia/Kuala_Lumpur': 'UNK-8',
'Asia/Kuching': 'UNK-8',
'Asia/Kuwait': 'UNK-3',
'Asia/Krasnoyarsk': '<+07>-7',
'Asia/Kuala_Lumpur': '<+08>-8',
'Asia/Kuching': '<+08>-8',
'Asia/Kuwait': '<+03>-3',
'Asia/Macau': 'CST-8',
'Asia/Magadan': 'UNK-11',
'Asia/Magadan': '<+11>-11',
'Asia/Makassar': 'WITA-8',
'Asia/Manila': 'PST-8',
'Asia/Muscat': 'UNK-4',
'Asia/Muscat': '<+04>-4',
'Asia/Nicosia': 'EET-2EEST,M3.5.0/3,M10.5.0/4',
'Asia/Novokuznetsk': 'UNK-7',
'Asia/Novosibirsk': 'UNK-7',
'Asia/Omsk': 'UNK-6',
'Asia/Oral': 'UNK-5',
'Asia/Phnom_Penh': 'UNK-7',
'Asia/Novokuznetsk': '<+07>-7',
'Asia/Novosibirsk': '<+07>-7',
'Asia/Omsk': '<+06>-6',
'Asia/Oral': '<+05>-5',
'Asia/Phnom_Penh': '<+07>-7',
'Asia/Pontianak': 'WIB-7',
'Asia/Pyongyang': 'KST-9',
'Asia/Qatar': 'UNK-3',
'Asia/Qyzylorda': 'UNK-5',
'Asia/Riyadh': 'UNK-3',
'Asia/Sakhalin': 'UNK-11',
'Asia/Samarkand': 'UNK-5',
'Asia/Qatar': '<+03>-3',
'Asia/Qyzylorda': '<+05>-5',
'Asia/Riyadh': '<+03>-3',
'Asia/Sakhalin': '<+11>-11',
'Asia/Samarkand': '<+05>-5',
'Asia/Seoul': 'KST-9',
'Asia/Shanghai': 'CST-8',
'Asia/Singapore': 'UNK-8',
'Asia/Srednekolymsk': 'UNK-11',
'Asia/Singapore': '<+08>-8',
'Asia/Srednekolymsk': '<+11>-11',
'Asia/Taipei': 'CST-8',
'Asia/Tashkent': 'UNK-5',
'Asia/Tbilisi': 'UNK-4',
'Asia/Tehran': 'UNK-3:30UNK,J79/24,J263/24',
'Asia/Thimphu': 'UNK-6',
'Asia/Tashkent': '<+05>-5',
'Asia/Tbilisi': '<+04>-4',
'Asia/Tehran': '<+0330>-3:30',
'Asia/Thimphu': '<+06>-6',
'Asia/Tokyo': 'JST-9',
'Asia/Tomsk': 'UNK-7',
'Asia/Ulaanbaatar': 'UNK-8',
'Asia/Urumqi': 'UNK-6',
'Asia/Ust-Nera': 'UNK-10',
'Asia/Vientiane': 'UNK-7',
'Asia/Vladivostok': 'UNK-10',
'Asia/Yakutsk': 'UNK-9',
'Asia/Yangon': 'UNK-6:30',
'Asia/Yekaterinburg': 'UNK-5',
'Asia/Yerevan': 'UNK-4',
'Atlantic/Azores': 'UNK1UNK,M3.5.0/0,M10.5.0/1',
'Asia/Tomsk': '<+07>-7',
'Asia/Ulaanbaatar': '<+08>-8',
'Asia/Urumqi': '<+06>-6',
'Asia/Ust-Nera': '<+10>-10',
'Asia/Vientiane': '<+07>-7',
'Asia/Vladivostok': '<+10>-10',
'Asia/Yakutsk': '<+09>-9',
'Asia/Yangon': '<+0630>-6:30',
'Asia/Yekaterinburg': '<+05>-5',
'Asia/Yerevan': '<+04>-4',
'Atlantic/Azores': '<-01>1<+00>,M3.5.0/0,M10.5.0/1',
'Atlantic/Bermuda': 'AST4ADT,M3.2.0,M11.1.0',
'Atlantic/Canary': 'WET0WEST,M3.5.0/1,M10.5.0',
'Atlantic/Cape_Verde': 'UNK1',
'Atlantic/Cape_Verde': '<-01>1',
'Atlantic/Faroe': 'WET0WEST,M3.5.0/1,M10.5.0',
'Atlantic/Madeira': 'WET0WEST,M3.5.0/1,M10.5.0',
'Atlantic/Reykjavik': 'GMT0',
'Atlantic/South_Georgia': 'UNK2',
'Atlantic/South_Georgia': '<-02>2',
'Atlantic/St_Helena': 'GMT0',
'Atlantic/Stanley': 'UNK3',
'Atlantic/Stanley': '<-03>3',
'Australia/Adelaide': 'ACST-9:30ACDT,M10.1.0,M4.1.0/3',
'Australia/Brisbane': 'AEST-10',
'Australia/Broken_Hill': 'ACST-9:30ACDT,M10.1.0,M4.1.0/3',
'Australia/Currie': 'AEST-10AEDT,M10.1.0,M4.1.0/3',
'Australia/Darwin': 'ACST-9:30',
'Australia/Eucla': 'UNK-8:45',
'Australia/Eucla': '<+0845>-8:45',
'Australia/Hobart': 'AEST-10AEDT,M10.1.0,M4.1.0/3',
'Australia/Lindeman': 'AEST-10',
'Australia/Lord_Howe': 'UNK-10:30UNK-11,M10.1.0,M4.1.0',
'Australia/Lord_Howe': '<+1030>-10:30<+11>-11,M10.1.0,M4.1.0',
'Australia/Melbourne': 'AEST-10AEDT,M10.1.0,M4.1.0/3',
'Australia/Perth': 'AWST-8',
'Australia/Sydney': 'AEST-10AEDT,M10.1.0,M4.1.0/3',
'Etc/GMT': 'GMT0',
'Etc/GMT+0': 'GMT0',
'Etc/GMT+1': 'UNK1',
'Etc/GMT+10': 'UNK10',
'Etc/GMT+11': 'UNK11',
'Etc/GMT+12': 'UNK12',
'Etc/GMT+2': 'UNK2',
'Etc/GMT+3': 'UNK3',
'Etc/GMT+4': 'UNK4',
'Etc/GMT+5': 'UNK5',
'Etc/GMT+6': 'UNK6',
'Etc/GMT+7': 'UNK7',
'Etc/GMT+8': 'UNK8',
'Etc/GMT+9': 'UNK9',
'Etc/GMT+1': '<-01>1',
'Etc/GMT+10': '<-10>10',
'Etc/GMT+11': '<-11>11',
'Etc/GMT+12': '<-12>12',
'Etc/GMT+2': '<-02>2',
'Etc/GMT+3': '<-03>3',
'Etc/GMT+4': '<-04>4',
'Etc/GMT+5': '<-05>5',
'Etc/GMT+6': '<-06>6',
'Etc/GMT+7': '<-07>7',
'Etc/GMT+8': '<-08>8',
'Etc/GMT+9': '<-09>9',
'Etc/GMT-0': 'GMT0',
'Etc/GMT-1': 'UNK-1',
'Etc/GMT-10': 'UNK-10',
'Etc/GMT-11': 'UNK-11',
'Etc/GMT-12': 'UNK-12',
'Etc/GMT-13': 'UNK-13',
'Etc/GMT-14': 'UNK-14',
'Etc/GMT-2': 'UNK-2',
'Etc/GMT-3': 'UNK-3',
'Etc/GMT-4': 'UNK-4',
'Etc/GMT-5': 'UNK-5',
'Etc/GMT-6': 'UNK-6',
'Etc/GMT-7': 'UNK-7',
'Etc/GMT-8': 'UNK-8',
'Etc/GMT-9': 'UNK-9',
'Etc/GMT-1': '<+01>-1',
'Etc/GMT-10': '<+10>-10',
'Etc/GMT-11': '<+11>-11',
'Etc/GMT-12': '<+12>-12',
'Etc/GMT-13': '<+13>-13',
'Etc/GMT-14': '<+14>-14',
'Etc/GMT-2': '<+02>-2',
'Etc/GMT-3': '<+03>-3',
'Etc/GMT-4': '<+04>-4',
'Etc/GMT-5': '<+05>-5',
'Etc/GMT-6': '<+06>-6',
'Etc/GMT-7': '<+07>-7',
'Etc/GMT-8': '<+08>-8',
'Etc/GMT-9': '<+09>-9',
'Etc/GMT0': 'GMT0',
'Etc/Greenwich': 'GMT0',
'Etc/UCT': 'UTC0',
@@ -354,7 +354,7 @@ export const TIME_ZONES: Record<string, string> = {
'Etc/Zulu': 'UTC0',
'Europe/Amsterdam': 'CET-1CEST,M3.5.0,M10.5.0/3',
'Europe/Andorra': 'CET-1CEST,M3.5.0,M10.5.0/3',
'Europe/Astrakhan': 'UNK-4',
'Europe/Astrakhan': '<+04>-4',
'Europe/Athens': 'EET-2EEST,M3.5.0/3,M10.5.0/4',
'Europe/Belgrade': 'CET-1CEST,M3.5.0,M10.5.0/3',
'Europe/Berlin': 'CET-1CEST,M3.5.0,M10.5.0/3',
@@ -363,18 +363,18 @@ export const TIME_ZONES: Record<string, string> = {
'Europe/Bucharest': 'EET-2EEST,M3.5.0/3,M10.5.0/4',
'Europe/Budapest': 'CET-1CEST,M3.5.0,M10.5.0/3',
'Europe/Busingen': 'CET-1CEST,M3.5.0,M10.5.0/3',
'Europe/Chisinau': 'EET-2EEST,M3.5.0,M10.5.0/3',
'Europe/Chisinau': 'EET-2EEST,M3.5.0/3,M10.5.0/4',
'Europe/Copenhagen': 'CET-1CEST,M3.5.0,M10.5.0/3',
'Europe/Dublin': 'IST-1GMT0,M10.5.0,M3.5.0/1',
'Europe/Gibraltar': 'CET-1CEST,M3.5.0,M10.5.0/3',
'Europe/Guernsey': 'GMT0BST,M3.5.0/1,M10.5.0',
'Europe/Helsinki': 'EET-2EEST,M3.5.0/3,M10.5.0/4',
'Europe/Isle_of_Man': 'GMT0BST,M3.5.0/1,M10.5.0',
'Europe/Istanbul': 'UNK-3',
'Europe/Istanbul': '<+03>-3',
'Europe/Jersey': 'GMT0BST,M3.5.0/1,M10.5.0',
'Europe/Kaliningrad': 'EET-2',
'Europe/Kiev': 'EET-2EEST,M3.5.0/3,M10.5.0/4',
'Europe/Kirov': 'UNK-3',
'Europe/Kirov': 'MSK-3',
'Europe/Lisbon': 'WET0WEST,M3.5.0/1,M10.5.0',
'Europe/Ljubljana': 'CET-1CEST,M3.5.0,M10.5.0/3',
'Europe/London': 'GMT0BST,M3.5.0/1,M10.5.0',
@@ -382,7 +382,7 @@ export const TIME_ZONES: Record<string, string> = {
'Europe/Madrid': 'CET-1CEST,M3.5.0,M10.5.0/3',
'Europe/Malta': 'CET-1CEST,M3.5.0,M10.5.0/3',
'Europe/Mariehamn': 'EET-2EEST,M3.5.0/3,M10.5.0/4',
'Europe/Minsk': 'UNK-3',
'Europe/Minsk': '<+03>-3',
'Europe/Monaco': 'CET-1CEST,M3.5.0,M10.5.0/3',
'Europe/Moscow': 'MSK-3',
'Europe/Oslo': 'CET-1CEST,M3.5.0,M10.5.0/3',
@@ -391,83 +391,114 @@ export const TIME_ZONES: Record<string, string> = {
'Europe/Prague': 'CET-1CEST,M3.5.0,M10.5.0/3',
'Europe/Riga': 'EET-2EEST,M3.5.0/3,M10.5.0/4',
'Europe/Rome': 'CET-1CEST,M3.5.0,M10.5.0/3',
'Europe/Samara': 'UNK-4',
'Europe/Samara': '<+04>-4',
'Europe/San_Marino': 'CET-1CEST,M3.5.0,M10.5.0/3',
'Europe/Sarajevo': 'CET-1CEST,M3.5.0,M10.5.0/3',
'Europe/Saratov': 'UNK-4',
'Europe/Saratov': '<+04>-4',
'Europe/Simferopol': 'MSK-3',
'Europe/Skopje': 'CET-1CEST,M3.5.0,M10.5.0/3',
'Europe/Sofia': 'EET-2EEST,M3.5.0/3,M10.5.0/4',
'Europe/Stockholm': 'CET-1CEST,M3.5.0,M10.5.0/3',
'Europe/Tallinn': 'EET-2EEST,M3.5.0/3,M10.5.0/4',
'Europe/Tirane': 'CET-1CEST,M3.5.0,M10.5.0/3',
'Europe/Ulyanovsk': 'UNK-4',
'Europe/Ulyanovsk': '<+04>-4',
'Europe/Uzhgorod': 'EET-2EEST,M3.5.0/3,M10.5.0/4',
'Europe/Vaduz': 'CET-1CEST,M3.5.0,M10.5.0/3',
'Europe/Vatican': 'CET-1CEST,M3.5.0,M10.5.0/3',
'Europe/Vienna': 'CET-1CEST,M3.5.0,M10.5.0/3',
'Europe/Vilnius': 'EET-2EEST,M3.5.0/3,M10.5.0/4',
'Europe/Volgograd': 'UNK-4',
'Europe/Volgograd': 'MSK-3',
'Europe/Warsaw': 'CET-1CEST,M3.5.0,M10.5.0/3',
'Europe/Zagreb': 'CET-1CEST,M3.5.0,M10.5.0/3',
'Europe/Zaporozhye': 'EET-2EEST,M3.5.0/3,M10.5.0/4',
'Europe/Zurich': 'CET-1CEST,M3.5.0,M10.5.0/3',
'Indian/Antananarivo': 'EAT-3',
'Indian/Chagos': 'UNK-6',
'Indian/Christmas': 'UNK-7',
'Indian/Cocos': 'UNK-6:30',
'Indian/Chagos': '<+06>-6',
'Indian/Christmas': '<+07>-7',
'Indian/Cocos': '<+0630>-6:30',
'Indian/Comoro': 'EAT-3',
'Indian/Kerguelen': 'UNK-5',
'Indian/Mahe': 'UNK-4',
'Indian/Maldives': 'UNK-5',
'Indian/Mauritius': 'UNK-4',
'Indian/Kerguelen': '<+05>-5',
'Indian/Mahe': '<+04>-4',
'Indian/Maldives': '<+05>-5',
'Indian/Mauritius': '<+04>-4',
'Indian/Mayotte': 'EAT-3',
'Indian/Reunion': 'UNK-4',
'Pacific/Apia': 'UNK-13UNK,M9.5.0/3,M4.1.0/4',
'Indian/Reunion': '<+04>-4',
'Pacific/Apia': '<+13>-13',
'Pacific/Auckland': 'NZST-12NZDT,M9.5.0,M4.1.0/3',
'Pacific/Bougainville': 'UNK-11',
'Pacific/Chatham': 'UNK-12:45UNK,M9.5.0/2:45,M4.1.0/3:45',
'Pacific/Chuuk': 'UNK-10',
'Pacific/Easter': 'UNK6UNK,M9.1.6/22,M4.1.6/22',
'Pacific/Efate': 'UNK-11',
'Pacific/Enderbury': 'UNK-13',
'Pacific/Fakaofo': 'UNK-13',
'Pacific/Fiji': 'UNK-12UNK,M11.2.0,M1.2.3/99',
'Pacific/Funafuti': 'UNK-12',
'Pacific/Galapagos': 'UNK6',
'Pacific/Gambier': 'UNK9',
'Pacific/Guadalcanal': 'UNK-11',
'Pacific/Bougainville': '<+11>-11',
'Pacific/Chatham': '<+1245>-12:45<+1345>,M9.5.0/2:45,M4.1.0/3:45',
'Pacific/Chuuk': '<+10>-10',
'Pacific/Easter': '<-06>6<-05>,M9.1.6/22,M4.1.6/22',
'Pacific/Efate': '<+11>-11',
'Pacific/Enderbury': '<+13>-13',
'Pacific/Fakaofo': '<+13>-13',
'Pacific/Fiji': '<+12>-12',
'Pacific/Funafuti': '<+12>-12',
'Pacific/Galapagos': '<-06>6',
'Pacific/Gambier': '<-09>9',
'Pacific/Guadalcanal': '<+11>-11',
'Pacific/Guam': 'ChST-10',
'Pacific/Honolulu': 'HST10',
'Pacific/Kiritimati': 'UNK-14',
'Pacific/Kosrae': 'UNK-11',
'Pacific/Kwajalein': 'UNK-12',
'Pacific/Majuro': 'UNK-12',
'Pacific/Marquesas': 'UNK9:30',
'Pacific/Kiritimati': '<+14>-14',
'Pacific/Kosrae': '<+11>-11',
'Pacific/Kwajalein': '<+12>-12',
'Pacific/Majuro': '<+12>-12',
'Pacific/Marquesas': '<-0930>9:30',
'Pacific/Midway': 'SST11',
'Pacific/Nauru': 'UNK-12',
'Pacific/Niue': 'UNK11',
'Pacific/Norfolk': 'UNK-11UNK,M10.1.0,M4.1.0/3',
'Pacific/Noumea': 'UNK-11',
'Pacific/Nauru': '<+12>-12',
'Pacific/Niue': '<-11>11',
'Pacific/Norfolk': '<+11>-11<+12>,M10.1.0,M4.1.0/3',
'Pacific/Noumea': '<+11>-11',
'Pacific/Pago_Pago': 'SST11',
'Pacific/Palau': 'UNK-9',
'Pacific/Pitcairn': 'UNK8',
'Pacific/Pohnpei': 'UNK-11',
'Pacific/Port_Moresby': 'UNK-10',
'Pacific/Rarotonga': 'UNK10',
'Pacific/Palau': '<+09>-9',
'Pacific/Pitcairn': '<-08>8',
'Pacific/Pohnpei': '<+11>-11',
'Pacific/Port_Moresby': '<+10>-10',
'Pacific/Rarotonga': '<-10>10',
'Pacific/Saipan': 'ChST-10',
'Pacific/Tahiti': 'UNK10',
'Pacific/Tarawa': 'UNK-12',
'Pacific/Tongatapu': 'UNK-13',
'Pacific/Wake': 'UNK-12',
'Pacific/Wallis': 'UNK-12'
'Pacific/Tahiti': '<-10>10',
'Pacific/Tarawa': '<+12>-12',
'Pacific/Tongatapu': '<+13>-13',
'Pacific/Wake': '<+12>-12',
'Pacific/Wallis': '<+12>-12'
};
// Pre-compute sorted timezone labels for better performance
export const TIME_ZONE_LABELS = Object.keys(TIME_ZONES).sort();
/** Resolve the select value for a saved zone. Prefer the label when it is in
* TIME_ZONES so a stale stored tz_format (after a table update) still shows the
* correct city; firmware migrates the format from the label on boot. */
export function selectedTimeZone(label: string, format: string) {
return TIME_ZONES[label] === format ? label : undefined;
if (label in TIME_ZONES) {
return label;
}
return Object.keys(TIME_ZONES).find((key) => TIME_ZONES[key] === format);
}
/** Return settings with tz_format / tz_format_t refreshed from TIME_ZONES, or
* null if already current (or labels are unknown). */
export function migrateNtpTimeZones<
T extends {
tz_label: string;
tz_format: string;
tz_label_t: string;
tz_format_t: string;
}
>(settings: T): T | null {
const tz_format = TIME_ZONES[settings.tz_label];
const tz_format_t = TIME_ZONES[settings.tz_label_t];
const next = { ...settings };
let changed = false;
if (tz_format && tz_format !== settings.tz_format) {
next.tz_format = tz_format;
changed = true;
}
if (tz_format_t && tz_format_t !== settings.tz_format_t) {
next.tz_format_t = tz_format_t;
changed = true;
}
return changed ? next : null;
}
const precomputedTimeZoneItems = TIME_ZONE_LABELS.map((label) => (
+376 -132
View File
@@ -3,11 +3,14 @@ import { Link } from 'react-router';
import CancelIcon from '@mui/icons-material/Cancel';
import CloseIcon from '@mui/icons-material/Close';
import DescriptionIcon from '@mui/icons-material/Description';
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 RefreshIcon from '@mui/icons-material/Refresh';
import RestartAltIcon from '@mui/icons-material/RestartAlt';
import SettingsBackupRestoreIcon from '@mui/icons-material/SettingsBackupRestore';
import SwapHorizIcon from '@mui/icons-material/SwapHoriz';
import WarningIcon from '@mui/icons-material/Warning';
import {
Box,
@@ -16,7 +19,6 @@ import {
DialogActions,
DialogContent,
DialogTitle,
Grid,
IconButton,
Table,
TableBody,
@@ -52,6 +54,8 @@ const STABLE_RELNOTES_URL =
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';
const RELEASES_API_URL =
'https://api.github.com/repos/emsesp/EMS-ESP32/releases/tags/';
const MAX_VERSION_POLLS = 5; // approx 15 seconds of waiting
// Types for better type safety
@@ -71,10 +75,101 @@ interface VersionData {
build_flags?: string;
partition: string;
partitions: PartitionData[];
recovery_version?: string; // only on boards with a factory partition, empty if not installed
developer_mode: boolean;
disable_reset: boolean;
}
interface RecoveryRelease {
url: string;
version: string;
}
// finds the recovery firmware for this board in the GitHub releases, trying each release tag in turn.
// The version is in the filename, e.g. EMS-ESP-Recovery-1_0_0-ESP32S3-16MB+.bin is 1.0.0
const findRecovery = async (
tags: string[],
platform: string
): Promise<RecoveryRelease | undefined> => {
const prefix = 'ems-esp-recovery-';
const suffix = `-${platform}.bin`.toLowerCase();
for (const tag of tags) {
try {
const response = await fetch(`${RELEASES_API_URL}${tag}`);
if (!response.ok) {
continue;
}
const release = (await response.json()) as {
assets?: { name: string; browser_download_url: string }[];
};
const asset = release.assets?.find((a) => {
const name = a.name.toLowerCase();
return name.startsWith(prefix) && name.endsWith(suffix);
});
if (asset) {
return {
url: asset.browser_download_url,
version: asset.name
.slice(prefix.length, asset.name.length - suffix.length)
.replaceAll('_', '.')
};
}
} catch {
// try the next one
}
}
return undefined;
};
// the GitHub API only allows 60 requests an hour, so only look once per page load for each board and channel
const recoveryLookups = new Map<string, Promise<RecoveryRelease | undefined>>();
const findRecoveryCached = (tags: string[], platform: string) => {
const key = `${tags.join(',')}|${platform}`;
let lookup = recoveryLookups.get(key);
if (!lookup) {
lookup = findRecovery(tags, platform);
recoveryLookups.set(key, lookup);
void lookup.then((release) => {
if (!release) {
recoveryLookups.delete(key); // try again next time
}
});
}
return lookup;
};
// true if version a is newer than b, like 1.0.1 and 1.0.0. A prerelease, like 1.1.0-dev.1, is older than the release
const isNewerVersion = (a: string, b: string) => {
const parse = (v: string) => {
const dash = v.indexOf('-');
const numbers = (text: string) =>
text.split('.').map((n) => Number.parseInt(n, 10) || 0);
return {
main: numbers(dash < 0 ? v : v.slice(0, dash)),
pre: dash < 0 ? [] : numbers(v.slice(dash + 1)),
isPre: dash >= 0
};
};
const x = parse(a);
const y = parse(b);
for (let i = 0; i < Math.max(x.main.length, y.main.length); i++) {
const diff = (x.main[i] ?? 0) - (y.main[i] ?? 0);
if (diff !== 0) {
return diff > 0;
}
}
if (x.isPre !== y.isPre) {
return !x.isPre;
}
for (let i = 0; i < Math.max(x.pre.length, y.pre.length); i++) {
const diff = (x.pre[i] ?? 0) - (y.pre[i] ?? 0);
if (diff !== 0) {
return diff > 0;
}
}
return false;
};
// Memoized components for better performance
const VersionInfoDialog = memo(
({
@@ -235,6 +330,7 @@ const VersionInfoDialog = memo(
<DialogActions>
{!isPartition && (
<Button
startIcon={<DescriptionIcon />}
variant="outlined"
component="a"
href={relNotesUrl}
@@ -244,7 +340,12 @@ const VersionInfoDialog = memo(
Changelog
</Button>
)}
<Button variant="outlined" onClick={onClose} color="secondary">
<Button
startIcon={<CloseIcon />}
variant="outlined"
onClick={onClose}
color="secondary"
>
{LL.CLOSE()}
</Button>
</DialogActions>
@@ -368,14 +469,16 @@ const InstallPartitionDialog = memo(
onClose: () => void;
onInstall: (partition: string) => void;
}) => {
const isRecovery = partition === 'boot';
return (
<Dialog sx={dialogStyle} open={openInstallPartitionDialog} onClose={onClose}>
<DialogTitle>
{LL.INSTALL()} {LL.STORED_VERSIONS()}
</DialogTitle>
<DialogTitle>{isRecovery ? LL.RECOVERY_APP() : LL.INSTALL()}</DialogTitle>
<DialogContent dividers>
<Typography sx={{ mb: 2 }}>
{LL.INSTALL_VERSION(LL.INSTALL(), version)}
{isRecovery
? 'This will boot in recovery mode'
: LL.INSTALL_VERSION(LL.INSTALL(), version)}
</Typography>
</DialogContent>
<DialogActions>
@@ -389,12 +492,14 @@ const InstallPartitionDialog = memo(
</Button>
<Button
startIcon={<WarningIcon color="warning" />}
startIcon={
isRecovery ? <RestartAltIcon /> : <WarningIcon color="warning" />
}
variant="outlined"
onClick={() => onInstall(partition)}
color="primary"
color={isRecovery ? 'success' : 'primary'}
>
{LL.INSTALL()}
{isRecovery ? LL.RESTART() : LL.INSTALL()}
</Button>
</DialogActions>
</Dialog>
@@ -412,6 +517,11 @@ const Version = () => {
const { me, versions, refreshVersions } = useContext(AuthenticatedContext);
const [restarting, setRestarting] = useState<boolean>(false);
const [findingRecovery, setFindingRecovery] = useState<boolean>(false);
const [installingRecovery, setInstallingRecovery] = useState<boolean>(false);
const [latestRecovery, setLatestRecovery] = useState<RecoveryRelease | undefined>(
undefined
);
const [confirmFactoryReset, setConfirmFactoryReset] = useState<boolean>(false);
const [confirmRestart, setConfirmRestart] = useState<boolean>(false);
const [openInstallDialog, setOpenInstallDialog] = useState<boolean>(false);
@@ -493,9 +603,6 @@ const Version = () => {
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);
@@ -538,6 +645,49 @@ const Version = () => {
await doRestart();
};
// the release of the channel we're on first, it may not have a recovery app yet so try the other one too
const stableTag = latestVersion ? `v${latestVersion.version}` : '';
const recoveryTags = (
usingDevVersion ? ['latest', stableTag] : [stableTag, 'latest']
).filter(Boolean);
const recoveryTagsKey = recoveryTags.join(',');
const hasRecoverySlot = data?.recovery_version !== undefined;
// look for the latest recovery app, to show if there is a newer one
useEffect(() => {
if (!hasRecoverySlot || !internetLive || !platform) {
return;
}
let cancelled = false;
void findRecoveryCached(recoveryTags, platform).then((release) => {
if (!cancelled) {
setLatestRecovery(release);
}
});
return () => {
cancelled = true;
};
}, [hasRecoverySlot, internetLive, platform, recoveryTagsKey]);
// downloads the recovery app from GitHub into the factory partition, EMS-ESP doesn't restart
const installRecovery = async () => {
setFindingRecovery(true);
const release =
latestRecovery ?? (await findRecoveryCached(recoveryTags, platform));
setFindingRecovery(false);
if (!release) {
toast.error(LL.RECOVERY_NOT_FOUND());
return;
}
await sendUploadURL(release.url)
.then(() => setInstallingRecovery(true))
.catch((error: Error) => {
toast.error(error.message);
});
};
const installPartitionFirmware = async (partition: string) => {
await sendSetPartition(partition).catch((error: Error) => {
toast.error(error.message);
@@ -601,8 +751,9 @@ const Version = () => {
{LL.LATEST_VERSION(usingDevVersion ? LL.DEVELOPMENT() : LL.STABLE())}
</span>
<Button
sx={{ ml: 1 }}
sx={{ ml: 3 }}
variant="outlined"
startIcon={<RefreshIcon />}
size="small"
onClick={() => showFirmwareDialog(showingDev)}
>
@@ -618,9 +769,10 @@ const Version = () => {
return (
<Button
sx={{ ml: 1 }}
sx={{ ml: 3 }}
variant="outlined"
color={isUpdateAvailable ? 'success' : 'warning'}
startIcon={!isUpdateAvailable ? <SwapHorizIcon /> : undefined}
size="small"
onClick={() => showFirmwareDialog(showingDev)}
>
@@ -633,7 +785,7 @@ const Version = () => {
display: 'inline-block',
width: 8,
height: 8,
ml: 1,
ml: 3,
verticalAlign: 'middle',
borderRadius: '50%',
backgroundColor: '#ffeb3b',
@@ -649,6 +801,20 @@ const Version = () => {
return <SystemMonitor />;
}
if (installingRecovery) {
return (
<SystemMonitor
onDone={(success) => {
setInstallingRecovery(false);
if (success) {
toast.success(LL.RECOVERY_INSTALLED());
}
void loadData();
}}
/>
);
}
if (!data) {
return (
<SectionContent>
@@ -664,64 +830,118 @@ const Version = () => {
{LL.THIS_VERSION()}
</Typography>
<Grid
container
direction="row"
<Box
sx={{
justifyContent: 'flex-start',
alignItems: 'baseline'
display: 'grid',
gridTemplateColumns: 'auto 1fr',
gridAutoRows: 'minmax(32px, auto)', // same height for every row
columnGap: 3,
rowGap: 0.75,
alignItems: 'center',
// whole pixel line height so the labels and values are centered on the same pixel
'& .MuiTypography-root': { lineHeight: '24px' },
'& .MuiTypography-caption': { lineHeight: 1 }
}}
>
<Grid size={{ xs: 4, md: 2 }}>
<Typography color="secondary">{LL.VERSION()}</Typography>
</Grid>
<Grid size={{ xs: 8, md: 10 }}>
<Typography>
<Typography color="secondary">{LL.VERSION()}</Typography>
<Box
sx={{
display: 'flex',
alignItems: 'center',
minWidth: 0
}}
>
<Typography component="span">
{data.emsesp_version}
{data.build_flags && (
<Typography variant="caption">
&nbsp; &#40;{data.build_flags}&#41;
<Typography component="span" variant="caption">
&nbsp;({data.build_flags})
</Typography>
)}
<IconButton
onClick={() => setPartitionVersionInfo(data.partition)}
aria-label={LL.FIRMWARE_VERSION_INFO()}
>
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
</IconButton>
</Typography>
</Grid>
<IconButton
size="small"
onClick={() => setPartitionVersionInfo(data.partition)}
aria-label={LL.FIRMWARE_VERSION_INFO()}
sx={{ p: 0.5 }}
>
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
</IconButton>
</Box>
<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 color="secondary">{LL.PLATFORM()}</Typography>
<Box
sx={{
display: 'flex',
alignItems: 'center',
minWidth: 0
}}
>
<Typography component="span">{platform}</Typography>
<Typography
component="span"
variant="caption"
sx={{
display: 'inline-flex',
alignItems: 'center',
ml: 0.75
}}
>
(
{data.psram ? (
<CheckIcon color="success" sx={{ fontSize: 16, mx: 0.25 }} />
) : (
<CloseIcon color="error" sx={{ fontSize: 16, mx: 0.25 }} />
)}
PSRAM)
</Typography>
</Grid>
</Grid>
</Box>
{data.recovery_version !== undefined && (
<>
<Typography color="secondary">{LL.RECOVERY_APP()}</Typography>
<Box
sx={{
display: 'flex',
alignItems: 'center',
minWidth: 0
}}
>
<Typography component="span">
{data.recovery_version || LL.RECOVERY_NOT_INSTALLED()}
</Typography>
{!data.recovery_version && me.admin && internetLive && (
<Button
startIcon={<DownloadIcon />}
sx={{ ml: 3 }}
variant="outlined"
size="small"
disabled={findingRecovery}
onClick={() => void installRecovery()}
>
{LL.INSTALL()}
</Button>
)}
{data.recovery_version && me.admin && (
<Button
startIcon={<RestartAltIcon />}
sx={{ ml: 3 }}
variant="outlined"
size="small"
onClick={() => {
const version = data.recovery_version;
if (version) {
showPartitionDialog(version, 'boot', '');
}
}}
>
{LL.RESTART_INTO_RECOVERY()}
</Button>
)}
</Box>
</>
)}
</Box>
{internetLive ? (
<>
@@ -729,83 +949,103 @@ const Version = () => {
{LL.AVAILABLE_VERSION()}
</Typography>
<Grid
container
direction="row"
rowSpacing={1}
<Box
sx={{
justifyContent: 'flex-start',
alignItems: 'baseline'
display: 'grid',
gridTemplateColumns: 'auto 1fr',
columnGap: 3,
rowGap: 0.75,
alignItems: 'center'
}}
>
{otherPartitions.length > 0 && data.developer_mode && (
<Typography color="secondary">{LL.STABLE()}</Typography>
<Box
sx={{
display: 'flex',
alignItems: 'center',
flexWrap: 'wrap',
minWidth: 0
}}
>
<Typography component="span">{latestVersion?.version}</Typography>
<IconButton
size="small"
onClick={() => setShowVersionInfo(1)}
aria-label={LL.FIRMWARE_VERSION_INFO()}
sx={{ p: 0.5 }}
>
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
</IconButton>
{showButtons(false)}
</Box>
<Typography color="secondary">{LL.DEVELOPMENT()}</Typography>
<Box
sx={{
display: 'flex',
alignItems: 'center',
flexWrap: 'wrap',
minWidth: 0
}}
>
<Typography component="span">{latestDevVersion?.version}</Typography>
<IconButton
size="small"
onClick={() => setShowVersionInfo(2)}
aria-label={LL.FIRMWARE_VERSION_INFO()}
sx={{ p: 0.5 }}
>
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
</IconButton>
{showButtons(true)}
</Box>
{data.recovery_version && latestRecovery && (
<>
<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>
<Typography color="secondary">{LL.RECOVERY_APP()}</Typography>
<Box
sx={{
display: 'flex',
alignItems: 'center',
flexWrap: 'wrap',
minWidth: 0
}}
>
<Typography component="span">
{latestRecovery.version}
</Typography>
{isNewerVersion(
latestRecovery.version,
data.recovery_version
) ? (
me.admin && (
<Button
sx={{ ml: 0 }}
startIcon={<DownloadIcon />}
sx={{ ml: 3 }}
variant="outlined"
color="success"
size="small"
onClick={() =>
showPartitionDialog(
partition.version,
partition.partition,
partition.install_date ?? ''
)
}
disabled={findingRecovery}
onClick={() => void installRecovery()}
>
{LL.INSTALL()}
</Button>
</Typography>
))}
</Grid>
)
) : (
<>
<CheckIcon
color="success"
sx={{ verticalAlign: 'middle', ml: 0.5, mr: 0.5 }}
/>
<span style={{ color: '#66bb6a', fontSize: '0.8em' }}>
{LL.LATEST_VERSION(LL.RECOVERY_APP())}
</span>
</>
)}
</Box>
</>
)}
<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>
</Box>
</>
) : (
<Typography sx={{ mt: 2 }} color="warning">
@@ -848,7 +1088,11 @@ const Version = () => {
<Typography sx={{ pt: 2, pb: 2 }} variant="h6" color="primary">
{LL.UPLOAD()}
</Typography>
<SingleUpload text={LL.UPLOAD_FIRMWARE_TEXT()} doRestart={doRestart} />
<SingleUpload
text={LL.UPLOAD_FIRMWARE_TEXT()}
doRestart={doRestart}
onRecoveryInstalled={() => void loadData()}
/>
</>
)}
</Box>
@@ -899,7 +1143,7 @@ const Version = () => {
{LL.CANCEL()}
</Button>
<Button
startIcon={<PowerSettingsNewIcon />}
startIcon={<RestartAltIcon />}
variant="outlined"
onClick={doRestart}
color="success"
@@ -931,7 +1175,7 @@ const Version = () => {
</Button>
)}
<Button
startIcon={<PowerSettingsNewIcon />}
startIcon={<RestartAltIcon />}
variant="outlined"
onClick={handleRestartClick}
color="success"
-12
View File
@@ -1,5 +1,3 @@
import { useContext } from 'react';
import AccessTimeIcon from '@mui/icons-material/AccessTime';
import DeviceHubIcon from '@mui/icons-material/DeviceHub';
import DirectionsBusIcon from '@mui/icons-material/DirectionsBus';
@@ -24,7 +22,6 @@ import { useRequest } from 'alova/client';
import { busConnectionStatus } from 'app/main/types';
import { FormLoader, SectionContent, useLayoutTitle } from 'components';
import ListMenuItem from 'components/layout/ListMenuItem';
import { AuthenticatedContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react';
import { NTPSyncStatus, NetworkConnectionStatus, SystemStatusCodes } from 'types';
import { useInterval } from 'utils';
@@ -56,8 +53,6 @@ const SystemStatus = () => {
useLayoutTitle(LL.STATUS_OF(''));
const { me } = useContext(AuthenticatedContext);
const { data, send: loadData, error } = useRequest(readSystemStatus);
useInterval(() => {
@@ -211,7 +206,6 @@ const SystemStatus = () => {
</ListItem>
<ListMenuItem
disabled={!me.admin}
icon={MemoryIcon}
bgcolor="#68374d"
label={LL.HARDWARE()}
@@ -220,7 +214,6 @@ const SystemStatus = () => {
/>
<ListMenuItem
disabled={!me.admin}
icon={DirectionsBusIcon}
bgcolor={busStatusHighlight}
label={LL.DATA_TRAFFIC()}
@@ -229,7 +222,6 @@ const SystemStatus = () => {
/>
<ListMenuItem
disabled={!me.admin}
icon={
data.network_status === NetworkConnectionStatus.WIFI_STATUS_CONNECTED
? WifiIcon
@@ -242,7 +234,6 @@ const SystemStatus = () => {
/>
<ListMenuItem
disabled={!me.admin}
icon={DeviceHubIcon}
bgcolor={activeHighlight(data.mqtt_status)}
label="MQTT"
@@ -251,7 +242,6 @@ const SystemStatus = () => {
/>
<ListMenuItem
disabled={!me.admin}
icon={AccessTimeIcon}
bgcolor={ntpStatusHighlight}
label="NTP"
@@ -260,7 +250,6 @@ const SystemStatus = () => {
/>
<ListMenuItem
disabled={!me.admin}
icon={SettingsInputAntennaIcon}
bgcolor={activeHighlight(data.ap_status)}
label={LL.ACCESS_POINT(0)}
@@ -269,7 +258,6 @@ const SystemStatus = () => {
/>
<ListMenuItem
disabled={!me.admin}
icon={LogoDevIcon}
bgcolor="#40828f"
label={LL.LOG_OF(LL.SYSTEM(0))}
+16 -3
View File
@@ -14,7 +14,12 @@ import { useInterval } from 'utils';
import { LinearProgressWithLabel } from '../../components/upload/LinearProgressWithLabel';
const SystemMonitor = () => {
interface SystemMonitorProps {
// when set the page isn't reloaded when done, e.g. when the firmware was installed without a restart
onDone?: (success: boolean) => void;
}
const SystemMonitor = ({ onDone }: SystemMonitorProps) => {
const [errorMessage, setErrorMessage] = useState<string>();
const hasInitialized = useRef(false);
const { LL } = useI18nContext();
@@ -42,7 +47,11 @@ const SystemMonitor = () => {
event.data.status === SystemStatusCodes.SYSTEM_STATUS_NORMAL ||
event.data.status === undefined
) {
document.location.href = '/';
if (onDone) {
onDone(true);
} else {
document.location.href = '/';
}
} else if (
event.data.status === SystemStatusCodes.SYSTEM_STATUS_ERROR_UPLOAD
) {
@@ -80,7 +89,11 @@ const SystemMonitor = () => {
const onCancel = async () => {
setErrorMessage(undefined);
await setSystemStatus(String(SystemStatusCodes.SYSTEM_STATUS_NORMAL));
document.location.href = '/';
if (onDone) {
onDone(false);
} else {
document.location.href = '/';
}
};
return (
@@ -14,10 +14,15 @@ import { LinearProgressWithLabel } from './LinearProgressWithLabel';
interface SingleUploadProps {
doRestart: () => void;
onRecoveryInstalled?: () => void;
text: string;
}
const SingleUpload = ({ doRestart, text }: SingleUploadProps) => {
const SingleUpload = ({
doRestart,
onRecoveryInstalled,
text
}: SingleUploadProps) => {
const [md5, setMd5] = useState<string>();
const [file, setFile] = useState<File>();
const { LL } = useI18nContext();
@@ -42,6 +47,12 @@ const SingleUpload = ({ doRestart, text }: SingleUploadProps) => {
setMd5((payload as { md5: string }).md5);
toast.success(LL.UPLOAD_MD5_RECEIVED());
setFile(undefined);
} else if (payload && typeof payload === 'object' && 'recovery' in payload) {
// written to the factory partition, EMS-ESP keeps running
toast.success(LL.RECOVERY_INSTALLED());
setMd5(undefined);
setFile(undefined);
onRecoveryInstalled?.();
} else {
if (payload && typeof payload === 'object' && 'md5_ok' in payload) {
toast.success(LL.UPLOAD_MD5_MATCHED());
+10 -3
View File
@@ -42,6 +42,7 @@ const cs: Translation = {
APPLY_CHANGES: 'Použít změny ({0})',
UPDATE: 'Aktualizovat',
REMOVE: 'Odebrat',
RESET: 'Obnovit',
PROBLEM_UPDATING: 'Problém s aktualizací',
PROBLEM_LOADING: 'Problém s načítáním',
ANALOG_SENSOR: 'Analogový senzor',
@@ -141,6 +142,8 @@ const cs: Translation = {
CUSTOMIZATIONS_HELP_6: 'odstranit z paměti',
SELECT_DEVICE: 'Vyberte zařízení',
SET_ALL: 'nastavit vše',
MODIFIED: 'upravené',
RESET_SELECTED: 'Obnovit vybrané',
OPTIONS: 'Možnosti',
NAME: 'Název',
CUSTOMIZATIONS_RESET: 'Opravdu chcete odstranit všechna přizpůsobení?',
@@ -235,7 +238,7 @@ const cs: Translation = {
TIME_ZONE: 'Časová zóna',
ACCESS_POINT: 'Přístupový bod',
AP_PROVIDE: 'Povolit přístupový bod',
AP_PROVIDE_TEXT_2: 'Když je síťové připojení stratené',
AP_PROVIDE_TEXT_2: 'Když není síťové připojení nalezeno',
AP_PROVIDE_TEXT_3: 'Nikdy',
AP_PREFERRED_CHANNEL: 'Preferovaný kanál',
AP_HIDE_SSID: 'Skrýt SSID',
@@ -336,7 +339,6 @@ const cs: Translation = {
SWITCH_RELEASE_TYPE: 'Přepnout na {0} verzi',
FIRMWARE_VERSION_INFO: 'Informace o verzi firmwaru',
USER_PROFILE: 'Uživatelský profil',
STORED_VERSIONS: 'Uložené verze',
ONLINE_HELP: 'online nápověda',
UPGRADE_IMPORTANT_MESSAGES: 'Aktualizovat důležité zprávy',
UPGRADE_IMPORTANT_MESSAGES_1: 'Tato aktualizace vyžaduje obnovení továrního nastavení. Ujistěte se, že nejprve stáhnete systémovou zálohu před pokračováním a poté nahrajte tento soubor po instalaci nové verze.',
@@ -352,7 +354,12 @@ const cs: Translation = {
UPLOAD_BACKUP_TEXT: 'Klikněte, abyste vybrali, nebo drop soubor zálohy (.json) sem',
UPLOAD_FIRMWARE_TEXT: 'Klikněte, abyste vybrali, nebo drop soubor firmware .bin/.md5 sem',
UPLOAD_MD5_RECEIVED: 'Digest přijat, nyní nahrajte firmware .bin',
UPLOAD_MD5_MATCHED: 'MD5 souhlasí, firmware se instaluje...'
UPLOAD_MD5_MATCHED: 'MD5 souhlasí, firmware se instaluje...',
RECOVERY_APP: 'Aplikace pro obnovu',
RECOVERY_NOT_INSTALLED: 'Nenainstalováno',
RECOVERY_INSTALLED: 'Firmware pro obnovu nainstalován',
RESTART_INTO_RECOVERY: 'Restartovat do aplikace pro obnovu',
RECOVERY_NOT_FOUND: 'Aplikace pro obnovu nebyla online nalezena'
};
export default cs;
+10 -3
View File
@@ -42,6 +42,7 @@ const de: Translation = {
APPLY_CHANGES: 'Änderungen anwenden ({0})',
UPDATE: 'Aktualisieren',
REMOVE: 'Entfernen',
RESET: 'Zurücksetzen',
PROBLEM_UPDATING: 'Problem beim Aktualisieren',
PROBLEM_LOADING: 'Problem beim Laden',
ANALOG_SENSOR: 'Analogsensor',
@@ -141,6 +142,8 @@ const de: Translation = {
CUSTOMIZATIONS_HELP_6: 'Aus dem Speicher entfernen',
SELECT_DEVICE: 'Wählen Sie ein Gerät aus',
SET_ALL: 'Setzen Sie alle',
MODIFIED: 'geändert',
RESET_SELECTED: 'Auswahl zurücksetzen',
OPTIONS: 'Optionen',
NAME: 'Name',
CUSTOMIZATIONS_RESET: 'Möchten Sie wirklich alle Anpassungen entfernen?',
@@ -235,7 +238,7 @@ const de: Translation = {
TIME_ZONE: 'Zeitzone',
ACCESS_POINT: 'Zugangspunkt',
AP_PROVIDE: 'Aktiviere Zugangspunkt',
AP_PROVIDE_TEXT_2: 'Wenn Netzwerkverbindung verloren geht',
AP_PROVIDE_TEXT_2: 'Wenn keine Netzwerkverbindung gefunden wird',
AP_PROVIDE_TEXT_3: 'Niemals',
AP_PREFERRED_CHANNEL: 'Bevorzugter Kanal',
AP_HIDE_SSID: 'Verstecke SSID',
@@ -336,7 +339,6 @@ const de: Translation = {
SWITCH_RELEASE_TYPE: 'Zum {0}-Release wechseln',
FIRMWARE_VERSION_INFO: 'Firmware-Versionsinformation',
USER_PROFILE: 'Benutzerprofil',
STORED_VERSIONS: 'Gespeicherte Versionen',
ONLINE_HELP: 'Online-Hilfe',
UPGRADE_IMPORTANT_MESSAGES: 'Wichtige Nachrichten aktualisieren',
UPGRADE_IMPORTANT_MESSAGES_1: 'Für diese Aktualisierung ist ein Werksreset erforderlich. Stellen Sie sicher, dass Sie zuerst eine Systemsicherung herunterladen, bevor Sie fortfahren, und laden Sie diese Datei dann nach der Installation der neuen Version hoch.',
@@ -352,7 +354,12 @@ const de: Translation = {
UPLOAD_BACKUP_TEXT: 'Klicken Sie, um eine Sicherungsdatei (.json) auszuwählen, oder drop ein',
UPLOAD_FIRMWARE_TEXT: 'Klicken Sie, um eine Firmware-Datei (.bin/.md5) auszuwählen, oder drop ein',
UPLOAD_MD5_RECEIVED: 'Prüfsumme empfangen, jetzt die Firmware-Datei (.bin) hochladen',
UPLOAD_MD5_MATCHED: 'MD5 stimmt überein, Firmware wird installiert...'
UPLOAD_MD5_MATCHED: 'MD5 stimmt überein, Firmware wird installiert...',
RECOVERY_APP: 'Wiederherstellungs-App',
RECOVERY_NOT_INSTALLED: 'Nicht installiert',
RECOVERY_INSTALLED: 'Wiederherstellungs-Firmware installiert',
RESTART_INTO_RECOVERY: 'Neustart in die Wiederherstellungs-App',
RECOVERY_NOT_FOUND: 'Wiederherstellungs-App online nicht gefunden'
};
export default de;
+10 -3
View File
@@ -42,6 +42,7 @@ const en: Translation = {
APPLY_CHANGES: 'Apply Changes ({0})',
UPDATE: 'Update',
REMOVE: 'Remove',
RESET: 'Reset',
PROBLEM_UPDATING: 'Problem updating',
PROBLEM_LOADING: 'Problem loading',
ANALOG_SENSOR: 'Analog Sensor',
@@ -141,6 +142,8 @@ const en: Translation = {
CUSTOMIZATIONS_HELP_6: 'remove from memory',
SELECT_DEVICE: 'Select a device',
SET_ALL: 'set all',
MODIFIED: 'modified',
RESET_SELECTED: 'Reset selected',
OPTIONS: 'Options',
NAME: 'Name',
CUSTOMIZATIONS_RESET: 'Are you sure you want remove all the customizations?',
@@ -235,7 +238,7 @@ const en: Translation = {
TIME_ZONE: 'Time Zone',
ACCESS_POINT: 'Access Point',
AP_PROVIDE: 'Enable Access Point',
AP_PROVIDE_TEXT_2: 'When network connection is lost',
AP_PROVIDE_TEXT_2: 'When network connection not found',
AP_PROVIDE_TEXT_3: 'Never',
AP_PREFERRED_CHANNEL: 'Preferred Channel',
AP_HIDE_SSID: 'Hide SSID',
@@ -336,7 +339,6 @@ const en: Translation = {
SWITCH_RELEASE_TYPE: 'Switch to {0} release',
FIRMWARE_VERSION_INFO: 'Firmware Version Information',
USER_PROFILE: 'User Profile',
STORED_VERSIONS: 'Stored Versions',
ONLINE_HELP: 'online help',
UPGRADE_IMPORTANT_MESSAGES: 'Upgrade Important Messages',
UPGRADE_IMPORTANT_MESSAGES_1: 'This upgrade requires a factory reset. Make sure you first download a System Backup before continuing, and then upload this file after the new version is installed.',
@@ -352,7 +354,12 @@ const en: Translation = {
UPLOAD_BACKUP_TEXT: 'Click to select, or drop a backup file (.json) here',
UPLOAD_FIRMWARE_TEXT: 'Click to select, or drop a firmware .bin/.md5 file here',
UPLOAD_MD5_RECEIVED: 'Digest received, now upload the firmware .bin file',
UPLOAD_MD5_MATCHED: 'MD5 matches, installing firmware...'
UPLOAD_MD5_MATCHED: 'MD5 matches, installing firmware...',
RECOVERY_APP: 'Recovery App',
RECOVERY_NOT_INSTALLED: 'Not installed',
RECOVERY_INSTALLED: 'Recovery firmware installed',
RESTART_INTO_RECOVERY: 'Restart into Recovery App',
RECOVERY_NOT_FOUND: 'Recovery App not found online'
};
export default en;
+10 -3
View File
@@ -42,6 +42,7 @@ const fr: Translation = {
APPLY_CHANGES: 'Appliquer les changements ({0})',
UPDATE: 'Update',
REMOVE: 'Enlever',
RESET: 'Réinitialiser',
PROBLEM_UPDATING: 'Problème lors de la mise à jour',
PROBLEM_LOADING: 'Problème lors du chargement',
ANALOG_SENSOR: 'Capteur analogique',
@@ -141,6 +142,8 @@ const fr: Translation = {
CUSTOMIZATIONS_HELP_6: 'supprimer de la mémoire',
SELECT_DEVICE: 'Sélectionnez un appareil',
SET_ALL: 'tout régler',
MODIFIED: 'modifié',
RESET_SELECTED: 'Réinitialiser la sélection',
OPTIONS: 'Options',
NAME: 'Nom',
CUSTOMIZATIONS_RESET: 'Êtes-vous sûr de vouloir supprimer toutes les personnalisations?',
@@ -235,7 +238,7 @@ const fr: Translation = {
TIME_ZONE: 'Fuseau horaire',
ACCESS_POINT: "Point d'accès",
AP_PROVIDE: "Activer le Point d'Accès",
AP_PROVIDE_TEXT_2: 'quand la connexion réseau est perdue',
AP_PROVIDE_TEXT_2: 'quand la connexion réseau n\'est pas trouvée',
AP_PROVIDE_TEXT_3: 'jamais',
AP_PREFERRED_CHANNEL: 'Canal préféré',
AP_HIDE_SSID: 'Cacher le SSID',
@@ -336,7 +339,6 @@ const fr: Translation = {
SWITCH_RELEASE_TYPE: 'Passer à la version {0}',
FIRMWARE_VERSION_INFO: 'Informations sur la version du firmware',
USER_PROFILE: 'Profil utilisateur',
STORED_VERSIONS: 'Versions stockées',
ONLINE_HELP: 'aide en ligne',
UPGRADE_IMPORTANT_MESSAGES: 'Mettre à jour les messages importants',
UPGRADE_IMPORTANT_MESSAGES_1: 'Cette mise à jour nécessite une réinitialisation de fabrique. Assurez-vous de télécharger une sauvegarde système avant de continuer, et de la charger après l\'installation de la nouvelle version.',
@@ -352,7 +354,12 @@ const fr: Translation = {
UPLOAD_BACKUP_TEXT: 'Cliquez pour sélectionner, ou drop un fichier de sauvegarde (.json) ici',
UPLOAD_FIRMWARE_TEXT: 'Cliquez pour sélectionner, ou drop un fichier firmware .bin/.md5 ici',
UPLOAD_MD5_RECEIVED: 'Empreinte reçue, téléversez maintenant le fichier firmware .bin',
UPLOAD_MD5_MATCHED: 'MD5 concordant, installation du firmware...'
UPLOAD_MD5_MATCHED: 'MD5 concordant, installation du firmware...',
RECOVERY_APP: 'Application de récupération',
RECOVERY_NOT_INSTALLED: 'Non installé',
RECOVERY_INSTALLED: 'Firmware de récupération installé',
RESTART_INTO_RECOVERY: 'Redémarrer dans l\'application de récupération',
RECOVERY_NOT_FOUND: 'Application de récupération introuvable en ligne'
};
export default fr;
+10 -3
View File
@@ -42,6 +42,7 @@ const it: Translation = {
APPLY_CHANGES: 'Applica Cambiamenti ({0})',
UPDATE: 'Update',
REMOVE: 'Elimina',
RESET: 'Reimposta',
PROBLEM_UPDATING: 'Problema aggiornamento',
PROBLEM_LOADING: 'Problema caricamento',
ANALOG_SENSOR: 'Sensore Analogico',
@@ -141,6 +142,8 @@ const it: Translation = {
CUSTOMIZATIONS_HELP_6: 'rimuovi dalla memoria',
SELECT_DEVICE: 'Seleziona un dispositivo',
SET_ALL: 'imposta tutto',
MODIFIED: 'modificato',
RESET_SELECTED: 'Reimposta selezionati',
OPTIONS: 'Opzioni',
NAME: 'Nome',
CUSTOMIZATIONS_RESET: 'Sei sicuro di voler rimuovere tutte le personalizzazioni?',
@@ -235,7 +238,7 @@ const it: Translation = {
TIME_ZONE: 'Fuso orario',
ACCESS_POINT: 'Access Point',
AP_PROVIDE: 'Abilita Access Point',
AP_PROVIDE_TEXT_2: 'quando la connessione di rete è persa',
AP_PROVIDE_TEXT_2: 'quando la connessione di rete non è trovata',
AP_PROVIDE_TEXT_3: 'mai',
AP_PREFERRED_CHANNEL: 'Canale preferito',
AP_HIDE_SSID: 'Nascondi SSID',
@@ -336,7 +339,6 @@ const it: Translation = {
SWITCH_RELEASE_TYPE: 'Cambia in {0} rilascio',
FIRMWARE_VERSION_INFO: 'Informazioni sulla versione del firmware',
USER_PROFILE: 'Profilo utente',
STORED_VERSIONS: 'Versioni memorizzate',
ONLINE_HELP: 'aiuto online',
UPGRADE_IMPORTANT_MESSAGES: 'Aggiorna Messaggi Importanti',
UPGRADE_IMPORTANT_MESSAGES_1: 'Questa aggiornamento richiede un ripristino di fabbrica. Assicurati di prima scaricare un backup del sistema prima di continuare, e poi caricare questo file dopo l\'installazione della nuova versione.',
@@ -352,7 +354,12 @@ const it: Translation = {
UPLOAD_BACKUP_TEXT: 'Klik per selezionare, o drop un file di backup (.json) qui',
UPLOAD_FIRMWARE_TEXT: 'Klik per selezionare, o drop un file firmware .bin/.md5 qui',
UPLOAD_MD5_RECEIVED: 'Digest ricevuto, ora caricare il file firmware .bin',
UPLOAD_MD5_MATCHED: 'MD5 corrispondente, installazione firmware...'
UPLOAD_MD5_MATCHED: 'MD5 corrispondente, installazione firmware...',
RECOVERY_APP: 'App di ripristino',
RECOVERY_NOT_INSTALLED: 'Non installato',
RECOVERY_INSTALLED: 'Firmware di ripristino installato',
RESTART_INTO_RECOVERY: 'Riavvia nell\'app di ripristino',
RECOVERY_NOT_FOUND: 'App di ripristino non trovata online'
};
export default it;
+10 -3
View File
@@ -42,6 +42,7 @@ const nl: Translation = {
APPLY_CHANGES: 'Aanpassen ({0})',
UPDATE: 'Update',
REMOVE: 'Verwijderen',
RESET: 'Resetten',
PROBLEM_UPDATING: 'Probleem met updaten',
PROBLEM_LOADING: 'Probleem met laden',
ANALOG_SENSOR: 'Analoge sensor',
@@ -141,6 +142,8 @@ const nl: Translation = {
CUSTOMIZATIONS_HELP_6: 'verwijderen van memory',
SELECT_DEVICE: 'Selecteer een apparaat',
SET_ALL: 'Alles aanzetten',
MODIFIED: 'gewijzigd',
RESET_SELECTED: 'Selectie resetten',
OPTIONS: 'Opties',
NAME: 'Naam',
CUSTOMIZATIONS_RESET: 'Weet je zeker dat je alle custom aanpassingen wilt verwijderen?',
@@ -235,7 +238,7 @@ const nl: Translation = {
TIME_ZONE: 'Tijdzone',
ACCESS_POINT: 'Access Point',
AP_PROVIDE: 'Activeer Access Point',
AP_PROVIDE_TEXT_2: 'als netwerk verbinding verloren gaat',
AP_PROVIDE_TEXT_2: 'als netwerkverbinding niet gevonden wordt',
AP_PROVIDE_TEXT_3: 'nooit',
AP_PREFERRED_CHANNEL: 'Voorkeurskanaal',
AP_HIDE_SSID: 'SSID verbergen',
@@ -336,7 +339,6 @@ const nl: Translation = {
SWITCH_RELEASE_TYPE: 'Switch naar {0} release',
FIRMWARE_VERSION_INFO: 'Informatie over firmwareversie',
USER_PROFILE: 'Gebruikersprofiel',
STORED_VERSIONS: 'Opgeslagen versies',
ONLINE_HELP: 'online help',
UPGRADE_IMPORTANT_MESSAGES: 'Upgrade Belangrijke Berichten',
UPGRADE_IMPORTANT_MESSAGES_1: 'Deze upgrade vereist een fabrieksinstelling. Zorg ervoor dat u eerst een Systeem Backup download voordat u doorgaat, en upload deze file na de installatie van de nieuwe versie.',
@@ -352,7 +354,12 @@ const nl: Translation = {
UPLOAD_BACKUP_TEXT: 'Klik voor selecteren, of drop een back-upbestand (.json) hier',
UPLOAD_FIRMWARE_TEXT: 'Klik voor selecteren, of drop een firmware .bin/.md5 bestand hier',
UPLOAD_MD5_RECEIVED: 'Digest ontvangen, upload nu het firmware .bin-bestand',
UPLOAD_MD5_MATCHED: 'MD5 komt overeen, firmware wordt geïnstalleerd...'
UPLOAD_MD5_MATCHED: 'MD5 komt overeen, firmware wordt geïnstalleerd...',
RECOVERY_APP: 'Herstel-app',
RECOVERY_NOT_INSTALLED: 'Niet geïnstalleerd',
RECOVERY_INSTALLED: 'Herstelfirmware geïnstalleerd',
RESTART_INTO_RECOVERY: 'Herstart naar de herstel-app',
RECOVERY_NOT_FOUND: 'Herstel-app niet online gevonden'
};
export default nl;
+10 -3
View File
@@ -42,6 +42,7 @@ const no: Translation = {
APPLY_CHANGES: 'Utfør endringer({0})',
UPDATE: 'Oppdater',
REMOVE: 'Fjern',
RESET: 'Tilbakestill',
PROBLEM_UPDATING: 'Problem med oppdatering',
PROBLEM_LOADING: 'Problem med opplasting',
ANALOG_SENSOR: 'Analog Sensor',
@@ -141,6 +142,8 @@ const no: Translation = {
CUSTOMIZATIONS_HELP_6: 'fjern fra minnet',
SELECT_DEVICE: 'Velg en enhet',
SET_ALL: 'sett alle',
MODIFIED: 'endret',
RESET_SELECTED: 'Tilbakestill valgte',
OPTIONS: 'Alternativ',
NAME: 'Navn',
CUSTOMIZATIONS_RESET: 'Er du sikker på att du vil fjerne tilpassninger?',
@@ -235,7 +238,7 @@ const no: Translation = {
TIME_ZONE: 'Tidssone',
ACCESS_POINT: 'Aksesspunkt',
AP_PROVIDE: 'Aktiver Aksesspunkt',
AP_PROVIDE_TEXT_2: 'når nettverksforbindelsen er utilgjengelig',
AP_PROVIDE_TEXT_2: 'når nettverksforbindelsen ikke finnes',
AP_PROVIDE_TEXT_3: 'aldri',
AP_PREFERRED_CHANNEL: 'Foretrukket kanal',
AP_HIDE_SSID: 'Skjul SSID',
@@ -336,7 +339,6 @@ const no: Translation = {
SWITCH_RELEASE_TYPE: 'Bytt til {0} utgivelse',
FIRMWARE_VERSION_INFO: 'Informasjon om firmwareversjon',
USER_PROFILE: 'Brukerprofil',
STORED_VERSIONS: 'Lagret versjoner',
ONLINE_HELP: 'online hjelp',
UPGRADE_IMPORTANT_MESSAGES: 'Oppdater viktige meldinger',
UPGRADE_IMPORTANT_MESSAGES_1: 'Denne oppdateringen krever en fabriksinstilling. Sørg for at du først lastet ned en System Backup før du fortsetter, og last denne filen etter at den nye versjonen er installert.',
@@ -352,7 +354,12 @@ const no: Translation = {
UPLOAD_BACKUP_TEXT: 'Klikk for å velge, eller drop en sikkerhetskopi (.json) her',
UPLOAD_FIRMWARE_TEXT: 'Klikk for å velge, eller drop en firmware .bin/.md5 her',
UPLOAD_MD5_RECEIVED: 'MD5 mottatt, last nå opp firmware .bin-filen',
UPLOAD_MD5_MATCHED: 'MD5 stemmer, firmware installeres...'
UPLOAD_MD5_MATCHED: 'MD5 stemmer, firmware installeres...',
RECOVERY_APP: 'Gjenopprettingsapp',
RECOVERY_NOT_INSTALLED: 'Ikke installert',
RECOVERY_INSTALLED: 'Gjenopprettingsfirmware installert',
RESTART_INTO_RECOVERY: 'Start på nytt i gjenopprettingsappen',
RECOVERY_NOT_FOUND: 'Fant ikke gjenopprettingsappen på nettet'
};
export default no;
+10 -3
View File
@@ -42,6 +42,7 @@ const pl: BaseTranslation = {
APPLY_CHANGES: 'Zapisz zmiany ({0})',
UPDATE: 'Zmień',
REMOVE: 'Usuń',
RESET: 'Resetuj',
PROBLEM_UPDATING: 'Problem z uaktualnieniem!',
PROBLEM_LOADING: 'Problem z załadowaniem!',
ANALOG_SENSOR: '{{u|u||ustawienia u|ustawień u}}rządzeni{{a podłączonego do EMS-ESP|e||a podłączonego do EMS-ESP|a podłączonego do EMS-ESP}}',
@@ -141,6 +142,8 @@ const pl: BaseTranslation = {
CUSTOMIZATIONS_HELP_6: 'usuń z pamięci',
SELECT_DEVICE: 'wybierz urządzenie',
SET_ALL: 'Ustaw wszystko jako',
MODIFIED: 'zmienione',
RESET_SELECTED: 'Resetuj zaznaczone',
OPTIONS: 'Opcje',
NAME: '{{Nazwa|nazwa|}}',
CUSTOMIZATIONS_RESET: 'Na pewno chcesz usunąć wszystkie personalizacje?',
@@ -235,7 +238,7 @@ const pl: BaseTranslation = {
TIME_ZONE: 'Strefa czasowa',
ACCESS_POINT: '{{Punkt|punktu|}} {{dostępowy|dostępowego|}}',
AP_PROVIDE: 'Punkt dostępowy',
AP_PROVIDE_TEXT_2: 'aktywny jeśli brak połączenia z siecią',
AP_PROVIDE_TEXT_2: 'gdy nie znaleziono połączenia z siecią',
AP_PROVIDE_TEXT_3: 'nieaktywny',
AP_PREFERRED_CHANNEL: 'Preferowany kanał',
AP_HIDE_SSID: 'Ukryj SSID',
@@ -336,7 +339,6 @@ const pl: BaseTranslation = {
SWITCH_RELEASE_TYPE: 'Zmień na {0} wydanie',
FIRMWARE_VERSION_INFO: 'Informacje o wersji firmware',
USER_PROFILE: 'Profil użytkownika',
STORED_VERSIONS: 'Zapisane wersje',
ONLINE_HELP: 'pomoc online',
UPGRADE_IMPORTANT_MESSAGES: 'Aktualizuj ważne wiadomości',
UPGRADE_IMPORTANT_MESSAGES_1: 'Ta aktualizacja wymaga resetu fabrycznego. Upewnij się, że najpierw pobierzesz kopię zapasową systemu przed kontynuowaniem, a następnie przesuń tę plik po zainstalowaniu nowej wersji.',
@@ -352,7 +354,12 @@ const pl: BaseTranslation = {
UPLOAD_BACKUP_TEXT: 'Kliknij, aby wybrać, lub drop plik kopii zapasowej (.json) tutaj',
UPLOAD_FIRMWARE_TEXT: 'Kliknij, aby wybrać, lub drop plik firmware .bin/.md5 tutaj',
UPLOAD_MD5_RECEIVED: 'Suma MD5 odebrana, teraz wgraj plik firmware .bin',
UPLOAD_MD5_MATCHED: 'MD5 zgodne, instalowanie firmware...'
UPLOAD_MD5_MATCHED: 'MD5 zgodne, instalowanie firmware...',
RECOVERY_APP: 'Aplikacja odzyskiwania',
RECOVERY_NOT_INSTALLED: 'Nie zainstalowano',
RECOVERY_INSTALLED: 'Zainstalowano firmware odzyskiwania',
RESTART_INTO_RECOVERY: 'Uruchom ponownie w aplikacji odzyskiwania',
RECOVERY_NOT_FOUND: 'Nie znaleziono aplikacji odzyskiwania online'
};
export default pl;
+11 -4
View File
@@ -42,6 +42,7 @@ const sk: Translation = {
APPLY_CHANGES: 'Aplikovať zmeny ({0})',
UPDATE: 'Aktualizovať',
REMOVE: 'Odstrániť',
RESET: 'Obnoviť',
PROBLEM_UPDATING: 'Problém s aktualizáciou',
PROBLEM_LOADING: 'Problém s načítaním',
ANALOG_SENSOR: 'Analógový snímač',
@@ -141,6 +142,8 @@ const sk: Translation = {
CUSTOMIZATIONS_HELP_6: 'odstrániť z pamäte',
SELECT_DEVICE: 'Zvoliť zariadenie',
SET_ALL: 'nastaviť všetko',
MODIFIED: 'upravené',
RESET_SELECTED: 'Obnoviť vybrané',
OPTIONS: 'Možnosti',
NAME: 'Názov',
CUSTOMIZATIONS_RESET: 'Naozaj chcete odstrániť všetky prispôsobenia?',
@@ -235,7 +238,7 @@ const sk: Translation = {
TIME_ZONE: 'Časová zóna',
ACCESS_POINT: 'Prístupový bod',
AP_PROVIDE: 'Povoliť prístupový bod',
AP_PROVIDE_TEXT_2: 'keď je sieťové pripojenie stratené',
AP_PROVIDE_TEXT_2: 'keď sieťové pripojenie nie je nájdené',
AP_PROVIDE_TEXT_3: 'nikdy',
AP_PREFERRED_CHANNEL: 'Preferovaný kanál',
AP_HIDE_SSID: 'Skryť SSID',
@@ -336,7 +339,6 @@ const sk: Translation = {
SWITCH_RELEASE_TYPE: 'Prepnúť na {0} verziu',
FIRMWARE_VERSION_INFO: 'Informácie o verzii firmware',
USER_PROFILE: 'Profil používateľa',
STORED_VERSIONS: 'Uložené verzie',
ONLINE_HELP: 'online pomoc',
UPGRADE_IMPORTANT_MESSAGES: 'Aktualizovať dôležité správy',
UPGRADE_IMPORTANT_MESSAGES_1: 'Táto aktualizácia vyžaduje reštart základných nastavení. Uistite sa, že najprv stiahnete systémovú zálohu pred pokračovaním, a potom nahrajte tento súbor po instalácii novej verzie.',
@@ -345,14 +347,19 @@ const sk: Translation = {
TEST_EMAIL_SUCCESSFUL: 'Test email bol úspešne odoslaný',
SYSTEM_NAME: 'Názov systému',
COMMANDS: 'Príkazy',
COMMANDS_HELP_1: 'Definujte vlastné príkazy pre magistrali EMS',
COMMANDS_HELP_1: 'Nižšie definujte opakovane použiteľné pomenované príkazy. Tieto môžu byť spustené z konzoly, API/MQTT alebo odkazované plánovačom',
COMMAND_EXECUTED: 'Príkaz vykonaný úspešne',
RUN: 'Volať príkaz',
INTERNET_CONNECTION_CHECK: 'Kontrola online pre dostupné verzie',
UPLOAD_BACKUP_TEXT: 'Kliknite, aby ste vybrali, alebo drop súbor zálohy (.json) sem',
UPLOAD_FIRMWARE_TEXT: 'Kliknite, aby ste vybrali, alebo drop súbor firmware .bin/.md5 sem',
UPLOAD_MD5_RECEIVED: 'Suma MD5 prijatá, teraz nahrajte firmware .bin',
UPLOAD_MD5_MATCHED: 'MD5 súhlasí, firmvér sa inštaluje...'
UPLOAD_MD5_MATCHED: 'MD5 súhlasí, firmvér sa inštaluje...',
RECOVERY_APP: 'Aplikácia na obnovu',
RECOVERY_NOT_INSTALLED: 'Nenainštalované',
RECOVERY_INSTALLED: 'Firmvér obnovy nainštalovaný',
RESTART_INTO_RECOVERY: 'Reštartovať do aplikácie na obnovu',
RECOVERY_NOT_FOUND: 'Aplikácia na obnovu sa online nenašla'
};
export default sk;
+10 -3
View File
@@ -42,6 +42,7 @@ const sv: Translation = {
APPLY_CHANGES: 'Utför ändringar ({0})',
UPDATE: 'Uppdatera',
REMOVE: 'Ta bort',
RESET: 'Återställ',
PROBLEM_UPDATING: 'Problem vid uppdatering',
PROBLEM_LOADING: 'Problem vid hämtning',
ANALOG_SENSOR: 'analog sensor',
@@ -141,6 +142,8 @@ const sv: Translation = {
CUSTOMIZATIONS_HELP_6: 'Ta bort',
SELECT_DEVICE: 'Välj en enhet',
SET_ALL: 'ställ in alla',
MODIFIED: 'ändrade',
RESET_SELECTED: 'Återställ markerade',
OPTIONS: 'Alternativ',
NAME: 'Namn',
CUSTOMIZATIONS_RESET: 'Är du säker på att du vill ta bort alla anpassningar?',
@@ -235,7 +238,7 @@ const sv: Translation = {
TIME_ZONE: 'Tidszon',
ACCESS_POINT: 'Accesspunkt',
AP_PROVIDE: 'Aktivera accesspunkt',
AP_PROVIDE_TEXT_2: 'när nätverksanslutningen är bortkopplad',
AP_PROVIDE_TEXT_2: 'när nätverksanslutningen inte hittas',
AP_PROVIDE_TEXT_3: 'aldrig',
AP_PREFERRED_CHANNEL: 'Kanal',
AP_HIDE_SSID: 'Göm SSID',
@@ -336,7 +339,6 @@ const sv: Translation = {
SWITCH_RELEASE_TYPE: 'Byt till {0} utgåva',
FIRMWARE_VERSION_INFO: 'Information om firmwareversion',
USER_PROFILE: 'Användarprofil',
STORED_VERSIONS: 'Lagrad versioner',
ONLINE_HELP: 'online hjälp',
UPGRADE_IMPORTANT_MESSAGES: 'Uppdatera viktiga meddelanden',
UPGRADE_IMPORTANT_MESSAGES_1: 'Denna uppdatering kräver en fabriksåterställning. Se till att du först laddar ned en System Backup innan du fortsätter, och ladda upp denna fil efter att den nya versionen är installerad.',
@@ -352,7 +354,12 @@ const sv: Translation = {
UPLOAD_BACKUP_TEXT: 'Klicka för att välja, eller drop en säkerhetskopia (.json) här',
UPLOAD_FIRMWARE_TEXT: 'Klicka för att välja, eller drop en firmware .bin/.md5 här',
UPLOAD_MD5_RECEIVED: 'MD5 mottagen, ladda nu upp firmware .bin-filen',
UPLOAD_MD5_MATCHED: 'MD5 stämmer, firmware installeras...'
UPLOAD_MD5_MATCHED: 'MD5 stämmer, firmware installeras...',
RECOVERY_APP: 'Återställningsapp',
RECOVERY_NOT_INSTALLED: 'Inte installerad',
RECOVERY_INSTALLED: 'Återställningsfirmware installerad',
RESTART_INTO_RECOVERY: 'Starta om i återställningsappen',
RECOVERY_NOT_FOUND: 'Återställningsappen hittades inte online'
};
export default sv;
+10 -3
View File
@@ -42,6 +42,7 @@ const tr: Translation = {
APPLY_CHANGES: 'Değişiklikleri Uygula ({0})',
UPDATE: 'Update',
REMOVE: 'Kaldır',
RESET: 'Sıfırla',
PROBLEM_UPDATING: 'Güncelleme Sorunu',
PROBLEM_LOADING: 'Yükleme Sorunu',
ANALOG_SENSOR: 'Analog Sensör',
@@ -141,6 +142,8 @@ const tr: Translation = {
CUSTOMIZATIONS_HELP_6: 'remove from memory',
SELECT_DEVICE: 'Bir cihaz seç',
SET_ALL: 'hepsini ayarla',
MODIFIED: 'değiştirilmiş',
RESET_SELECTED: 'Seçilenleri sıfırla',
OPTIONS: 'Seçenekler',
NAME: 'İsim',
CUSTOMIZATIONS_RESET: 'Bütün özelleştirmeleri kaldırmak istediğinizden emin misiniz?',
@@ -235,7 +238,7 @@ const tr: Translation = {
TIME_ZONE: 'Saat dilimi',
ACCESS_POINT: 'Erişim Noktası',
AP_PROVIDE: 'Erişim noktasını çalıştır',
AP_PROVIDE_TEXT_2: 'Ağ bağlantısı kesildiğinde',
AP_PROVIDE_TEXT_2: 'Ağ bağlantısı bulunamadığında',
AP_PROVIDE_TEXT_3: 'asla',
AP_PREFERRED_CHANNEL: 'Tercih edilen kanal',
AP_HIDE_SSID: 'SSID yi gizle',
@@ -336,7 +339,6 @@ const tr: Translation = {
SWITCH_RELEASE_TYPE: '{0} sürümüne geç',
FIRMWARE_VERSION_INFO: 'Firmware Sürüm Bilgisi',
USER_PROFILE: 'Kullanıcı Profili',
STORED_VERSIONS: 'Kaydedilmiş Sürümler',
ONLINE_HELP: 'online yardım',
UPGRADE_IMPORTANT_MESSAGES: 'Önemli Mesajları Güncelle',
UPGRADE_IMPORTANT_MESSAGES_1: 'Bu güncelleme továrnı ayarlarını gerektirir. Yapılandırmanızı ve ayarlarınızı önce yedekleyin ve ardından yeni sürüm yüklendikten sonra yükleyin.',
@@ -352,7 +354,12 @@ const tr: Translation = {
UPLOAD_BACKUP_TEXT: 'Bir yedek dosyası (.json), buraya drop yapın',
UPLOAD_FIRMWARE_TEXT: 'Bir firmware .bin dosyası, buraya drop yapın',
UPLOAD_MD5_RECEIVED: 'Özet alındı, şimdi firmware .bin dosyasını yükleyin',
UPLOAD_MD5_MATCHED: 'MD5 eşleşti, firmware yükleniyor...'
UPLOAD_MD5_MATCHED: 'MD5 eşleşti, firmware yükleniyor...',
RECOVERY_APP: 'Kurtarma Uygulaması',
RECOVERY_NOT_INSTALLED: 'Yüklü değil',
RECOVERY_INSTALLED: 'Kurtarma firmware yüklendi',
RESTART_INTO_RECOVERY: 'Kurtarma uygulamasına yeniden başlat',
RECOVERY_NOT_FOUND: 'Kurtarma uygulaması çevrimiçi bulunamadı'
};
export default tr;
+517
View File
@@ -0,0 +1,517 @@
import { type ReactNode, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel';
import HealthAndSafetyIcon from '@mui/icons-material/HealthAndSafety';
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
import LogoutIcon from '@mui/icons-material/Logout';
import MemoryIcon from '@mui/icons-material/Memory';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import RestartAltIcon from '@mui/icons-material/RestartAlt';
import SettingsBackupRestoreIcon from '@mui/icons-material/SettingsBackupRestore';
import {
Avatar,
Box,
Button,
Chip,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Divider,
IconButton,
List,
ListItem,
ListItemAvatar,
ListItemText,
MenuItem,
Table,
TableBody,
TableCell,
TableRow,
TextField,
Typography
} from '@mui/material';
import { dialogStyle } from 'CustomTheme';
import ButtonRow from 'components/ButtonRow';
import MessageBox from 'components/MessageBox';
import SectionContent from 'components/SectionContent';
import { toast } from 'components/toast';
import FirmwareUpload from './FirmwareUpload';
import { HttpError, factoryReset, restart, setBootPartition } from './api';
import type { RecoveryPartition, RecoveryStatus } from './types';
const formatMB = (bytes: number) => `${(bytes / 1048576).toFixed(2)} MB`;
const formatInstallDate = (epoch: number) =>
epoch > 0 ? new Date(epoch * 1000).toLocaleString() : 'unknown';
const formatUptime = (seconds: number) => {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
return `${h}h ${m}m ${s}s`;
};
const ETH_STATE_TEXT: Record<
Exclude<RecoveryStatus['eth_state'], undefined | 'connected'>,
string
> = {
disabled: 'disabled, the clock mode conflicts with PSRAM',
failed: 'failed to start, no PHY found',
no_link: 'no cable connected',
connecting: 'waiting for an IP address'
};
const failedToStart = (p: RecoveryPartition) =>
p.ota_state === 'aborted' || p.ota_state === 'invalid';
// why the device is running the recovery, if it can be worked out
const recoveryReason = (status: RecoveryStatus, failed: RecoveryPartition[]) => {
if (status.reason === 'crash') {
return 'EMS-ESP crashed repeatedly and switched to the recovery.';
}
if (status.reason === 'request') {
return undefined; // started on purpose from EMS-ESP, e.g. a long press of the button
}
if (failed.length > 0) {
return `The firmware in ${failed.map((p) => p.label).join(' and ')} failed to start.`;
}
if (status.reset_reason === 'panic' || status.reset_reason === 'watchdog') {
return 'EMS-ESP crashed while starting.';
}
return undefined;
};
const SectionTitle = ({ children }: { children: ReactNode }) => (
<Typography sx={{ pb: 1 }} variant="h6" color="primary">
{children}
</Typography>
);
// same look as the version info dialog in the EMS-ESP app
const InfoRow = ({ label, value }: { label: string; value: ReactNode }) => (
<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
}}
>
{label}
</TableCell>
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
{value}
</TableCell>
</TableRow>
);
interface DashboardProps {
status: RecoveryStatus;
onRestarting: (target: string) => void;
onSignOut: () => void;
}
const Dashboard = ({ status, onRestarting, onSignOut }: DashboardProps) => {
const [confirmBoot, setConfirmBoot] = useState<RecoveryPartition>();
const [infoPartition, setInfoPartition] = useState<RecoveryPartition>();
const [confirmFactoryReset, setConfirmFactoryReset] = useState(false);
const [pickedTarget, setPickedTarget] = useState<string>();
const uploadTarget = pickedTarget ?? status.upload_target;
const handleError = (error: unknown) => {
if (error instanceof HttpError && error.status === 401) {
toast.warning('Session expired, please sign in again');
onSignOut();
} else {
toast.error((error as Error).message);
}
};
const doBoot = async (partition: RecoveryPartition) => {
setConfirmBoot(undefined);
try {
await setBootPartition(partition.label);
onRestarting(partition.label);
} catch (error) {
if (error instanceof HttpError && error.status === 400) {
toast.error(`${partition.label} does not contain a bootable firmware`);
} else {
handleError(error);
}
}
};
const doRestart = async (target: string) => {
try {
await restart();
onRestarting(target);
} catch (error) {
handleError(error);
}
};
const doFactoryReset = async () => {
setConfirmFactoryReset(false);
try {
await factoryReset();
onRestarting(status.boot);
} catch (error) {
handleError(error);
}
};
const otaPartitions = status.partitions.filter((p) => !p.factory);
const hasFirmware = otaPartitions.some((p) => p.valid);
const reason = recoveryReason(
status,
otaPartitions.filter((p) => p.valid && failedToStart(p))
);
return (
<>
<SectionContent>
{reason && <MessageBox sx={{ mb: 2 }} level="error" message={reason} />}
<MessageBox level={hasFirmware ? 'info' : 'warning'}>
{hasFirmware
? 'This device is running the EMS-ESP Recovery. Start one of the installed firmwares, or upload a new one.'
: 'No EMS-ESP firmware is installed. Upload a firmware to continue.'}
</MessageBox>
</SectionContent>
<SectionContent>
<SectionTitle>Firmware</SectionTitle>
<List dense>
{status.partitions.map((p, index) => (
<Box key={p.label}>
{index > 0 && <Divider component="li" />}
<ListItem
secondaryAction={
!p.factory &&
p.valid && (
<Button
startIcon={<PlayArrowIcon />}
variant="outlined"
color="primary"
size="small"
onClick={() => setConfirmBoot(p)}
>
Start
</Button>
)
}
>
<ListItemAvatar>
<Avatar
sx={{
bgcolor: p.factory
? '#5d89f7'
: p.valid
? '#2e586a'
: '#5f5f5f'
}}
>
{p.factory ? <HealthAndSafetyIcon /> : <MemoryIcon />}
</Avatar>
</ListItemAvatar>
<ListItemText
primary={
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
flexWrap: 'wrap'
}}
>
<span>{p.factory ? `Recovery (${p.label})` : p.label}</span>
{p.label === status.running && (
<Chip size="small" color="success" label="running" />
)}
{p.label === status.boot && p.label !== status.running && (
<Chip size="small" color="secondary" label="next boot" />
)}
{p.label === uploadTarget && (
<Chip
size="small"
variant="outlined"
label="upload target"
/>
)}
{!p.valid && (
<Chip
size="small"
color="warning"
variant="outlined"
label="empty"
/>
)}
{p.valid && failedToStart(p) && (
<Chip size="small" color="error" label="failed to start" />
)}
{p.valid && p.ota_state === 'pending' && (
<Chip
size="small"
color="warning"
variant="outlined"
label="unconfirmed"
/>
)}
{p.valid && (
<IconButton
size="small"
onClick={() => setInfoPartition(p)}
aria-label="Firmware version info"
sx={{ p: 0.5 }}
>
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
</IconButton>
)}
</Box>
}
secondary={
p.valid
? `${p.version ? 'v' + p.version + ' · ' : ''}${formatMB(p.image_size)} of ${formatMB(p.size)}`
: formatMB(p.size)
}
/>
</ListItem>
</Box>
))}
</List>
</SectionContent>
<SectionContent>
<SectionTitle>Upload Firmware</SectionTitle>
<Typography sx={{ pb: 2 }} variant="body2" color="text.secondary">
The firmware is started automatically after the upload. To enable the MD5
check, upload the matching .md5 file first.
</Typography>
<TextField
select
size="small"
sx={{ mb: 2, minWidth: 260 }}
label="Write to"
value={uploadTarget}
onChange={(e) => setPickedTarget(e.target.value)}
>
{otaPartitions.map((p) => (
<MenuItem key={p.label} value={p.label}>
{p.label}
{p.valid
? ` (replaces ${p.version ? 'v' + p.version : 'installed firmware'})`
: ' (empty)'}
</MenuItem>
))}
</TextField>
<FirmwareUpload
target={uploadTarget}
chip={status.chip}
onUploaded={() => void doRestart(uploadTarget)}
onUnauthorized={onSignOut}
/>
</SectionContent>
<SectionContent>
<SectionTitle>System</SectionTitle>
<Table size="small" sx={{ borderCollapse: 'collapse', minWidth: 0 }}>
<TableBody>
<InfoRow
label="Recovery version"
value={'v' + status.recovery_version}
/>
<InfoRow
label="Chip"
value={`${status.chip}, ${formatMB(status.flash_size)} flash${status.psram_size ? ', ' + formatMB(status.psram_size) + ' PSRAM' : ''}`}
/>
<InfoRow
label="Free heap"
value={`${Math.round(status.free_heap / 1024)} KB`}
/>
<InfoRow label="Uptime" value={formatUptime(status.uptime)} />
<InfoRow label="MAC address" value={status.mac} />
<InfoRow
label="Access point"
value={`${status.ap_ssid} on ${status.ap_ip} (${status.ap_clients} connected)`}
/>
{status.eth_state && (
<InfoRow
label="Ethernet"
value={
status.eth_state === 'connected'
? `connected, ${status.eth_ip ?? ''}${status.static_ip ? ' (static IP)' : ''}`
: ETH_STATE_TEXT[status.eth_state]
}
/>
)}
<InfoRow
label="WiFi"
value={
!status.wifi_ssid
? 'not configured'
: status.wifi_connected
? `${status.wifi_ssid}, ${status.wifi_ip ?? ''} (${status.wifi_rssi ?? 0} dBm)${status.static_ip ? ', static IP' : ''}`
: !status.wifi_started
? status.eth_state === 'connected'
? 'not used, Ethernet is connected'
: 'waiting for Ethernet'
: `connecting to ${status.wifi_ssid}`
}
/>
<InfoRow label="Hostname" value={status.hostname} />
</TableBody>
</Table>
<ButtonRow>
{!status.disable_reset && (
<Button
startIcon={<SettingsBackupRestoreIcon />}
variant="outlined"
color="error"
onClick={() => setConfirmFactoryReset(true)}
>
Factory Reset
</Button>
)}
<Button
startIcon={<RestartAltIcon />}
variant="outlined"
color="success"
onClick={() => void doRestart(status.boot)}
>
Restart{status.boot !== status.running ? ` (boots ${status.boot})` : ''}
</Button>
<Button
startIcon={<LogoutIcon />}
variant="outlined"
color="secondary"
sx={{ float: 'right' }}
onClick={onSignOut}
>
Sign Out
</Button>
</ButtonRow>
</SectionContent>
<Dialog
sx={dialogStyle}
open={confirmFactoryReset}
onClose={() => setConfirmFactoryReset(false)}
>
<DialogTitle>Factory Reset</DialogTitle>
<DialogContent dividers>
Are you sure you want to reset EMS-ESP to its factory defaults?
</DialogContent>
<DialogActions>
<Button
startIcon={<CancelIcon />}
variant="outlined"
color="secondary"
onClick={() => setConfirmFactoryReset(false)}
>
Cancel
</Button>
<Button
startIcon={<SettingsBackupRestoreIcon />}
variant="outlined"
color="error"
onClick={() => void doFactoryReset()}
>
Factory Reset
</Button>
</DialogActions>
</Dialog>
<Dialog
sx={dialogStyle}
open={!!confirmBoot}
onClose={() => setConfirmBoot(undefined)}
>
<DialogTitle>Start Firmware</DialogTitle>
<DialogContent dividers>
Restart and run
{confirmBoot?.version ? ` EMS-ESP v${confirmBoot.version}` : ''} from{' '}
{confirmBoot?.label}?
{confirmBoot && failedToStart(confirmBoot) && (
<MessageBox
sx={{ mt: 2 }}
level="warning"
message="This firmware failed to start before. If it fails again the device falls back to the other firmware, or to the recovery."
/>
)}
</DialogContent>
<DialogActions>
<Button
startIcon={<CancelIcon />}
variant="outlined"
color="secondary"
onClick={() => setConfirmBoot(undefined)}
>
Cancel
</Button>
<Button
startIcon={<PlayArrowIcon />}
variant="outlined"
color="primary"
onClick={() => confirmBoot && void doBoot(confirmBoot)}
>
Start
</Button>
</DialogActions>
</Dialog>
<Dialog
sx={dialogStyle}
open={!!infoPartition}
onClose={() => setInfoPartition(undefined)}
>
<DialogTitle>Firmware Version Info</DialogTitle>
<DialogContent dividers>
{infoPartition && (
<Table size="small" sx={{ borderCollapse: 'collapse', minWidth: 0 }}>
<TableBody>
<InfoRow
label="Version"
value={
infoPartition.version ? 'v' + infoPartition.version : 'unknown'
}
/>
<InfoRow
label="Type"
value={infoPartition.factory ? 'Recovery App' : 'EMS-ESP Firmware'}
/>
<InfoRow label="Partition" value={infoPartition.label} />
<InfoRow
label="Size"
value={`${formatMB(infoPartition.image_size)} of ${formatMB(infoPartition.size)}`}
/>
<InfoRow
label="Install Date"
value={formatInstallDate(infoPartition.install_date)}
/>
</TableBody>
</Table>
)}
</DialogContent>
<DialogActions>
<Button
variant="outlined"
color="secondary"
onClick={() => setInfoPartition(undefined)}
>
Close
</Button>
</DialogActions>
</Dialog>
</>
);
};
export default Dashboard;
+208
View File
@@ -0,0 +1,208 @@
import { type ChangeEvent, type DragEvent, useRef, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel';
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
import UploadIcon from '@mui/icons-material/Upload';
import { Box, Button, Typography, styled } from '@mui/material';
import { toast } from 'components/toast';
import { LinearProgressWithLabel } from 'components/upload/LinearProgressWithLabel';
import { HttpError, type UploadHandle, uploadFile } from './api';
const DocumentUploader = styled(Box)<{ active?: boolean }>(({ theme, active }) => ({
border: `2px dashed ${active ? '#6dc24b' : '#4282fe'}`,
backgroundColor: '#2e3339',
padding: theme.spacing(1.25),
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
borderRadius: theme.spacing(1),
cursor: 'pointer',
minHeight: '120px',
transition: 'border-color 0.2s ease-in-out'
}));
const uploadErrorMessage = (status: number, chip: string) => {
switch (status) {
case 400:
return 'Invalid target partition';
case 401:
case 403:
return 'Not authorized, please sign in again';
case 409:
return 'This is the recovery firmware, it can only be installed from EMS-ESP';
case 503:
return `This firmware is not built for the ${chip}`;
case 507:
return 'The firmware does not fit in the partition';
case 500:
return 'Upload failed, flash write error or MD5 mismatch';
default:
return 'Upload failed. Only EMS-ESP firmware .bin and .md5 files are accepted';
}
};
interface FirmwareUploadProps {
target: string;
chip: string;
onUploaded: () => void;
onUnauthorized: () => void;
}
const FirmwareUpload = ({
target,
chip,
onUploaded,
onUnauthorized
}: FirmwareUploadProps) => {
const [file, setFile] = useState<File>();
const [dragged, setDragged] = useState(false);
const [md5, setMd5] = useState<string>();
const [progress, setProgress] = useState<number>();
const uploadRef = useRef<UploadHandle | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const selectFile = (selected?: File) => {
if (!selected) return;
if (/\.(bin|md5)$/i.test(selected.name)) {
setFile(selected);
} else {
toast.warning('Please select an EMS-ESP firmware .bin or .md5 file');
}
};
const onFileChange = (e: ChangeEvent<HTMLInputElement>) => {
selectFile(e.target.files?.[0]);
e.target.value = ''; // allow the same file to be selected again
};
const onDrop = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
setDragged(false);
selectFile(e.dataTransfer.files[0]);
};
const startUpload = async () => {
if (!file) return;
setProgress(0);
uploadRef.current = uploadFile(file, target, setProgress);
try {
const result = await uploadRef.current.promise;
if (result.md5) {
setMd5(result.md5);
toast.success('MD5 received, now upload the firmware .bin');
} else {
if (result.md5_ok) {
toast.success('Firmware MD5 matches');
}
toast.success(`Firmware written to ${target}`);
onUploaded();
}
} catch (error) {
const status = error instanceof HttpError ? error.status : 0;
if (status === -1) {
toast.warning('Upload aborted');
} else {
toast.error(uploadErrorMessage(status, chip));
if (status === 401 || status === 403) onUnauthorized();
}
} finally {
uploadRef.current = null;
setProgress(undefined);
setFile(undefined);
}
};
if (progress !== undefined) {
return (
<>
<Typography sx={{ mb: 1 }}>
Uploading {file?.name} to {target}&hellip;
</Typography>
<LinearProgressWithLabel value={progress} />
<Button
sx={{ mt: 2 }}
startIcon={<CancelIcon />}
variant="outlined"
color="secondary"
onClick={() => uploadRef.current?.abort()}
>
Cancel
</Button>
</>
);
}
return (
<>
<DocumentUploader
active={!!(file || dragged)}
onDrop={onDrop}
onDragOver={(e) => {
e.preventDefault();
setDragged(true);
}}
onDragLeave={() => setDragged(false)}
onClick={() => inputRef.current?.click()}
>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<CloudUploadIcon sx={{ mr: 4 }} color="primary" fontSize="large" />
<Typography>
{md5
? 'MD5 received. Now drop the firmware .bin file here, or click to browse'
: 'Drop an EMS-ESP firmware .bin file here, or click to browse'}
</Typography>
</Box>
<input
type="file"
hidden
ref={inputRef}
accept=".bin,.md5"
onChange={onFileChange}
style={{ display: 'none' }}
/>
{file && (
<>
<Typography sx={{ fontSize: 14, color: '#6dc24b', my: 1 }}>
{file.name}
</Typography>
<Box>
<Button
startIcon={<CancelIcon />}
variant="outlined"
color="secondary"
onClick={(e) => {
e.stopPropagation();
setFile(undefined);
}}
>
Cancel
</Button>
<Button
sx={{ ml: 2 }}
startIcon={<UploadIcon />}
variant="outlined"
color="primary"
onClick={(e) => {
e.stopPropagation();
void startUpload();
}}
>
Upload
</Button>
</Box>
</>
)}
</DocumentUploader>
{md5 && (
<Typography sx={{ mt: 2 }} variant="body2" color="success">
{'MD5: ' + md5}
</Typography>
)}
</>
);
};
export default FirmwareUpload;
+132
View File
@@ -0,0 +1,132 @@
import { useCallback, useEffect, useState } from 'react';
import RefreshIcon from '@mui/icons-material/Refresh';
import {
AppBar,
Box,
Button,
CircularProgress,
Toolbar,
Typography
} from '@mui/material';
import CustomTheme from 'CustomTheme';
import MessageBox from 'components/MessageBox';
import SectionContent from 'components/SectionContent';
import { Toaster } from 'components/toast';
import Dashboard from './Dashboard';
import RestartMonitor from './RestartMonitor';
import SignIn from './SignIn';
import { getToken, readStatus, setToken } from './api';
import type { RecoveryStatus } from './types';
const STATUS_REFRESH_MS = 5000;
const RecoveryApp = () => {
const [status, setStatus] = useState<RecoveryStatus>();
const [error, setError] = useState<string>();
const [restartTarget, setRestartTarget] = useState<string>();
const loadStatus = useCallback(async () => {
try {
const data = await readStatus();
if (!data.authenticated && getToken()) {
setToken(null); // stale token, e.g. after the device restarted
}
setStatus(data);
setError(undefined);
} catch (e) {
setError((e as Error).message);
}
}, []);
useEffect(() => {
void loadStatus();
}, [loadStatus]);
useEffect(() => {
if (!status?.authenticated || restartTarget !== undefined) return;
const timer = setInterval(() => void loadStatus(), STATUS_REFRESH_MS);
return () => clearInterval(timer);
}, [status?.authenticated, restartTarget, loadStatus]);
const signOut = () => {
setToken(null);
void loadStatus();
};
let content;
if (restartTarget !== undefined && status) {
content = (
<RestartMonitor
target={restartTarget}
viaAccessPoint={window.location.hostname === status.ap_ip}
hostname={status.hostname}
/>
);
} else if (!status) {
content = (
<SectionContent>
{error ? (
<MessageBox level="error" message={error}>
<Button
sx={{ ml: 2 }}
startIcon={<RefreshIcon />}
variant="contained"
color="error"
onClick={() => void loadStatus()}
>
Retry
</Button>
</MessageBox>
) : (
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}>
<CircularProgress size={100} />
</Box>
)}
</SectionContent>
);
} else if (!status.authenticated) {
content = <SignIn onSignedIn={() => void loadStatus()} />;
} else {
content = (
<>
<AppBar
position="fixed"
sx={{ boxShadow: 'none', backgroundColor: '#2e586a' }}
>
<Toolbar>
<img
src="/app/icon.png"
alt="EMS-ESP"
style={{ height: 32, marginRight: 16 }}
/>
<Typography variant="h6">EMS-ESP</Typography>
<Typography variant="h6" sx={{ color: '#90caf9' }}>
&nbsp;&nbsp;|&nbsp;&nbsp;
</Typography>
<Typography variant="h6">Recovery</Typography>
</Toolbar>
</AppBar>
<Box component="main" sx={{ maxWidth: 900, mx: 'auto' }}>
<Toolbar />
<Dashboard
status={status}
onRestarting={setRestartTarget}
onSignOut={signOut}
/>
</Box>
</>
);
}
return (
<CustomTheme>
{content}
<Toaster />
</CustomTheme>
);
};
export default RecoveryApp;
+149
View File
@@ -0,0 +1,149 @@
import { useEffect, useState } from 'react';
import RefreshIcon from '@mui/icons-material/Refresh';
import { Box, Button, LinearProgress, Typography } from '@mui/material';
import MessageBox from 'components/MessageBox';
import { STATUS_URL } from './api';
const POLL_INTERVAL_MS = 1000;
const POLL_TIMEOUT_MS = 3000;
const FIRST_POLL_DELAY_MS = 1500;
// reload anyway if the device never appeared to go down
const NO_DOWNTIME_RELOAD_MS = 20000;
const GIVE_UP_MS = 90000;
interface RestartMonitorProps {
target: string;
viaAccessPoint: boolean;
hostname: string;
}
// Waits for the device to go down and come back, then reloads the page. Both the recovery app
// and EMS-ESP answer on STATUS_URL (EMS-ESP serves its WebUI for unknown GETs).
const RestartMonitor = ({
target,
viaAccessPoint,
hostname
}: RestartMonitorProps) => {
const [gaveUp, setGaveUp] = useState(false);
useEffect(() => {
const started = Date.now();
let seenDown = false;
let timer: ReturnType<typeof setTimeout>;
let cancelled = false;
const poll = async () => {
let up: boolean;
try {
const response = await fetch(STATUS_URL, {
cache: 'no-store',
signal: AbortSignal.timeout(POLL_TIMEOUT_MS)
});
up = response.ok;
} catch {
up = false;
}
if (cancelled) return;
const elapsed = Date.now() - started;
if (!up) {
seenDown = true;
} else if (seenDown || elapsed > NO_DOWNTIME_RELOAD_MS) {
window.location.reload();
return;
}
if (elapsed > GIVE_UP_MS) {
setGaveUp(true);
return;
}
timer = setTimeout(() => void poll(), POLL_INTERVAL_MS);
};
timer = setTimeout(() => void poll(), FIRST_POLL_DELAY_MS);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, []);
return (
<Box
sx={{
position: 'fixed',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<Box
sx={{
width: '30%',
minWidth: '300px',
maxWidth: '500px',
backgroundColor: '#393939',
border: 2,
borderColor: '#565656',
borderRadius: '8px',
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.3)',
p: 3,
display: 'flex',
alignItems: 'center',
flexDirection: 'column'
}}
>
<img
src="/app/icon.png"
alt="EMS-ESP"
style={{ width: '40px', height: '40px', marginBottom: '16px' }}
/>
<Typography sx={{ textAlign: 'center' }} variant="h6">
Restarting{target ? ` from ${target}` : ''}
</Typography>
{gaveUp ? (
<>
<MessageBox sx={{ mt: 2 }} level="warning">
The device has not come back on this address.
{viaAccessPoint &&
` If you were connected to the recovery access point, reconnect to your normal network and open http://${hostname}.local`}
</MessageBox>
<Button
sx={{ mt: 2 }}
startIcon={<RefreshIcon />}
variant="outlined"
onClick={() => window.location.reload()}
>
Reload
</Button>
</>
) : (
<>
<Typography sx={{ mt: 2, textAlign: 'center' }} variant="h6">
Please wait&hellip;
</Typography>
<Box sx={{ width: '100%', mt: 2 }}>
<LinearProgress />
</Box>
{viaAccessPoint && (
<Typography
sx={{ mt: 2, textAlign: 'center' }}
variant="body2"
color="text.secondary"
>
EMS-ESP does not use the recovery access point. If this page does not
reload, reconnect to your normal network and open http://{hostname}
.local
</Typography>
)}
</>
)}
</Box>
</Box>
);
};
export default RestartMonitor;
+142
View File
@@ -0,0 +1,142 @@
import { type KeyboardEvent, useState } from 'react';
import ForwardIcon from '@mui/icons-material/Forward';
import VisibilityIcon from '@mui/icons-material/Visibility';
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
import {
Box,
Button,
IconButton,
InputAdornment,
Paper,
TextField,
Typography
} from '@mui/material';
import type { Theme } from '@mui/material/styles';
import { toast } from 'components/toast';
import { HttpError, setToken, signIn } from './api';
interface SignInProps {
onSignedIn: () => void;
}
const SignIn = ({ onSignedIn }: SignInProps) => {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [processing, setProcessing] = useState(false);
const doSignIn = async () => {
if (!username || !password) {
toast.warning('Please enter the admin username and password');
return;
}
setProcessing(true);
try {
const { access_token } = await signIn(username, password);
setToken(access_token);
onSignedIn();
} catch (error) {
if (error instanceof HttpError && error.status === 401) {
toast.warning('Invalid login details');
} else {
toast.error('Error: ' + (error as Error).message);
}
setProcessing(false);
}
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Enter') {
void doSignIn();
}
};
return (
<Box
sx={(theme: Theme) => ({
display: 'flex',
height: '100vh',
margin: 'auto',
padding: 2,
justifyContent: 'center',
flexDirection: 'column',
maxWidth: theme.breakpoints.values.sm
})}
>
<Paper
sx={(theme) => ({
textAlign: 'center',
padding: theme.spacing(2),
paddingTop: '172px',
backgroundImage: 'url("/app/icon.png")',
backgroundRepeat: 'no-repeat',
backgroundPosition: '50% ' + theme.spacing(2),
width: '100%'
})}
>
<Typography variant="h4">EMS-ESP</Typography>
<Typography sx={{ mb: 2 }} variant="h6" color="secondary">
Recovery
</Typography>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: 1,
alignItems: 'center'
}}
>
<TextField
disabled={processing}
sx={{ width: '32ch' }}
name="username"
label="Username"
value={username}
onChange={(e) => setUsername(e.target.value)}
slotProps={{ input: { autoCapitalize: 'none', autoCorrect: 'off' } }}
/>
<TextField
disabled={processing}
sx={{ width: '32ch' }}
name="password"
label="Password"
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={onKeyDown}
slotProps={{
input: {
endAdornment: (
<InputAdornment position="end">
<IconButton
onClick={() => setShowPassword(!showPassword)}
edge="end"
>
{showPassword ? <VisibilityIcon /> : <VisibilityOffIcon />}
</IconButton>
</InputAdornment>
)
}
}}
/>
</Box>
<Button
variant="contained"
color="primary"
sx={{ mt: 2 }}
onClick={doSignIn}
disabled={processing}
>
<ForwardIcon sx={{ mr: 1 }} />
Sign In
</Button>
</Paper>
</Box>
);
};
export default SignIn;
+109
View File
@@ -0,0 +1,109 @@
import type { RecoveryStatus, UploadResult } from './types';
const TOKEN_KEY = 'recovery_token';
export const getToken = (): string | null => sessionStorage.getItem(TOKEN_KEY);
export const setToken = (token: string | null): void => {
if (token) {
sessionStorage.setItem(TOKEN_KEY, token);
} else {
sessionStorage.removeItem(TOKEN_KEY);
}
};
export class HttpError extends Error {
readonly status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
}
}
const authHeaders = (): Record<string, string> => {
const token = getToken();
return token ? { Authorization: `Bearer ${token}` } : {};
};
const request = async <T>(
method: string,
url: string,
body?: unknown
): Promise<T> => {
const init: RequestInit = { method, headers: authHeaders(), cache: 'no-store' };
if (body !== undefined) {
init.headers = { ...authHeaders(), 'Content-Type': 'application/json' };
init.body = JSON.stringify(body);
}
const response = await fetch(url, init);
if (!response.ok) {
throw new HttpError(
response.status,
response.statusText || `HTTP ${response.status}`
);
}
const text = await response.text();
return (text ? JSON.parse(text) : undefined) as T;
};
export const STATUS_URL = '/rest/recovery/status';
export const readStatus = () => request<RecoveryStatus>('GET', STATUS_URL);
export const signIn = (username: string, password: string) =>
request<{ access_token: string }>('POST', '/rest/signIn', { username, password });
export const setBootPartition = (partition: string) =>
request<void>('POST', '/rest/recovery/boot', { partition });
export const restart = () => request<void>('POST', '/rest/recovery/restart');
export const factoryReset = () => request<void>('POST', '/rest/recovery/format');
export interface UploadHandle {
promise: Promise<UploadResult>;
abort: () => void;
}
// XHR instead of fetch, for upload progress events
export const uploadFile = (
file: File,
partition: string,
onProgress: (percent: number) => void
): UploadHandle => {
const xhr = new XMLHttpRequest();
const promise = new Promise<UploadResult>((resolve, reject) => {
xhr.upload.onprogress = (event) => {
if (event.lengthComputable && event.total > 0) {
onProgress(Math.round((event.loaded * 100) / event.total));
}
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
resolve(
(xhr.responseText ? JSON.parse(xhr.responseText) : {}) as UploadResult
);
} catch {
resolve({});
}
} else {
reject(new HttpError(xhr.status, xhr.statusText));
}
};
// the device closes the connection on unsupported files, so there is no status code
xhr.onerror = () => reject(new HttpError(0, 'Connection closed'));
xhr.onabort = () => reject(new HttpError(-1, 'Aborted'));
});
const formData = new FormData();
formData.append('file', file);
xhr.open('POST', `/rest/uploadFile?partition=${encodeURIComponent(partition)}`);
for (const [key, value] of Object.entries(authHeaders())) {
xhr.setRequestHeader(key, value);
}
xhr.send(formData);
return { promise, abort: () => xhr.abort() };
};
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import RecoveryApp from './RecoveryApp';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<RecoveryApp />
</StrictMode>
);
+55
View File
@@ -0,0 +1,55 @@
export interface RecoveryPartition {
label: string;
factory: boolean;
address: number;
size: number;
valid: boolean;
image_size: number;
version: string;
install_date: number;
// bootloader rollback state, empty if the partition has never been selected
ota_state: '' | 'new' | 'pending' | 'valid' | 'invalid' | 'aborted';
}
export interface RecoveryStatus {
recovery_version: string;
authenticated: boolean;
hostname: string;
chip: string;
flash_size: number;
psram_size: number;
free_heap: number;
mac: string;
uptime: number;
reason: '' | 'crash' | 'request'; // why EMS-ESP switched to the recovery
reset_reason:
| 'power_on'
| 'external'
| 'software'
| 'panic'
| 'watchdog'
| 'brownout'
| 'other';
ap_ssid: string;
ap_ip: string;
ap_clients: number;
wifi_ssid: string;
wifi_started: boolean; // false while waiting for Ethernet, or when Ethernet is connected
wifi_connected: boolean;
wifi_ip?: string;
wifi_rssi?: number;
static_ip: boolean;
disable_reset: boolean; // factory reset is disabled in the EMS-ESP settings
// only on boards with Ethernet configured
eth_state?: 'disabled' | 'failed' | 'no_link' | 'connecting' | 'connected';
eth_ip?: string;
running: string;
boot: string;
upload_target: string;
partitions: RecoveryPartition[];
}
export interface UploadResult {
md5?: string;
md5_ok?: boolean;
}
+1 -1
View File
@@ -51,7 +51,7 @@ export interface SystemStatus {
free_caps: number;
model: string;
board: string;
has_loader: boolean;
recovery_version?: string;
has_partition: boolean;
partitions: {
partition: string;
+60
View File
@@ -12,6 +12,7 @@ const KB_DIVISOR = 1024;
const REPEAT_CHAR = '=';
const REPEAT_COUNT = 50;
const DEFAULT_OUT_DIR = 'dist';
const RECOVERY_OUT_DIR = 'dist-recovery';
const ES_TARGET = 'es2020';
const CHUNK_SIZE_WARNING_LIMIT = 1024;
const ASSETS_INLINE_LIMIT = 4096;
@@ -205,6 +206,65 @@ const imageOptimizationPlugin = {
export default defineConfig(
async ({ command, mode }: { command: string; mode: string }) => {
if (command === 'serve' && mode === 'recovery') {
console.log('Preparing Recovery WebUI with mock server');
const recoveryMockUrl = pathToFileURL(
path.resolve(import.meta.dirname, '../mock-api/recoveryMock.js')
).href;
const { default: recoveryMock } = (await import(
/* @vite-ignore */ recoveryMockUrl
)) as { default: () => PluginOption };
return {
plugins: [...createBasePlugins(true, false), recoveryMock()],
resolve: {
alias: RESOLVE_ALIASES,
extensions: RESOLVE_EXTENSIONS,
tsconfigPaths: true
},
server: {
open: '/recovery.html',
port: 3001
}
};
}
if (mode === 'recovery') {
console.log('Preparing for Recovery WebUI, optimized build');
return {
plugins: [...createBasePlugins(false, true), imageOptimizationPlugin],
resolve: {
alias: RESOLVE_ALIASES,
extensions: RESOLVE_EXTENSIONS,
tsconfigPaths: true
},
build: {
...createBaseBuildConfig(),
outDir: RECOVERY_OUT_DIR,
emptyOutDir: true,
minify: 'terser' as const,
terserOptions: createProductionTerserOptions(),
rollupOptions: {
input: path.resolve(import.meta.dirname, 'recovery.html'),
checks: {
pluginTimings: false
},
treeshake: {
moduleSideEffects: false,
propertyReadSideEffects: false as const,
unknownGlobalSideEffects: false
},
output: {
chunkFileNames: 'assets/[name]-[hash].js',
entryFileNames: 'assets/[name]-[hash].js',
assetFileNames: 'assets/[name]-[hash].[ext]',
manualChunks,
sourcemap: false
}
}
}
};
}
if (command === 'serve') {
console.log(`Preparing for standalone build with server, mode=${mode}`);
const mockServerUrl = pathToFileURL(
+290
View File
@@ -0,0 +1,290 @@
#include "OtaUpdater.h"
#include <Update.h>
#include <esp_app_format.h>
#include <esp_image_format.h>
#ifdef OTA_UPDATER_ANY_PARTITION
#include <esp_heap_caps.h>
#include <esp_partition.h>
#endif
#include <cstddef>
static String getFilenameExtension(const String & filename) {
const auto pos = filename.lastIndexOf('.');
if (pos != -1) {
return filename.substring(static_cast<unsigned int>(pos) + 1);
}
return {};
}
OtaUpdater::FileType OtaUpdater::classify(const String & filename, size_t filesize, size_t min_firmware_size) {
const String extension = getFilenameExtension(filename);
if (extension == "bin" && filename.endsWith("littlefs.bin")) {
return FileType::FILESYSTEM;
}
if (extension == "bin") {
String lower = filename;
lower.toLowerCase();
const size_t min_size =
lower.indexOf("recovery") >= 0 ? MIN_RECOVERY_FIRMWARE_SIZE : min_firmware_size;
if (filesize >= min_size) {
return FileType::FIRMWARE;
}
}
if (extension == "json") {
return FileType::JSON;
}
if (extension == "md5") {
return FileType::MD5;
}
return FileType::UNSUPPORTED;
}
bool OtaUpdater::isCompatibleFirmware(const uint8_t * data, size_t len) {
// 0xE9 magic at offset 0 indicates an esp bin, chip id at offset 12
#if CONFIG_IDF_TARGET_ESP32
constexpr uint8_t chip_id = ESP_CHIP_ID_ESP32;
#elif CONFIG_IDF_TARGET_ESP32S2
constexpr uint8_t chip_id = ESP_CHIP_ID_ESP32S2;
#elif CONFIG_IDF_TARGET_ESP32C3
constexpr uint8_t chip_id = ESP_CHIP_ID_ESP32C3;
#elif CONFIG_IDF_TARGET_ESP32S3
constexpr uint8_t chip_id = ESP_CHIP_ID_ESP32S3;
#elif CONFIG_IDF_TARGET_ESP32C6
constexpr uint8_t chip_id = ESP_CHIP_ID_ESP32C6;
#else
return true;
#endif
return len <= 12 || (data[0] == ESP_IMAGE_HEADER_MAGIC && data[12] == chip_id);
}
bool OtaUpdater::isRecoveryFirmware(const uint8_t * data, size_t len) {
constexpr size_t offset = sizeof(esp_image_header_t) + sizeof(esp_image_segment_header_t);
if (len < offset + offsetof(esp_app_desc_t, time)) {
return false;
}
esp_app_desc_t desc;
memcpy(&desc, data + offset, offsetof(esp_app_desc_t, time));
return desc.magic_word == ESP_APP_DESC_MAGIC_WORD && strncmp(desc.project_name, RECOVERY_PROJECT_NAME, sizeof(desc.project_name)) == 0;
}
bool OtaUpdater::isRecoveryPartition(const esp_partition_t * partition, esp_app_desc_t * desc) {
esp_app_desc_t d;
if (partition == nullptr || esp_ota_get_partition_description(partition, &d) != ESP_OK
|| strncmp(d.project_name, RECOVERY_PROJECT_NAME, sizeof(d.project_name)) != 0) {
return false;
}
if (desc != nullptr) {
*desc = d;
}
return true;
}
bool OtaUpdater::parseMd5Digest(const uint8_t * data, size_t len, std::array<char, 33> & out) {
size_t i = 0;
while (i < len && (data[i] == ' ' || data[i] == '\t' || data[i] == '\r' || data[i] == '\n')) {
++i;
}
if (len - i < 32) {
return false;
}
for (size_t n = 0; n < 32; n++) {
const char c = static_cast<char>(data[i + n]);
const bool hex = (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F');
if (!hex) {
return false;
}
out[n] = (c >= 'A' && c <= 'F') ? static_cast<char>(c - 'A' + 'a') : c;
}
out[32] = '\0';
return true;
}
bool OtaUpdater::confirmRunningApp() {
#ifdef CONFIG_APP_ROLLBACK_ENABLE
esp_ota_img_states_t state;
if (esp_ota_get_state_partition(esp_ota_get_running_partition(), &state) == ESP_OK && state == ESP_OTA_IMG_PENDING_VERIFY) {
return esp_ota_mark_app_valid_cancel_rollback() == ESP_OK;
}
#endif
return false;
}
bool OtaUpdater::setMd5(const uint8_t * data, size_t len) {
return parseMd5Digest(data, len, _md5);
}
void OtaUpdater::clearMd5() {
_md5.front() = '\0';
_md5_applied = false;
}
bool OtaUpdater::hasMd5() const {
return strlen(_md5.data()) == _md5.size() - 1;
}
#ifdef OTA_UPDATER_ANY_PARTITION
void OtaUpdater::setError(esp_err_t err) {
snprintf(_error_buf, sizeof(_error_buf), "%s (0x%x)", esp_err_to_name(err), static_cast<unsigned>(err));
_error = _error_buf;
}
void OtaUpdater::releaseTarget() {
_target = nullptr;
heap_caps_free(_dram);
_dram = nullptr;
}
#endif
bool OtaUpdater::beginFirmware(size_t filesize, const esp_partition_t * target, bool set_boot) {
_md5_applied = false;
_error = "";
confirmRunningApp();
if (target != nullptr) {
#ifdef OTA_UPDATER_ANY_PARTITION
if (filesize > target->size + 4096) { // allow for the multipart overhead
_error = "Firmware does not fit in the partition";
return false;
}
_dram = static_cast<uint8_t *>(heap_caps_malloc(DRAM_CHUNK, MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT));
if (_dram == nullptr) {
_error = "No internal RAM for OTA buffer";
return false;
}
_target = target;
_written = 0;
_erased = 0;
_set_boot = set_boot;
if (hasMd5()) {
_md5Builder.begin();
_md5_applied = true;
}
return true;
#else
_error = "Writing to a specific partition is not supported";
return false;
#endif
}
if (!Update.begin(filesize - sizeof(esp_image_header_t))) {
return false;
}
if (hasMd5()) {
Update.setMD5(_md5.data());
_md5_applied = true;
}
return true;
}
bool OtaUpdater::beginFilesystem() {
clearMd5(); // so Update.end() doesn't compare against a stale digest
_error = "";
// the HTTP content length is the multipart body size, not the file size, so it can exceed
// the partition by a few hundred bytes. Let the Update library size against the whole partition.
return Update.begin(UPDATE_SIZE_UNKNOWN, U_SPIFFS);
}
bool OtaUpdater::write(const uint8_t * data, size_t len) {
#ifdef OTA_UPDATER_ANY_PARTITION
if (_target != nullptr) {
while (len) {
const size_t n = len < DRAM_CHUNK ? len : DRAM_CHUNK;
memcpy(_dram, data, n);
const size_t need = _written + n;
if (need > _erased) {
size_t erase_to = (need + FLASH_SECTOR - 1) / FLASH_SECTOR * FLASH_SECTOR;
if (erase_to > _target->size) {
erase_to = _target->size;
}
const size_t erase_len = erase_to - _erased;
if (erase_len > 0) {
const esp_err_t err = esp_partition_erase_range(_target, _erased, erase_len);
if (err != ESP_OK) {
setError(err);
return false;
}
_erased = erase_to;
}
}
const esp_err_t err = esp_partition_write(_target, _written, _dram, n);
if (err != ESP_OK) {
setError(err);
return false;
}
if (_md5_applied) {
_md5Builder.add(_dram, n);
}
_written += n;
data += n;
len -= n;
}
return true;
}
#endif
return Update.write(const_cast<uint8_t *>(data), len) == len;
}
bool OtaUpdater::end() {
#ifdef OTA_UPDATER_ANY_PARTITION
if (_target != nullptr) {
const esp_partition_t * target = _target;
const bool set_boot = _set_boot;
releaseTarget();
if (_md5_applied) {
_md5Builder.calculate();
if (strcmp(_md5Builder.toString().c_str(), _md5.data()) != 0) {
_error = "MD5 check failed";
return false;
}
}
esp_image_metadata_t meta = {};
esp_partition_pos_t part_pos = {.offset = target->address, .size = target->size};
esp_err_t err = esp_image_verify(ESP_IMAGE_VERIFY_SILENT, &part_pos, &meta);
if (err != ESP_OK) {
setError(err);
return false;
}
if (set_boot) {
err = esp_ota_set_boot_partition(target);
if (err != ESP_OK) {
setError(err);
return false;
}
}
return true;
}
#endif
return Update.end(true);
}
void OtaUpdater::abort() {
#ifdef OTA_UPDATER_ANY_PARTITION
if (_target != nullptr) {
releaseTarget();
return;
}
#endif
if (Update.isRunning()) {
Update.abort();
}
}
size_t OtaUpdater::progress() const {
#ifdef OTA_UPDATER_ANY_PARTITION
if (_target != nullptr) {
return _written;
}
#endif
return Update.progress();
}
const char * OtaUpdater::errorString() const {
return _error[0] != '\0' ? _error : Update.errorString();
}
+90
View File
@@ -0,0 +1,90 @@
#ifndef OtaUpdater_h
#define OtaUpdater_h
#include <Arduino.h>
#include <esp_ota_ops.h>
#include <array>
#ifdef OTA_UPDATER_ANY_PARTITION
#include <MD5Builder.h>
#endif
// Validates and streams firmware (.bin), LittleFS images (*littlefs.bin) and MD5 digests (.md5) into flash.
// Shared between the EMS-ESP application and the recovery application.
class OtaUpdater {
public:
enum class FileType : uint8_t { UNSUPPORTED, FIRMWARE, FILESYSTEM, JSON, MD5 };
// min_firmware_size is for the main EMS-ESP image. Filenames containing "recovery"
// (case-insensitive) use MIN_RECOVERY_FIRMWARE_SIZE instead.
static FileType classify(const String & filename, size_t filesize, size_t min_firmware_size);
// project name in the app description of the recovery firmware, set by custom_fw_name in platformio.ini
static constexpr char RECOVERY_PROJECT_NAME[] = "EMS-ESP-Recovery";
// recovery firmware is much smaller than the main image (~512 KB–1 MB)
static constexpr size_t MIN_RECOVERY_FIRMWARE_SIZE = 512 * 1024;
// checks the ESP image magic byte and that the chip id matches the chip we're running on
static bool isCompatibleFirmware(const uint8_t * data, size_t len);
// checks the app description at the start of an image, needs the first 112 bytes
static bool isRecoveryFirmware(const uint8_t * data, size_t len);
// fills in desc if the partition holds the recovery firmware
static bool isRecoveryPartition(const esp_partition_t * partition, esp_app_desc_t * desc = nullptr);
// Accepts a raw 32-char hex digest, optionally with trailing newline or a GNU "hash filename" suffix.
static bool parseMd5Digest(const uint8_t * data, size_t len, std::array<char, 33> & out);
// With bootloader rollback a new image boots as pending verification until confirmed, and the
// IDF refuses to start another firmware update until then. Returns true if it was pending.
static bool confirmRunningApp();
bool setMd5(const uint8_t * data, size_t len);
void clearMd5();
bool hasMd5() const;
bool md5Applied() const {
return _md5_applied;
}
const char * md5() const {
return _md5.data();
}
// filesize is the HTTP content length, which is slightly bigger than the image.
// Without a target the image goes to the next OTA partition after the running one. Writing to a
// specific partition needs OTA_UPDATER_ANY_PARTITION, which adds ~1.7KB.
bool beginFirmware(size_t filesize, const esp_partition_t * target = nullptr, bool set_boot = true);
bool beginFilesystem();
bool write(const uint8_t * data, size_t len);
bool end(); // on success the new firmware becomes the boot partition, unless set_boot was false
void abort();
size_t progress() const;
const char * errorString() const;
private:
std::array<char, 33> _md5{};
bool _md5_applied = false;
const char * _error = "";
#ifdef OTA_UPDATER_ANY_PARTITION
// explicit target (factory/boot or an OTA slot) is written with the partition API;
// esp_ota_begin() returns ESP_ERR_INVALID_STATE (0x102) on the factory partition
const esp_partition_t * _target = nullptr;
size_t _written = 0;
size_t _erased = 0;
bool _set_boot = true;
MD5Builder _md5Builder;
// AsyncTCP may hand us PSRAM pointers; ESP32 flash DMA needs internal RAM
uint8_t * _dram = nullptr;
static constexpr size_t DRAM_CHUNK = 1024;
static constexpr size_t FLASH_SECTOR = 4096;
char _error_buf[48]{};
void setError(esp_err_t err);
void releaseTarget();
#endif
};
#endif
+7 -5
View File
@@ -13,7 +13,7 @@ using espMqttClientInternals::PacketType;
using espMqttClientTypes::DisconnectReason;
using espMqttClientTypes::Error;
MqttClient::MqttClient(espMqttClientTypes::UseInternalTask useInternalTask, uint8_t priority, uint8_t core)
MqttClient::MqttClient(espMqttClientTypes::UseInternalTask useInternalTask, uint8_t priority, uint8_t core, uint32_t stackSize)
: _useInternalTask(useInternalTask)
, _transport(nullptr)
, _onConnectCallback(nullptr)
@@ -55,6 +55,7 @@ MqttClient::MqttClient(espMqttClientTypes::UseInternalTask useInternalTask, uint
, _disconnectReason(DisconnectReason::TCP_DISCONNECTED)
#if defined(ARDUINO_ARCH_ESP32) && ARDUHAL_LOG_LEVEL >= ARDUHAL_LOG_LEVEL_INFO
, _highWaterMark(4294967295)
, _stackSize(stackSize)
#endif
{
EMC_GENERATE_CLIENTID(_generatedClientId);
@@ -64,12 +65,12 @@ MqttClient::MqttClient(espMqttClientTypes::UseInternalTask useInternalTask, uint
if (_useInternalTask == espMqttClientTypes::UseInternalTask::YES) {
#ifdef CONFIG_FREERTOS_UNICORE
// no core to pin to on single-core targets (S2, C3, C6)
xTaskCreate((TaskFunction_t)_loop, "mqttclient", EMC_TASK_STACK_SIZE, this, priority, &_taskHandle);
xTaskCreate((TaskFunction_t)_loop, "mqttclient", stackSize, this, priority, &_taskHandle);
#else
if (core > 1) {
xTaskCreate((TaskFunction_t)_loop, "mqttclient", EMC_TASK_STACK_SIZE, this, priority, &_taskHandle);
xTaskCreate((TaskFunction_t)_loop, "mqttclient", stackSize, this, priority, &_taskHandle);
} else {
xTaskCreatePinnedToCore((TaskFunction_t)_loop, "mqttclient", EMC_TASK_STACK_SIZE, this, priority, &_taskHandle, core);
xTaskCreatePinnedToCore((TaskFunction_t)_loop, "mqttclient", stackSize, this, priority, &_taskHandle, core);
}
#endif
}
@@ -77,6 +78,7 @@ MqttClient::MqttClient(espMqttClientTypes::UseInternalTask useInternalTask, uint
(void) useInternalTask;
(void) priority;
(void) core;
(void) stackSize;
#endif
_clientId = _generatedClientId;
_core = core;
@@ -320,7 +322,7 @@ void MqttClient::loop() {
size_t waterMark = uxTaskGetStackHighWaterMark(NULL);
if (waterMark < _highWaterMark) {
_highWaterMark = waterMark;
emc_log_i("Stack usage: %zu/%i", EMC_TASK_STACK_SIZE - _highWaterMark, EMC_TASK_STACK_SIZE);
emc_log_i("Stack usage: %zu/%zu", _stackSize - _highWaterMark, _stackSize);
}
#endif
}
+2 -1
View File
@@ -81,7 +81,7 @@ class MqttClient {
}
protected:
explicit MqttClient(espMqttClientTypes::UseInternalTask useInternalTask, uint8_t priority = 1, uint8_t core = 1);
explicit MqttClient(espMqttClientTypes::UseInternalTask useInternalTask, uint8_t priority = 1, uint8_t core = 1, uint32_t stackSize = EMC_TASK_STACK_SIZE);
espMqttClientTypes::UseInternalTask _useInternalTask;
espMqttClientInternals::Transport* _transport;
@@ -207,6 +207,7 @@ class MqttClient {
#if defined(ARDUINO_ARCH_ESP32)
#if ARDUHAL_LOG_LEVEL >= ARDUHAL_LOG_LEVEL_INFO
size_t _highWaterMark;
size_t _stackSize;
#endif
#endif
};
+2 -2
View File
@@ -208,8 +208,8 @@ class MqttClientSetup : public MqttClient {
*/
protected:
explicit MqttClientSetup(espMqttClientTypes::UseInternalTask useInternalTask, uint8_t priority = 1, uint8_t core = 1)
: MqttClient(useInternalTask, priority, core) {
explicit MqttClientSetup(espMqttClientTypes::UseInternalTask useInternalTask, uint8_t priority = 1, uint8_t core = 1, uint32_t stackSize = EMC_TASK_STACK_SIZE)
: MqttClient(useInternalTask, priority, core, stackSize) {
#if EMC_MULTIPLE_CALLBACKS
_onConnectCallback = [this](bool sessionPresent) {
for (auto callback : _onConnectCallbacks) if (callback.first) callback.first(sessionPresent);
+4 -4
View File
@@ -15,8 +15,8 @@ espMqttClient::espMqttClient(espMqttClientTypes::UseInternalTask useInternalTask
_transport = &_client;
}
espMqttClient::espMqttClient(uint8_t priority, uint8_t core)
: MqttClientSetup(espMqttClientTypes::UseInternalTask::YES, priority, core)
espMqttClient::espMqttClient(uint8_t priority, uint8_t core, uint32_t stackSize)
: MqttClientSetup(espMqttClientTypes::UseInternalTask::YES, priority, core, stackSize)
, _client() {
_transport = &_client;
}
@@ -27,8 +27,8 @@ espMqttClientSecure::espMqttClientSecure(espMqttClientTypes::UseInternalTask use
_transport = &_client;
}
espMqttClientSecure::espMqttClientSecure(uint8_t priority, uint8_t core)
: MqttClientSetup(espMqttClientTypes::UseInternalTask::YES, priority, core)
espMqttClientSecure::espMqttClientSecure(uint8_t priority, uint8_t core, uint32_t stackSize)
: MqttClientSetup(espMqttClientTypes::UseInternalTask::YES, priority, core, stackSize)
, _client() {
_transport = &_client;
}
+2 -2
View File
@@ -48,7 +48,7 @@ class espMqttClientSecure : public MqttClientSetup<espMqttClientSecure> {
class espMqttClient : public MqttClientSetup<espMqttClient> {
public:
explicit espMqttClient(espMqttClientTypes::UseInternalTask useInternalTask);
explicit espMqttClient(uint8_t priority = 1, uint8_t core = 1);
explicit espMqttClient(uint8_t priority = 1, uint8_t core = 1, uint32_t stackSize = EMC_TASK_STACK_SIZE);
protected:
espMqttClientInternals::ClientSync _client;
@@ -57,7 +57,7 @@ class espMqttClient : public MqttClientSetup<espMqttClient> {
class espMqttClientSecure : public MqttClientSetup<espMqttClientSecure> {
public:
explicit espMqttClientSecure(espMqttClientTypes::UseInternalTask useInternalTask);
explicit espMqttClientSecure(uint8_t priority = 1, uint8_t core = 1);
explicit espMqttClientSecure(uint8_t priority = 1, uint8_t core = 1, uint32_t stackSize = EMC_TASK_STACK_SIZE);
espMqttClientSecure & setInsecure();
espMqttClientSecure & setCACert(const char * rootCA);
espMqttClientSecure & setCertificate(const char * clientCa);
+9
View File
@@ -20,6 +20,8 @@
#include <Arduino.h>
#include <sys/time.h>
namespace uuid {
static uint64_t now_millis = 0;
@@ -41,4 +43,11 @@ uint32_t get_uptime() {
return (uint32_t)now_millis;
}
// both clocks are read live and subtracted in us, rounding each to ms or s first makes the offset jitter
int64_t get_wall_offset_ms() {
struct timeval tv;
gettimeofday(&tv, nullptr);
return ((int64_t)tv.tv_sec * 1000000 + tv.tv_usec - esp_timer_get_time()) / 1000;
}
} // namespace uuid
+3
View File
@@ -105,6 +105,9 @@ uint64_t get_uptime_ms();
uint32_t get_uptime();
uint32_t get_uptime_sec();
// milliseconds to add to an uptime in ms to get the wall clock time in ms since the epoch
int64_t get_wall_offset_ms();
void set_uptime();
} // namespace uuid
+6 -5
View File
@@ -106,14 +106,15 @@ void Shell::output_logs() {
}
while (1) {
time_t offset = time(nullptr) - uuid::get_uptime_sec();
if (offset < 1500000000L) {
int64_t offset_ms = uuid::get_wall_offset_ms();
if (offset_ms < 1500000000000LL) {
print(uuid::log::format_timestamp_ms(message.content_->uptime_ms, 3));
} else {
time_t t1 = offset + (time_t)(message.content_->uptime_ms / 1000);
char timestr[25];
int64_t wall_ms = offset_ms + (int64_t)message.content_->uptime_ms;
time_t t1 = (time_t)(wall_ms / 1000);
char timestr[25];
strftime(timestr, 25, "%FT%T", localtime(&t1));
printf("%s.%03d", timestr, (uint16_t)(message.content_->uptime_ms % 1000));
printf("%s.%03d", timestr, (int)(wall_ms % 1000));
}
printf(" %c %lu: [%s] ", uuid::log::format_level_char(message.content_->level), message.id_, message.content_->name);
-2
View File
@@ -148,7 +148,5 @@ double ledcSetup(uint8_t chan, double freq, uint8_t bit_num) {
void ledcAttachPin(uint8_t pin, uint8_t chan) {};
void ledcAttach(uint8_t pin, uint8_t chan, uint8_t bit_num) {};
void ledcWrite(uint8_t chan, uint32_t duty) {};
void neopixelWrite(uint8_t pin, uint8_t red_val, uint8_t green_val, uint8_t blue_val) {};
void rgbLedWrite(uint8_t pin, uint8_t red_val, uint8_t green_val, uint8_t blue_val) {};
#endif
-2
View File
@@ -83,8 +83,6 @@ double ledcSetup(uint8_t chan, double freq, uint8_t bit_num);
void ledcAttachPin(uint8_t pin, uint8_t chan);
void ledcAttach(uint8_t pin, uint8_t chan, uint8_t bit_num);
void ledcWrite(uint8_t chan, uint32_t duty);
void neopixelWrite(uint8_t pin, uint8_t red_val, uint8_t green_val, uint8_t blue_val);
void rgbLedWrite(uint8_t pin, uint8_t red_val, uint8_t green_val, uint8_t blue_val);
inline int rtc_get_reset_reason(int cpu_no) {
return 0;
+1 -1
View File
@@ -1,6 +1,6 @@
/*
* EMS-ESP - https://github.com/emsesp/EMS-ESP
* Copyright 2020-2025 emsesp.org
* Copyright 2020-2026 emsesp.org
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
+1 -1
View File
@@ -1,6 +1,6 @@
/*
* EMS-ESP - https://github.com/emsesp/EMS-ESP
* Copyright 2020-2025 emsesp.org
* Copyright 2020-2026 emsesp.org
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
+1 -1
View File
@@ -1,6 +1,6 @@
/*
* EMS-ESP - https://github.com/emsesp/EMS-ESP
* Copyright 2020-2025 emsesp.org
* Copyright 2020-2026 emsesp.org
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
+1 -1
View File
@@ -1,6 +1,6 @@
/*
* EMS-ESP - https://github.com/emsesp/EMS-ESP
* Copyright 2020-2025 emsesp.org
* Copyright 2020-2026 emsesp.org
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
+2 -2
View File
@@ -15,7 +15,7 @@
},
"devDependencies": {
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
"prettier": "^3.9.8"
"prettier": "^3.9.10"
},
"packageManager": "pnpm@12.6.0"
"packageManager": "pnpm@12.15.0"
}
+93 -93
View File
@@ -7,153 +7,153 @@ importers:
configDependencies: {}
packageManagerDependencies:
pnpm:
specifier: 12.6.0
version: 12.6.0
specifier: 12.15.0
version: 12.15.0
packages:
'@pnpm/exe.android-arm64@12.6.0':
resolution: {integrity: sha512-kviIHft9h02q+7N2In7tSL9T/HRUSGwYmU74YPtsWU5ee7vgqPyC0JT6RRX6miW+NxX1CvHgQxg69qq1qTLJYQ==}
'@pnpm/exe.android-arm64@12.15.0':
resolution: {integrity: sha512-eVCqmscY70LGvgRzu3mi8IBWBFGix+a6mNZoWmXxESsBdeiKEF9xQSuaeel10yn+zI7TXy2jlI63vFLzETk+aQ==}
cpu: [arm64]
os: [android]
'@pnpm/exe.android-x64@12.6.0':
resolution: {integrity: sha512-CT8aJKLq2mtZFE71pr4E5Z2xHL8uGnRGr+NclDqsXlVF4SVcQ2QAs14mWi0C8gjT4hbmi+Q0lJRRIkMAWfjjng==}
'@pnpm/exe.android-x64@12.15.0':
resolution: {integrity: sha512-Gx/WbkFcdUFUg8g8AShezcS9EiQxfYvdAm2hfKLO8sfJmJvKOCt5fzlgHWDzmGhoclibgW+tUGZ/KF4F4wOTCA==}
cpu: [x64]
os: [android]
'@pnpm/exe.darwin-arm64@12.6.0':
resolution: {integrity: sha512-rafpVkjzugKBMxSvGQd5wK1x5eOTkD/+lcJIFHeQhvd+BJ8kDisOOvwhQDrpGd4vd2Fx+hhW0P2Ptt638OlhFg==}
'@pnpm/exe.darwin-arm64@12.15.0':
resolution: {integrity: sha512-N0ZykiDWCvqSBJvt71j7cSoBR993FqvYZFtmpCtqvW3pguiI+NAgraLPA5OTwu5mV+/d8hYpVcPktJA3xFDLtA==}
cpu: [arm64]
os: [darwin]
'@pnpm/exe.darwin-x64@12.6.0':
resolution: {integrity: sha512-72Jpuv1m24gI8zUNcaylYpBWEhBzz1VPhnDIU2GYM2eRP+KsOqVLwV78i+JrihI39zY2NNXmaN4eAKX1inpaDQ==}
'@pnpm/exe.darwin-x64@12.15.0':
resolution: {integrity: sha512-7Ntr2HlBUxzwWOg7NwDxCbRW/3vXuqKO+hcXm+w3SBi9rrHY53li1wLqWVT7Gjxd+lVEHM1QJZo6WJEGFU8C6A==}
cpu: [x64]
os: [darwin]
'@pnpm/exe.freebsd-x64@12.6.0':
resolution: {integrity: sha512-LON4QgNy1w/XF4uySZIyC/5hEB0oGdelXsY9tSKqstER+2j2X2oHz67skD11RRd/HWFRtMr64shQZ4I9ZoZ0zg==}
'@pnpm/exe.freebsd-x64@12.15.0':
resolution: {integrity: sha512-btOiyL7fL7W4uQGH0aqFai2TNC3IoKcZ7gNHBVbEFZt2dWrnrD0SufL3iGs3fLlmb4OtuU35uf4Zpr9V9Jh9mA==}
cpu: [x64]
os: [freebsd]
'@pnpm/exe.linux-arm64-musl@12.6.0':
resolution: {integrity: sha512-BdDpX+DeaMUc5x6WNcE6FkmFy36NwrBbBvwsZU737Zt3u1fTY+iKFPchjEQNCM4rT6q6xvF2oN3ZwExGsJCkxQ==}
'@pnpm/exe.linux-arm64-musl@12.15.0':
resolution: {integrity: sha512-pNC38k+O+90FOjbPNSnw0E7apq1PKTmz3fFe1amn+lys/U5kL++uzir8bRiho8ONcyA3d9EU7wdqcY0DKkj+JA==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@pnpm/exe.linux-arm64@12.6.0':
resolution: {integrity: sha512-8h2sNoIhHDpHDYqZZCh9PXr6krqW10btb0GHVGuw710muDHlFAHMtUG2ogmaHge6zJG1xgY4Y7mwX5g/LWvrYg==}
'@pnpm/exe.linux-arm64@12.15.0':
resolution: {integrity: sha512-qML/8aCXslh8td/w4U+HJNqUcZ2+m6EfTbWScIzZRmyPsRFN+xB4LP1U/hymxc1iSJiKtG56HJx2fvUErWFIGw==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@pnpm/exe.linux-ppc64@12.6.0':
resolution: {integrity: sha512-ld6xgcEhFCFrsVsJhbmdmR714PnsEspO/Ij8w0/D7OSiz2hYGu3fZd5tKRxOI5m6H6Gl0/RRFAPncTHbsgXVjQ==}
'@pnpm/exe.linux-ppc64@12.15.0':
resolution: {integrity: sha512-tjRDUUl7RCJ6T/6eoNPxOqkwU4Mzi5RtTXmSAxujyz37OTwwsMU7Ps087Zk4gHcUvRSeyF40ZFsew3vtE+nmUA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@pnpm/exe.linux-riscv64@12.6.0':
resolution: {integrity: sha512-HIbImydoC3J8NFt+8MXfhTCf5rO6NjCMyinSAHac0VmV8uCHw+kbQkyAzxV84rzz9ZOzmBOnj0b1N461UIYGsw==}
'@pnpm/exe.linux-riscv64@12.15.0':
resolution: {integrity: sha512-bF1uBWEfbwaeryz6lIiaJ8AWD082otZfvzGX5l+6LZRpRwUEpJHUsJrORaYP5/cNMv5ZR2p6TvvcJcQzwtDzmw==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@pnpm/exe.linux-s390x@12.6.0':
resolution: {integrity: sha512-DjSqT7+BZ/lWcDHwNMuzsRVfyxUimh+D54Xw4tSWbdVVQhtWtNMUyI93LVnwCmsoEpar2gC3SFeAgywx37svKQ==}
'@pnpm/exe.linux-s390x@12.15.0':
resolution: {integrity: sha512-zKMhIzsexYMToeLmqV5jPsvV/f9vwsJb2kUH2NFYzalbxImeBrlA4kL7GwF8gD4pagV6jYYcJb8RjbZIdjHptg==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@pnpm/exe.linux-x64-musl@12.6.0':
resolution: {integrity: sha512-31lKeGPmRE6xfV6I3VjaEGVFRei14pl/zgoFDK4w+UkbJqOuNl2Ht3O1GuqNIg3Gl14qTEb4kZRAYFHXifDx9A==}
'@pnpm/exe.linux-x64-musl@12.15.0':
resolution: {integrity: sha512-A9J0Jg0Rdx+qQU4qXtwyST5tto9IOu1zi4fDk35nbc6DWahggbcKtFvZPKFwL2qgUXGXOX//hXv6z/sp6emO8A==}
cpu: [x64]
os: [linux]
libc: [musl]
'@pnpm/exe.linux-x64@12.6.0':
resolution: {integrity: sha512-qFWBneHJAJ73W4whtbaFOL1M/7DBC6ILHXuxc7ZPtEhfPuT1zeZiGrmKHoMAfJA+mcm6xhOFljqVTUS+00Jabw==}
'@pnpm/exe.linux-x64@12.15.0':
resolution: {integrity: sha512-yc+QlZR+9+nr29J4fRzhMoapgxTBDndq/+peyoq1XUufnQFnfbXUMFudDIKzHi545linGqmOmXfn0NoufNgcOg==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@pnpm/exe.win32-arm64@12.6.0':
resolution: {integrity: sha512-OhfefXEEykZlslSUhR8PPRe6MVPV3Oink/dVfIecE5velpc7j80cZYsHlaDrtRDcm3TPwQHkaRW1SJ1efX9oFg==}
'@pnpm/exe.win32-arm64@12.15.0':
resolution: {integrity: sha512-fPXZxuY1VclkOga9bc53qNCXcegfeoBR8+Ue+AospDwKzuGGSHll0oDxkLfkmpjyRqpe7bH5DyQPYp/xSMaIVA==}
cpu: [arm64]
os: [win32]
'@pnpm/exe.win32-x64@12.6.0':
resolution: {integrity: sha512-L2tuyrD2+Imgxs3VK/ST/2L3xD6vDP0ktJQxM0TaGw7TbAQEMq+RMc8iK6Ew7Id579uzjJe8jSfJID2ZGsOHCA==}
'@pnpm/exe.win32-x64@12.15.0':
resolution: {integrity: sha512-nrd+Tp1We1DGj4DGG5Bea9lZNh9y1PjlLBdEwdInS4D0h0UIDtM7JaGGGDJhNzIwcHASyihWI7HLV+dtG+fhYg==}
cpu: [x64]
os: [win32]
pnpm@12.6.0:
resolution: {integrity: sha512-PvaPlRyxEawgS0paFvCy3fDaVqluBBPoHYVdnwtV75JnFHCQKOHNAMQFwsX7e56OxNxGd3yAXQNzwvL/AP0g7A==}
engines: {node: '>=18.*'}
pnpm@12.15.0:
resolution: {integrity: sha512-s5UdbW/0qfE9x89WzSjSuOs328ycaOUuHhGRhz6p+CoBcoerULqtxczElPA2xnwOR+sTYIwvo29HcRth6hKOYQ==}
engines: {node: '>=18'}
hasBin: true
snapshots:
'@pnpm/exe.android-arm64@12.6.0':
'@pnpm/exe.android-arm64@12.15.0':
optional: true
'@pnpm/exe.android-x64@12.6.0':
'@pnpm/exe.android-x64@12.15.0':
optional: true
'@pnpm/exe.darwin-arm64@12.6.0':
'@pnpm/exe.darwin-arm64@12.15.0':
optional: true
'@pnpm/exe.darwin-x64@12.6.0':
'@pnpm/exe.darwin-x64@12.15.0':
optional: true
'@pnpm/exe.freebsd-x64@12.6.0':
'@pnpm/exe.freebsd-x64@12.15.0':
optional: true
'@pnpm/exe.linux-arm64-musl@12.6.0':
'@pnpm/exe.linux-arm64-musl@12.15.0':
optional: true
'@pnpm/exe.linux-arm64@12.6.0':
'@pnpm/exe.linux-arm64@12.15.0':
optional: true
'@pnpm/exe.linux-ppc64@12.6.0':
'@pnpm/exe.linux-ppc64@12.15.0':
optional: true
'@pnpm/exe.linux-riscv64@12.6.0':
'@pnpm/exe.linux-riscv64@12.15.0':
optional: true
'@pnpm/exe.linux-s390x@12.6.0':
'@pnpm/exe.linux-s390x@12.15.0':
optional: true
'@pnpm/exe.linux-x64-musl@12.6.0':
'@pnpm/exe.linux-x64-musl@12.15.0':
optional: true
'@pnpm/exe.linux-x64@12.6.0':
'@pnpm/exe.linux-x64@12.15.0':
optional: true
'@pnpm/exe.win32-arm64@12.6.0':
'@pnpm/exe.win32-arm64@12.15.0':
optional: true
'@pnpm/exe.win32-x64@12.6.0':
'@pnpm/exe.win32-x64@12.15.0':
optional: true
pnpm@12.6.0:
pnpm@12.15.0:
optionalDependencies:
'@pnpm/exe.android-arm64': 12.6.0
'@pnpm/exe.android-x64': 12.6.0
'@pnpm/exe.darwin-arm64': 12.6.0
'@pnpm/exe.darwin-x64': 12.6.0
'@pnpm/exe.freebsd-x64': 12.6.0
'@pnpm/exe.linux-arm64': 12.6.0
'@pnpm/exe.linux-arm64-musl': 12.6.0
'@pnpm/exe.linux-ppc64': 12.6.0
'@pnpm/exe.linux-riscv64': 12.6.0
'@pnpm/exe.linux-s390x': 12.6.0
'@pnpm/exe.linux-x64': 12.6.0
'@pnpm/exe.linux-x64-musl': 12.6.0
'@pnpm/exe.win32-arm64': 12.6.0
'@pnpm/exe.win32-x64': 12.6.0
'@pnpm/exe.android-arm64': 12.15.0
'@pnpm/exe.android-x64': 12.15.0
'@pnpm/exe.darwin-arm64': 12.15.0
'@pnpm/exe.darwin-x64': 12.15.0
'@pnpm/exe.freebsd-x64': 12.15.0
'@pnpm/exe.linux-arm64': 12.15.0
'@pnpm/exe.linux-arm64-musl': 12.15.0
'@pnpm/exe.linux-ppc64': 12.15.0
'@pnpm/exe.linux-riscv64': 12.15.0
'@pnpm/exe.linux-s390x': 12.15.0
'@pnpm/exe.linux-x64': 12.15.0
'@pnpm/exe.linux-x64-musl': 12.15.0
'@pnpm/exe.win32-arm64': 12.15.0
'@pnpm/exe.win32-x64': 12.15.0
---
lockfileVersion: '9.0'
@@ -175,10 +175,10 @@ importers:
devDependencies:
'@trivago/prettier-plugin-sort-imports':
specifier: ^6.0.2
version: 6.0.2(prettier@3.9.8)
version: 6.0.2(prettier@3.9.10)
prettier:
specifier: ^3.9.8
version: 3.9.8
specifier: ^3.9.10
version: 3.9.10
packages:
@@ -194,16 +194,16 @@ packages:
resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==}
engines: {node: '>=6.9.0'}
'@babel/helper-string-parser@7.29.7':
resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==}
'@babel/helper-string-parser@7.29.11':
resolution: {integrity: sha512-snZbO7MEDOof+hvNa97EjEY7qD7h2kwvQr5AJC3ZhQudKW5dnej6k3kP9NQV88hJwaS8vDqRLFo9rivLEhle2g==}
engines: {node: '>=6.9.0'}
'@babel/helper-validator-identifier@7.29.7':
resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==}
engines: {node: '>=6.9.0'}
'@babel/parser@7.29.9':
resolution: {integrity: sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==}
'@babel/parser@7.29.11':
resolution: {integrity: sha512-OZ6+oaK7GMyg3IfCpj8JOmn8n+w2rJbDT0LLfVDTLb1ZFeKDh3AiNELgCMUw175IJHSkLl74hG0M0VLsvx+gag==}
engines: {node: '>=6.0.0'}
hasBin: true
@@ -211,12 +211,12 @@ packages:
resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==}
engines: {node: '>=6.9.0'}
'@babel/traverse@7.29.8':
resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==}
'@babel/traverse@7.29.10':
resolution: {integrity: sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ==}
engines: {node: '>=6.9.0'}
'@babel/types@7.29.8':
resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==}
'@babel/types@7.29.11':
resolution: {integrity: sha512-WCsemW40Cpc6gouC1SrY6PBaZ+oLRlEVY9XBvZBI7il/HyUrb2nJ505wE1MHEFyDZNnrWDEuy4jL50WVHfTTSQ==}
engines: {node: '>=6.9.0'}
'@jridgewell/gen-mapping@0.3.13':
@@ -303,8 +303,8 @@ packages:
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
prettier@3.9.8:
resolution: {integrity: sha512-WRFq3Wn3WId7LLROfMLdH7xaFr2jR62wU8nLO6rQUOLOxNZUviyJQs1M0iIhLexSFy+L+w0ch66wtoO2jRjG0A==}
prettier@3.9.10:
resolution: {integrity: sha512-skGHTH1Yb9QvaTC4hWnRTzEYtCpApF0UMBPz+t/0ZPzP8ihefo/btKd+QA7pUE0fEwA1SbS/NmhyEcstdXOIdA==}
engines: {node: '>=14'}
hasBin: true
@@ -318,43 +318,43 @@ snapshots:
'@babel/generator@7.29.8':
dependencies:
'@babel/parser': 7.29.9
'@babel/types': 7.29.8
'@babel/parser': 7.29.11
'@babel/types': 7.29.11
'@jridgewell/gen-mapping': 0.3.13
'@jridgewell/trace-mapping': 0.3.31
jsesc: 3.1.0
'@babel/helper-globals@7.29.7': {}
'@babel/helper-string-parser@7.29.7': {}
'@babel/helper-string-parser@7.29.11': {}
'@babel/helper-validator-identifier@7.29.7': {}
'@babel/parser@7.29.9':
'@babel/parser@7.29.11':
dependencies:
'@babel/types': 7.29.8
'@babel/types': 7.29.11
'@babel/template@7.29.7':
dependencies:
'@babel/code-frame': 7.29.7
'@babel/parser': 7.29.9
'@babel/types': 7.29.8
'@babel/parser': 7.29.11
'@babel/types': 7.29.11
'@babel/traverse@7.29.8':
'@babel/traverse@7.29.10':
dependencies:
'@babel/code-frame': 7.29.7
'@babel/generator': 7.29.8
'@babel/helper-globals': 7.29.7
'@babel/parser': 7.29.9
'@babel/parser': 7.29.11
'@babel/template': 7.29.7
'@babel/types': 7.29.8
'@babel/types': 7.29.11
debug: 4.4.3
transitivePeerDependencies:
- supports-color
'@babel/types@7.29.8':
'@babel/types@7.29.11':
dependencies:
'@babel/helper-string-parser': 7.29.7
'@babel/helper-string-parser': 7.29.11
'@babel/helper-validator-identifier': 7.29.7
'@jridgewell/gen-mapping@0.3.13':
@@ -373,17 +373,17 @@ snapshots:
'@msgpack/msgpack@3.1.3': {}
'@trivago/prettier-plugin-sort-imports@6.0.2(prettier@3.9.8)':
'@trivago/prettier-plugin-sort-imports@6.0.2(prettier@3.9.10)':
dependencies:
'@babel/generator': 7.29.8
'@babel/parser': 7.29.9
'@babel/traverse': 7.29.8
'@babel/types': 7.29.8
'@babel/parser': 7.29.11
'@babel/traverse': 7.29.10
'@babel/types': 7.29.11
javascript-natural-sort: 0.7.1
lodash-es: 4.18.1
minimatch: 9.0.9
parse-imports-exports: 0.2.4
prettier: 3.9.8
prettier: 3.9.10
transitivePeerDependencies:
- supports-color
@@ -421,4 +421,4 @@ snapshots:
picocolors@1.1.1: {}
prettier@3.9.8: {}
prettier@3.9.10: {}
+221
View File
@@ -0,0 +1,221 @@
// Vite plugin that mocks the EMS-ESP Recovery firmware REST API, used by `pnpm recovery`
// Sign in with admin/admin. Uploading any *.bin >= 512KB "flashes" it into the next OTA partition.
// It starts as if an update in app1 failed to start and the bootloader fell back to the recovery.
const MIN_FIRMWARE_SIZE = 512 * 1024;
const RESTART_DOWNTIME_MS = 4000;
const TOKEN = 'mock-recovery-token';
const MB = 1048576;
const state = {
token: null,
boot: 'boot',
downUntil: 0,
md5: null,
partitions: [
{
label: 'boot',
factory: true,
address: 0x10000,
size: 0x480000,
valid: true,
image_size: 0.8 * MB,
version: '1.0.0',
install_date: 0,
ota_state: ''
},
{
label: 'app0',
factory: false,
address: 0x490000,
size: 0x490000,
valid: true,
image_size: 1.86 * MB,
version: '3.8.1',
install_date: 1767225600,
ota_state: 'valid'
},
{
label: 'app1',
factory: false,
address: 0x920000,
size: 0x490000,
valid: true,
image_size: 1.91 * MB,
version: '3.9.0-dev.16',
install_date: 1790000000,
ota_state: 'aborted'
}
]
};
const failedToStart = (p) => p.ota_state === 'aborted' || p.ota_state === 'invalid';
const started = Date.now();
// same rule as the firmware: an empty partition, then a failed one, otherwise the oldest install date
const nextUploadTarget = () => {
const ota = state.partitions.filter((p) => !p.factory);
const rank = (p) => (!p.valid ? 0 : failedToStart(p) ? 1 : 2);
return ota.reduce((a, b) =>
rank(b) < rank(a) || (rank(b) === rank(a) && b.install_date < a.install_date)
? b
: a
).label;
};
const status = (authenticated) => ({
recovery_version: '1.0.0',
authenticated,
hostname: 'ems-esp',
chip: 'ESP32', // a wired E32V2 with a static IP, WiFi is not used
flash_size: 16 * MB,
psram_size: 8 * MB,
free_heap: 245760,
mac: '24:0A:C4:00:00:01',
uptime: Math.floor((Date.now() - started) / 1000),
reason: '',
reset_reason: 'panic',
ap_ssid: 'ems-esp-recovery',
ap_ip: '192.168.4.1',
ap_clients: 1,
wifi_ssid: 'MyHomeWiFi',
wifi_started: false,
wifi_connected: false,
static_ip: true,
disable_reset: false, // set to true to test with the factory reset disabled
eth_state: 'connected',
eth_ip: '192.168.1.50',
running: 'boot',
boot: state.boot,
upload_target: nextUploadTarget(),
partitions: state.partitions
});
const readBody = (req) =>
new Promise((resolve, reject) => {
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => resolve(Buffer.concat(chunks)));
req.on('error', reject);
});
const send = (res, code, body) => {
res.statusCode = code;
if (body !== undefined) {
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify(body));
} else {
res.end();
}
};
const isAuthorized = (req) =>
state.token && req.headers.authorization === `Bearer ${state.token}`;
const restart = () => {
state.token = null;
state.downUntil = Date.now() + RESTART_DOWNTIME_MS;
};
const uploadedFilename = (body) => {
const match = /filename="([^"]+)"/.exec(body.subarray(0, 1024).toString());
return match ? match[1] : '';
};
export default function recoveryMock() {
return {
name: 'recovery-mock',
configureServer(server) {
server.middlewares.use(async (req, res, next) => {
const [url, query] = req.url.split('?');
const params = new URLSearchParams(query ?? '');
if (!url.startsWith('/rest/')) {
return next();
}
if (Date.now() < state.downUntil) {
return send(res, 503);
}
if (url === '/rest/recovery/status' && req.method === 'GET') {
return send(res, 200, status(!!isAuthorized(req)));
}
if (url === '/rest/signIn' && req.method === 'POST') {
const { username, password } = JSON.parse(
(await readBody(req)).toString() || '{}'
);
if (username === 'admin' && password === 'admin') {
state.token = TOKEN;
return send(res, 200, { access_token: TOKEN });
}
return send(res, 401);
}
if (!isAuthorized(req)) {
await readBody(req);
return send(res, 401);
}
if (url === '/rest/recovery/restart' && req.method === 'POST') {
send(res, 200);
return restart();
}
if (url === '/rest/recovery/format' && req.method === 'POST') {
if (status(true).disable_reset) return send(res, 403);
send(res, 200);
return restart();
}
if (url === '/rest/recovery/boot' && req.method === 'POST') {
const { partition } = JSON.parse((await readBody(req)).toString() || '{}');
const p = state.partitions.find((x) => x.label === partition);
if (!p) return send(res, 404);
if (!p.valid) return send(res, 400);
if (!p.factory) p.ota_state = 'new';
state.boot = partition;
send(res, 200);
return restart();
}
if (url === '/rest/uploadFile' && req.method === 'POST') {
const body = await readBody(req);
const filename = uploadedFilename(body);
if (filename.endsWith('.md5')) {
state.md5 = '0123456789abcdef0123456789abcdef';
return send(res, 200, { md5: state.md5 });
}
if (
!filename.endsWith('.bin') ||
filename.endsWith('littlefs.bin') ||
body.length < MIN_FIRMWARE_SIZE
) {
return send(res, 406);
}
if (filename.startsWith('EMS-ESP-Recovery')) {
return send(res, 409);
}
const target = state.partitions.find(
(x) => x.label === (params.get('partition') ?? nextUploadTarget())
);
if (!target || target.factory) return send(res, 400);
Object.assign(target, {
valid: true,
image_size: body.length,
version: '',
install_date: Math.floor(Date.now() / 1000),
ota_state: 'new'
});
state.boot = target.label;
const md5_ok = !!state.md5;
state.md5 = null;
return md5_ok ? send(res, 200, { md5_ok }) : send(res, 200);
}
return send(res, 404);
});
}
};
}
+79 -14
View File
@@ -103,7 +103,7 @@ let system_status = {
cpu_cores: 2,
cpu_freq_mhz: 240,
max_alloc_heap: 191,
arduino_version: 'Tasmota Arduino v3.3.8',
arduino_version: 'Arduino v3.3.8',
sdk_version: '5.5.4.260407',
partition: 'app0',
flash_chip_size: 16384,
@@ -116,7 +116,8 @@ let system_status = {
psram: true,
psram_size: 8189,
free_psram: 8166,
has_loader: true,
recovery_version: '', // upload an EMS-ESP-Recovery-*.bin to set it
// recovery_version: '1.0.0',
has_partition: true,
partitions: [
{
@@ -151,12 +152,12 @@ let system_status = {
let DEV_VERSION_IS_UPGRADEABLE: boolean;
let STABLE_VERSION_IS_UPGRADEABLE: boolean;
let THIS_VERSION: string;
let LATEST_STABLE_VERSION = '3.8.4';
let LATEST_STABLE_VERSION = '3.8.6';
let LATEST_DEV_VERSION = '3.9.0-dev.1';
// scenarios for testing versioning
let version_test = 0; // on latest stable, or switch to dev
// let version_test = 1; // on latest dev, or switch back to stable
// let version_test = 0; // on latest stable, or switch to dev
let version_test = 1; // on latest dev, or switch back to stable
// let version_test = 2; // upgrade an older stable to latest stable or switch to latest dev
// let version_test = 3; // upgrade dev to latest, or switch to stable
// let version_test = 4; // downgrade to an older dev, or switch back to stable
@@ -209,7 +210,7 @@ switch (emulate_esp) {
case 'ESP32':
system_status.esp_platform = 'ESP32';
system_status.cpu_type = 'ESP32-D0WD-V3';
system_status.arduino_version = 'Tasmota Arduino v3.3.8';
system_status.arduino_version = 'Arduino v3.3.8';
system_status.sdk_version = '5.5.4.260407';
system_status.psram = true;
system_status.psram_size = 4096;
@@ -225,7 +226,7 @@ switch (emulate_esp) {
default:
system_status.esp_platform = 'ESP32S3';
system_status.cpu_type = 'ESP32-S3';
system_status.arduino_version = 'Tasmota Arduino v3.3.8';
system_status.arduino_version = 'Arduino v3.3.8';
system_status.sdk_version = '5.5.4.260407';
system_status.psram = true;
system_status.psram_size = 8189;
@@ -3819,7 +3820,7 @@ const emsesp_devicedata_10 = {
u: 0,
id: '00hc1 nofrost mode',
c: 'hc1/nofrostmode',
l: ['room', 'outdoor', 'room outdoor']
l: ['off', 'room', 'outdoor', 'room outdoor']
},
{
v: 2,
@@ -4443,6 +4444,8 @@ const emsesp_deviceentities_4 = [
n: 'hc2 selected room temperature',
id: 'hc2/seltemp',
m: 8,
mi: 5,
ma: 30,
w: true
},
{
@@ -4465,8 +4468,25 @@ const emsesp_deviceentities_7 = [
{ u: 0, n: '!reset', id: 'reset', m: 8, w: false },
{ v: false, n: 'heating active', id: 'heatingactive', m: 8, w: false },
{ v: false, n: 'tapwater active', id: 'tapwateractive', m: 4, w: false },
{ v: 5, n: 'selected flow temperature', id: 'selflowtemp', m: 4, w: true },
{ v: 0, n: 'burner selected max power', id: 'selburnpow', m: 14, w: true },
{
v: 5,
n: 'selected flow temperature',
id: 'selflowtemp',
m: 8,
mi: 0,
ma: 90,
c: true,
w: true
},
{
v: 0,
n: 'burner selected max power',
id: 'selburnpow',
m: 14,
mi: 0,
ma: 100,
w: true
},
{ v: 0, n: 'heating pump modulation', id: 'heatingpumpmod', m: 0, w: false },
{ n: 'heating pump 2 modulation', id: 'heatingpump2mod', m: 0, w: false },
{ n: 'outside temperature', id: 'outdoortemp', m: 0, w: false },
@@ -4484,16 +4504,48 @@ const emsesp_deviceentities_7 = [
{ v: false, n: 'ignition', id: 'ignwork', m: 0, w: false },
{ v: false, n: 'oil preheating', id: 'oilpreheat', m: 0, w: false },
{ v: true, n: 'heating activated', id: 'heatingactivated', m: 0, w: false },
{ v: 80, n: 'heating temperature', id: 'heatingtemp', m: 0, w: false },
{ v: 70, n: 'burner pump max power', id: 'pumpmodmax', m: 0, w: false },
{
v: 80,
n: 'heating temperature',
id: 'heatingtemp',
m: 0,
mi: 30,
ma: 82,
w: true
},
{
v: 70,
n: 'burner pump max power',
id: 'pumpmodmax',
m: 0,
mi: 10,
ma: 100,
w: true
},
{ v: 30, n: 'burner pump min power', id: 'pumpmodmin', m: 0, w: false },
{ v: 1, n: 'pump delay', id: 'pumpdelay', m: 0, w: false },
{ v: 10, n: 'burner min period', id: 'burnminperiod', m: 0, w: false },
{ v: 0, n: 'burner min power', id: 'burnminpower', m: 0, w: false },
{ v: 50, n: 'burner max power', id: 'burnmaxpower', m: 0, w: false },
{
v: 50,
n: 'burner max power',
id: 'burnmaxpower',
m: 0,
mi: 0,
ma: 100,
w: true
},
{ v: -6, n: 'hysteresis on temperature', id: 'boilhyston', m: 0, w: false },
{ v: 6, n: 'hysteresis off temperature', id: 'boilhystoff', m: 0, w: false },
{ v: 0, n: 'set flow temperature', id: 'setflowtemp', m: 0, w: true },
{
v: 0,
n: 'set flow temperature',
id: 'setflowtemp',
m: 0,
mi: 0,
ma: 90,
w: true
},
{ v: 0, n: 'burner set power', id: 'setburnpow', m: 0, w: false },
{ v: 0, n: 'burner current power', id: 'curburnpow', m: 0, w: false },
{ v: 326323, n: 'burner starts', id: 'burnstarts', m: 0, w: false },
@@ -4691,6 +4743,15 @@ router
return { md5: digest };
}
// the firmware recognises the recovery image by its app description, the mock by its file name
if (fileExtension === 'bin' && fileName.startsWith('EMS-ESP-Recovery')) {
const md5_ok = pendingFirmwareMd5;
pendingFirmwareMd5 = false;
system_status.recovery_version = '1.0.0';
console.log('Recovery firmware installed');
return md5_ok ? { recovery: true, md5_ok } : { recovery: true };
}
if (fileExtension === 'bin' && pendingFirmwareMd5) {
pendingFirmwareMd5 = false;
console.log('Firmware MD5 matches');
@@ -5437,6 +5498,10 @@ router
} else if (action === 'uploadURL') {
// upload URL
console.log('upload File from URL', content.param);
if (String(content.param).toLowerCase().includes('recovery')) {
// the recovery app is installed without a restart
system_status.recovery_version = '1.0.0';
}
return status(200);
} else if (action === 'resetMQTT') {
// reset MQTT
+51 -22
View File
@@ -2,36 +2,46 @@
[common]
; custom build flags to use in my_build_flags
; -DEMSESP_UART_DEBUG ; debugging UART
; -DEMSESP_DEBUG ; enables DEBUG to the log. Will generate a lot of extra traffic on Console and Syslog
; -DEMSESP_DEFAULT_BOARD_PROFILE=\"NODEMCU\" ; hard code the default board name
; -DEMSESP_DEBUG_SENSOR ; additional debug for the sensors
; -DCORE_DEBUG_LEVEL=5 ; verbose core level debugging 5=verbose, 4=debug, 3=info
; -DEMSESP_TEST ; enable the tests. EN language only
; -DEMSESP_DE_ONLY ; only DE translated entity names
; -DEMSESP_EN_ONLY ; only EN translated entity names
; -DEMSESP_PINGTEST ; send log message every 1/2 second
; -D EMSESP_UART_DEBUG ; debugging UART
; -D EMSESP_DEBUG ; enables DEBUG to the log. Will generate a lot of extra traffic on Console and Syslog
; -D EMSESP_DEFAULT_BOARD_PROFILE=\"NODEMCU\" ; hard code the default board name
; -D EMSESP_DEBUG_SENSOR ; additional debug for the sensors
; -D CORE_DEBUG_LEVEL=5 ; verbose core level debugging 5=verbose, 4=debug, 3=info
; -D EMSESP_TEST ; enable the tests. EN language only
; -D EMSESP_DE_ONLY ; only DE translated entity names
; -D EMSESP_EN_ONLY ; only EN translated entity names
; -D EMSESP_PINGTEST ; send log message every 1/2 second
; -D EMSESP_GATEWAY_TX_HOLD ; hold Tx while a K30RF/KM200 gateway is syncing, see issue #3094
; -D EMSESP_TX_PRE_DELAY=1000 ; wait this many us before each Tx so we don't overlap the master's break, see issue #3094
; my_build_flags = -DEMSESP_TEST -DEMSESP_DEBUG
; my_build_flags = -DEMSESP_DEBUG -DEMSESP_TEST -DEMSESP_PINGTEST
; my_build_flags = -DEMSESP_DEBUG -DCORE_DEBUG_LEVEL=5
; my_build_flags = -DEMSESP_EN_ONLY -DEMSESP_TEST -DCORE_DEBUG_LEVEL=5 -DARDUINO_USB_CDC_ON_BOOT=1
my_build_flags =
; my_build_flags = -DEMSESP_EN_ONLY
; my_build_flags = -D EMSESP_GATEWAY_TX_HOLD -D EMSESP_TX_PRE_DELAY=1000
my_build_flags = -DEMSESP_DEBUG
; my_build_flags =
[platformio]
default_envs = s_16M_P ; BBQKees E32V2
; default_envs = build-webUI ; build the web interface only
; default_envs = s3_16M_P ; BBQKees S3
; default_envs = s_4M ; BBQKees older S32, 4MB no psram
; default_envs = s_16M ; BBQKees newer S32 V2, 16MB no psram
; default_envs = c6 ; XIAO ESP32C
; default_envs = debug
default_envs = s_16M_P, recovery_s_16M_P, build-recovery, build-webUI
; default_envs = s_16M_P ; BBQKees E32V2
; default_envs = s_16M_P_custom ; BBQKees E32V2 with custom Arduino/IDF libs
; default_envs = recovery_s_16M_P ; recovery firmware
; default_envs = crashtest_s_16M_P ; crash test
; default_envs = build-webUI ; build the main ems-esp webUI only
; default_envs = build-recovery ; build the recovery WebUI only
; default_envs = s3_16M_P ; BBQKees S3 firmware
; default_envs = s_4M ; firmware forBBQKees older S32, 4MB no psram
; default_envs = s_16M ; firmware for BBQKees newer S32 V2, 16MB no psram
; default_envs = c6 ; firmware for XIAO ESP32C
; default_envs = debug ; see below
[env]
; uncomment if you want to upload the firmware via OTA (must have upload_protocol = custom)
extra_scripts =
pre:scripts/build_interface.py ; builds the WebUI
scripts/rename_fw.py ; renames the firmware .bin file - comment out if not needed
post:scripts/rename_fw.py ; renames the firmware .bin file
scripts/upload.py ; optionally upload the firmware via OTA (must have upload_protocol = custom)
; set the username and password for the admin account (default password is admin)
@@ -40,13 +50,33 @@ custom_password = admin
; ** upload settings **
; for direct USB, which is the default you can set an optional port for example:
; upload_port = /dev/ttyUSB0 or COM3
; upload_speed = 921600
; upload_port = /dev/ttyUSB0
; upload_port = /dev/ttyACM0
; CH340G bridge tops out here reliably; drop back to 460800 if uploads fail
; 2000000 is the max speed for the CH340G bridge
upload_speed = 921600
; for direct via HTTP (OTA) set:
; upload_protocol = custom
; custom_emsesp_ip = <ip address> or ems-esp.local
upload_protocol = custom
custom_emsesp_ip = ems-esp.local ; E32V2.2 (test)
; custom_emsesp_ip = ems-espP.local ; E32V2.2 (prod)
; custom_emsesp_ip = ems-esps32.local ; S32, 4MB no psramS32
; custom_emsesp_ip = ems-espS3.local ; ems-espS3
; lib_deps =
; bblanchon/ArduinoJson @ 7.4.3
; ESP32Async/AsyncTCP @ 3.4.10
; ESP32Async/ESPAsyncWebServer @ 3.11.0
; https://github.com/mobizt/ReadyMail.git @ 0.4.0
; https://github.com/mobizt/ESP_SSLClient.git @ 3.1.3
; https://github.com/emsesp/EMS-ESP-Modules.git @ 1.0.8
; ; file://${PROJECT_DIR}/../modules/EMS-ESP-Modules
; ** debug settings **
; to be used with an ESP32-S3 which has an onboard JTAG, connect the OTG USB port to the PC
; if using an external JTAG board like the ESP-PROG set debug_tool = esp-prog, and use zadig to set the correct driver for the USB port
@@ -69,4 +99,3 @@ debug_init_break =
upload_speed = 2000000
monitor_speed = 115200
extra_scripts =
; debug_build_flags = -Og -g2 -ggdb2
+136 -23
View File
@@ -6,10 +6,12 @@
; For example: EMS-ESP-3_7_0-dev_31-esp32-16MB+.bin
;
; boards can be found in https://github.com/pioarduino/platform-espressif32/tree/main/boards
; Tasmota platform 2026.09.50 needs PlatformIO Core >=6.2.0. Official platformio is still
; 6.1.19 — install pioarduino instead (see CONTRIBUTING.md). `pio upgrade` will not get 6.2.0.
[platformio]
; build all supported targets. Use pio_local.ini to override and build specific targets.
default_envs = s_4M, s_16M, s_16M_P, s3_16M_P
default_envs = s_4M, s_16M, s_16M_P, s3_16M_P, recovery_s_16M_P, recovery_s3_16M_P
description = EMS-ESP Firmware for the ESP32
src_dir = src
@@ -21,8 +23,8 @@ extra_configs =
pio_local.ini
[common]
core_build_flags = -std=gnu++20 -O3 -flto=auto -Wno-type-limits -Wall -Wextra -Wno-unused-parameter -Wno-unused-variable -Wno-format -Wno-missing-field-initializers
core_unbuild_flags = -std=gnu++11 -std=gnu++14 -std=gnu++17 -fno-lto
core_build_flags = -Wall -Wextra -Wno-type-limits -Wno-unused-parameter -Wno-unused-variable -Wno-format -Wno-missing-field-initializers
core_unbuild_flags = -O2 -O3 -Og -fno-lto
my_build_flags =
@@ -31,25 +33,22 @@ build_flags =
${factory_settings.build_flags}
${common.my_build_flags}
-I./src/core
; core 3.3.12 uses spi_host_device_t in ETH.h without including it. A framework
; refresh restores that header, so force the declaration on every compile.
-include hal/spi_types.h
-D ARDUINO_ARCH_ESP32
-D ONEWIRE_CRC16=0
-D CONFIG_UART_ISR_IN_IRAM
; AsyncTCP
-D CONFIG_ASYNC_TCP_MAX_ACK_TIME=5000 ; default
-D CONFIG_ASYNC_TCP_PRIORITY=10 ; default
-D CONFIG_ASYNC_TCP_QUEUE_SIZE=64 ; default
-D CONFIG_ASYNC_TCP_RUNNING_CORE=1 ; force async_tcp task to be on same core as Arduino app (default is any core)
-D CONFIG_ASYNC_TCP_STACK_SIZE=8192 ; default is 16KB/8192*2
; ESPAsyncWebServer
; -D WS_MAX_QUEUED_MESSAGES=0 ; not used, default 8
-D ENABLE_PSRAM ; incase BOARD_HAS_PSRAM is not set, so boards without PSRAM keep using the internal heap
; -D SSE_MAX_QUEUED_MESSAGES=1 ; for log messages, default 32
; ESP_SSLClient
-D ENABLE_PSRAM ; take the ~17KB of TLS buffers from PSRAM instead of the internal heap. The library
; needs BOARD_HAS_PSRAM too, so boards without PSRAM keep using the internal heap
-D EMSESP_MQTT_RUNNING_CORE=1 ; default 1
; -D EMSESP_MQTT_STACKSIZE=5120 ; default
-D EMSESP_UART_RUNNING_CORE=1 ; default any core
; -D EMSESP_UART_STACKSIZE=2560 ; default
; -D EMSESP_UART_STACKSIZE=2560 ; 5120 is default
; -D EMSESP_GATEWAY_TX_HOLD ; hold Tx while a K30RF/KM200 gateway is syncing, see issue #3094
; -D EMSESP_TX_PRE_DELAY=1000 ; wait this many us before each Tx so we don't overlap the master's break, see issue #3094
unbuild_flags =
${common.core_unbuild_flags}
@@ -58,32 +57,35 @@ unbuild_flags =
[espressif32_base_4M]
framework = arduino
board_build.partitions = partitions/esp32_partition_4M.csv
board_upload.flash_size = 4MB
board_build.app_partition_name = app0
platform = https://github.com/tasmota/platform-espressif32/releases/download/2026.05.50/platform-espressif32.zip ; Tasmota Arduino Core 3.3.8 based on IDF 5.5.4.260407
; 16MB Flash variants
[espressif32_base_16M]
framework = arduino
board_build.partitions = partitions/esp32_partition_16M.csv
board_upload.flash_size = 16MB
board_build.app_partition_name = app0
platform = https://github.com/tasmota/platform-espressif32/releases/download/2026.05.50/platform-espressif32.zip ; Tasmota Arduino Core 3.3.8 based on IDF 5.5.4.260407
build_flags =
${common.build_flags}
-D EMSESP_HAS_RECOVERY ; installs and falls back to the recovery firmware in the factory partition
-D OTA_UPDATER_ANY_PARTITION ; so the recovery firmware can be uploaded into the factory partition
; 32MB Flash variants
[espressif32_base_32M]
framework = arduino
board_build.partitions = partitions/esp32_partition_32M.csv
board_upload.flash_size = 32MB
board_build.app_partition_name = app0
platform = https://github.com/tasmota/platform-espressif32/releases/download/2026.05.50/platform-espressif32.zip ; Tasmota Arduino Core 3.3.8 based on IDF 5.5.4.260407
[env]
; use own Ardunio libs packages from https://github.com/emsesp/esp32-arduino-lib-builder which is based on Arduino Core 3.3.12 and IDF 5.5.5. Same as s_16M_P_custom
; with Tasmota's tool chain (platform)
; if we want to go back to Tasmota libs, use the following:
; platform = https://github.com/tasmota/platform-espressif32/releases/download/2026.05.50/platform-espressif32.zip ; Tasmota Arduino Core 3.3.8 based on IDF 5.5.4.260407
platform = https://github.com/tasmota/platform-espressif32/releases/download/2026.09.50/platform-espressif32.zip
platform_packages = framework-arduinoespressif32 @ https://github.com/emsesp/esp32-arduino-lib-builder/releases/download/1410-0850-5.5.5/framework-arduinoespressif32-v5.5.5-a63b839f.tar.xz
build_flags =
${common.build_flags}
build_unflags =
${common.unbuild_flags}
extra_scripts =
pre:scripts/enable_ccache.py ; uses ccache when it is installed
post:scripts/rename_fw.py ; renames the firmware .bin file
monitor_speed = 115200
monitor_filters = direct, esp32_exception_decoder
@@ -106,6 +108,14 @@ extra_scripts = pre:scripts/build_interface.py
; Exclude all source files so the C code doesn't build
build_src_filter = -<*>
; builds the recovery web interface only (recovery/WWWData.h), not the recovery firmware
[env:build-recovery]
platform = native
targets = build
extra_scripts = pre:scripts/build_recovery_interface.py
; Exclude all source files so the C code doesn't build
build_src_filter = -<*>
;
; Builds for different board types
; If you're building for a single target environment, we recommend creating a pio_local.ini (see example file)
@@ -126,6 +136,50 @@ board = s_16M
extends = espressif32_base_16M
board = s_16M_P
[env:s_16M_P_custom]
; same as s_16M_P, but the Arduino/IDF libs are rebuilt with HybridCompile using the custom_sdkconfig below
; this is same procedure as in the esp32-arduino-lib-builder repository (configs/defconfig.ems-esp)
; SPI Ethernet is left enabled: Arduino ETH.cpp needs those types; unused W5500/DM9051 objects are GC'd
extends = env:s_16M_P
custom_sdkconfig =
'# CONFIG_BT_ENABLED is not set'
'# CONFIG_LWIP_PPP_SUPPORT is not set'
'# CONFIG_LWIP_IP_FORWARD is not set'
'# CONFIG_LWIP_IPV4_NAPT is not set'
'# CONFIG_LWIP_IPV4_NAPT_PORTMAP is not set'
'# CONFIG_ULP_COPROC_ENABLED is not set'
'# CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS is not set'
'# CONFIG_FREERTOS_USE_TRACE_FACILITY is not set'
'# CONFIG_FREERTOS_ENABLE_TASK_SNAPSHOT is not set'
'# CONFIG_ESP_MODEM_USE_PPP_MODE is not set'
'# CONFIG_ESP_GDBSTUB_ENABLED is not set'
'# CONFIG_ESP_NETIF_REPORT_DATA_TRAFFIC is not set'
'# CONFIG_MDNS_ENABLE_CONSOLE_CLI is not set'
'# CONFIG_MDNS_ENABLE_BROWSE is not set'
'# CONFIG_I2S_ISR_IRAM_SAFE is not set'
'# CONFIG_RINGBUF_PLACE_ISR_FUNCTIONS_INTO_FLASH is not set'
'CONFIG_UART_ISR_IN_IRAM=y'
'CONFIG_SPIRAM_MALLOC_ALWAYSINTERNAL=256'
'CONFIG_SPIRAM_MALLOC_RESERVE_INTERNAL=1024'
'CONFIG_ESP_WIFI_ESPNOW_MAX_ENCRYPT_NUM=0'
'CONFIG_ESP_WIFI_STATIC_RX_BUFFER_NUM=6'
'CONFIG_ESP_WIFI_DYNAMIC_RX_BUFFER_NUM=16'
'CONFIG_ESP_WIFI_DYNAMIC_TX_BUFFER_NUM=16'
'CONFIG_ESP_WIFI_MGMT_SBUF_NUM=16'
'CONFIG_ESP_WIFI_RX_BA_WIN=4'
'CONFIG_ESP_WIFI_TX_BA_WIN=4'
'CONFIG_MDNS_MAX_SERVICES=4'
'CONFIG_MDNS_ACTION_QUEUE_LEN=8'
'CONFIG_ETH_DMA_RX_BUFFER_NUM=6'
'CONFIG_ETH_DMA_TX_BUFFER_NUM=6'
'CONFIG_LWIP_MAX_ACTIVE_TCP=10'
'CONFIG_LWIP_MAX_LISTENING_TCP=8'
'CONFIG_LWIP_MAX_UDP_PCBS=8'
'CONFIG_LWIP_MAX_RAW_PCBS=4'
'CONFIG_LWIP_IPV6_NUM_ADDRESSES=2'
'CONFIG_LWIP_LOCAL_HOSTNAME="ems-esp"'
'CONFIG_TINYUSB_DESC_CDC_STRING="EMS-ESP CDC Device"'
[env:s3_16M_P]
; 16MB ESP32-S3 - with PSRAM - like a BBQKees S3
extends = espressif32_base_16M
@@ -158,12 +212,69 @@ build_flags =
; XIAO ESP32C - 512KB SRAM & 4MB Flash - https://wiki.seeedstudio.com/xiao_esp32c6_getting_started/
[env:c6_xiao_4M]
extends = espressif32_base_4M
board_build.app_partition_name = app0
board = seeed_xiao_esp32c6
build_flags =
${common.build_flags}
-DEMSESP_EN_ONLY
; Crash test builds for the bootloader rollback and the recovery fallback. Install with the web upload, not over
; serial, since a serial flash boots the recovery. Change EMSESP_CRASH_TEST to pick the test:
; 1 crashes on every start, so the bootloader rolls back to the previous firmware
; 2 runs normally until it's confirmed after 2 minutes, then crashes on every start. After 5 crashes in a row
; it switches to the recovery firmware. Without one it keeps crashing until reflashed over serial
[env:crashtest_s_16M_P]
extends = env:s_16M_P
build_flags =
${espressif32_base_16M.build_flags}
-D EMSESP_CRASH_TEST=1
custom_fw_name = EMS-ESP-CrashTest
; EMS-ESP Recovery - a small app for the factory (boot) partition with an access point and a firmware upload page.
; Only for boards with a factory partition (16MB). Sources are in ./recovery, the WebUI is built from ./interface.
; build: pio run -e recovery_s3_16M_P
; flash into the boot partition: pio run -e recovery_s3_16M_P -t upload
; run it from app0 like a normal app instead, add an [env:recovery_s3_16M_P] section to pio_local.ini with custom_app_offset =
; develop the WebUI with a mock: cd interface && pnpm recovery
[recovery_base]
extends = espressif32_base_16M
board_build.app_partition_name = boot
build_src_filter = -<*> +<../recovery/>
build_flags =
${factory_settings.build_flags}
-std=gnu++20 -Os
-D OTA_UPDATER_ANY_PARTITION
-D CONFIG_ASYNC_TCP_RUNNING_CORE=1
-D CONFIG_ASYNC_TCP_STACK_SIZE=8192
build_unflags =
${common.core_unbuild_flags}
lib_ldf_mode = chain
lib_deps =
bblanchon/ArduinoJson @ 7.4.3
ESP32Async/AsyncTCP @ 3.5.0
ESP32Async/ESPAsyncWebServer @ 3.12.1
extra_scripts =
pre:scripts/build_recovery_interface.py
post:scripts/set_app_desc.py
post:scripts/rename_fw.py
post:scripts/upload_factory.py
upload_protocol = esptool
custom_app_offset = 0x10000
custom_fw_name = EMS-ESP-Recovery
custom_version_file = recovery/recovery_version.h
custom_version_define = RECOVERY_VERSION
[env:recovery_s_16M]
extends = recovery_base
board = s_16M
[env:recovery_s_16M_P]
extends = recovery_base
board = s_16M_P
[env:recovery_s3_16M_P]
extends = recovery_base
board = s3_16M_P
; foundation for building and testing natively, standalone without an ESP32
; use the `standalone` environment instead of `native` for testing
[env:native]
@@ -194,6 +305,7 @@ build_src_filter =
-<../lib/uuid-syslog>
-<../lib/eModbus>
-<../lib/OneWire>
-<../lib/OtaUpdater>
-<../src/core/ModuleLibrary.cpp>
lib_ldf_mode = off
lib_deps =
@@ -237,6 +349,7 @@ build_src_filter =
-<../lib/uuid-syslog>
-<../lib/eModbus>
-<../lib/OneWire>
-<../lib/OtaUpdater>
-<../src/core/ModuleLibrary.cpp>
lib_ldf_mode = off
lib_deps = Unity
+8 -1
View File
@@ -786,7 +786,6 @@ negcond
negheat
nein
nennvolumentstrom
neopixel
netcmp
netflowtemp
netif
@@ -1352,3 +1351,11 @@ zyxwvutsrqponmlkjihgfedcba
öffnen
česky
živanović
neopixel
firmwares
recognises
POWERON
crashtest
otadata
merase
Seeed
+932
View File
@@ -0,0 +1,932 @@
// EMS-ESP Recovery
//
// Minimal application that lives in the factory ("boot") partition. It brings up a WiFi access point
// and connects to the network with the saved EMS-ESP settings (Ethernet, else WiFi, with the static IP
// if one is set), and serves a cut-down WebUI to upload a new EMS-ESP firmware into the OTA partitions
// or select which OTA partition to boot.
//
// The EMS-ESP settings in LittleFS are only ever read, never written, except for a factory reset
// which erases them.
#include <Arduino.h>
#include <ArduinoJson.h>
#include <AsyncJson.h>
#include <DNSServer.h>
#include <ESPAsyncWebServer.h>
#include <ESPmDNS.h>
#include <LittleFS.h>
#include <OtaUpdater.h>
#include <Preferences.h>
#include <WiFi.h>
#if CONFIG_IDF_TARGET_ESP32
#include <ETH.h>
#endif
#include <esp_image_format.h>
#include <esp_ota_ops.h>
#include <esp_partition.h>
#include <esp_random.h>
#include <algorithm>
#include <vector>
#include "WWWData.h" // generated by `pnpm build-recovery` in ./interface
#include "recovery_version.h"
#ifndef FACTORY_AP_CHANNEL
#define FACTORY_AP_CHANNEL 1
#endif
static constexpr size_t MIN_FIRMWARE_SIZE = 512 * 1024; // smallest .bin accepted by the upload, anything smaller is rejected
static constexpr uint32_t RESTART_DELAY_MS = 500; // delay before restarting, so the HTTP response is sent first
static constexpr uint32_t ETH_WAIT_MS = 15000; // how long Ethernet gets to obtain an address before WiFi is tried
static constexpr char NVS_NAMESPACE[] = "ems-esp";
static constexpr char NVS_FRESH_FW[] = "fresh_firmware"; // EMSESP_NVS_BOOT_NEW_FIRMWARE in emsesp.h
static constexpr char NVS_REASON[] = "rec_reason"; // EMSESP_NVS_RECOVERY_REASON in emsesp.h
#define LOG(...) \
do { \
Serial.print("[recovery] "); \
Serial.printf(__VA_ARGS__); \
Serial.println(); \
} while (0)
struct AdminUser {
String username;
String password;
};
struct Settings {
String ap_ssid = FACTORY_AP_SSID;
String ap_password = FACTORY_AP_PASSWORD;
uint8_t ap_channel = FACTORY_AP_CHANNEL;
IPAddress ap_ip;
IPAddress ap_gateway;
IPAddress ap_subnet;
String wifi_ssid = FACTORY_WIFI_SSID;
String wifi_password = FACTORY_WIFI_PASSWORD;
String hostname = FACTORY_NETWORK_HOSTNAME;
bool static_ip = false;
IPAddress local_ip;
IPAddress gateway_ip;
IPAddress subnet_mask;
IPAddress dns_ip_1;
IPAddress dns_ip_2;
bool eth_10mbit = false;
uint8_t phy_type = 0; // PHY_type in src/core/system.h, 0 is no Ethernet
int8_t eth_power = -1;
uint8_t eth_phy_addr = 0;
uint8_t eth_clock_mode = 0;
bool disable_reset = false; // factory reset is disabled in the EMS-ESP settings
std::vector<AdminUser> admins;
};
struct PartitionInfo {
String label;
bool factory;
uint32_t address;
uint32_t size;
bool valid;
uint32_t image_size;
String version;
uint32_t install_date; // UTC epoch, 0 if unknown
String ota_state; // bootloader rollback state, empty if the partition has never been selected
};
static AsyncWebServer server(80);
static DNSServer dnsServer;
static Settings settings;
static OtaUpdater ota;
static std::vector<PartitionInfo> partitions;
static uint32_t restart_at = 0;
static String session_token;
static String ap_ssid;
static String recovery_reason; // set by EMS-ESP when it switched to the recovery
static const char * eth_state = ""; // "", "disabled", "failed" or "started"
static uint32_t wifi_join_at = 0; // when to try WiFi if Ethernet has no address, 0 if not waiting
static bool wifi_started = false;
static bool ethConnected();
// upload state, only one upload at a time
static int upload_error = 0;
static bool upload_done = false;
static String upload_target;
static size_t upload_size = 0; // content length, for the progress
static size_t upload_step = 0; // last progress step logged, in tens of percent
// ----------------------------------------------------------------------------
// settings (read-only)
// ----------------------------------------------------------------------------
static bool readJsonFile(const char * path, JsonDocument & doc) {
File file = LittleFS.open(path, "r");
if (!file) {
return false;
}
const DeserializationError error = deserializeJson(doc, file);
file.close();
if (error) {
LOG("Failed to parse %s: %s", path, error.c_str());
return false;
}
return true;
}
static IPAddress parseIP(JsonVariantConst value, const char * fallback) {
IPAddress ip;
if (!ip.fromString(value | fallback)) {
ip.fromString(fallback);
}
return ip;
}
static void loadSettings() {
// never format the filesystem, it holds the EMS-ESP settings
const bool mounted = LittleFS.begin(false);
LOG("LittleFS %s", mounted ? "mounted" : "not available, using factory settings");
JsonDocument doc;
if (mounted && readJsonFile("/config/apSettings.json", doc)) {
settings.ap_ssid = doc["ssid"] | FACTORY_AP_SSID;
settings.ap_password = doc["password"] | FACTORY_AP_PASSWORD;
settings.ap_channel = doc["channel"] | FACTORY_AP_CHANNEL;
}
settings.ap_ip = parseIP(doc["local_ip"], FACTORY_AP_LOCAL_IP);
settings.ap_gateway = parseIP(doc["gateway_ip"], FACTORY_AP_GATEWAY_IP);
settings.ap_subnet = parseIP(doc["subnet_mask"], FACTORY_AP_SUBNET_MASK);
doc.clear();
if (mounted && readJsonFile("/config/networkSettings.json", doc)) {
settings.wifi_ssid = doc["ssid"] | FACTORY_WIFI_SSID;
settings.wifi_password = doc["password"] | FACTORY_WIFI_PASSWORD;
settings.hostname = doc["hostname"] | FACTORY_NETWORK_HOSTNAME;
settings.eth_10mbit = doc["eth_10mbit"] | false;
settings.local_ip.fromString(doc["local_ip"] | "");
settings.gateway_ip.fromString(doc["gateway_ip"] | "");
settings.subnet_mask.fromString(doc["subnet_mask"] | "");
settings.dns_ip_1.fromString(doc["dns_ip_1"] | "");
settings.dns_ip_2.fromString(doc["dns_ip_2"] | "");
settings.static_ip = (doc["static_ip_config"] | false) && settings.local_ip != IPAddress();
}
// the Ethernet PHY settings come from the board profile, stored with the EMS-ESP application settings
doc.clear();
if (mounted && readJsonFile("/config/emsespSettings.json", doc)) {
settings.phy_type = doc["phy_type"] | 0;
settings.eth_power = doc["eth_power"] | -1;
settings.eth_phy_addr = doc["eth_phy_addr"] | 0;
settings.eth_clock_mode = doc["eth_clock_mode"] | 0;
settings.disable_reset = doc["disable_reset"] | false;
}
doc.clear();
if (mounted && readJsonFile("/config/securitySettings.json", doc)) {
for (JsonObject user : doc["users"].as<JsonArray>()) {
if (user["admin"] | false) {
settings.admins.push_back({user["username"] | "", user["password"] | ""});
}
}
}
if (settings.admins.empty()) {
// the admin user couldn't be read, so sign in with the factory login but never allow a factory reset
settings.disable_reset = true;
settings.admins.push_back({FACTORY_ADMIN_USERNAME, FACTORY_ADMIN_PASSWORD});
}
if (mounted) {
LittleFS.end();
}
}
// ----------------------------------------------------------------------------
// partitions
// ----------------------------------------------------------------------------
static bool openNvs(Preferences & nvs, bool readOnly) {
if (esp_partition_find_first(ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_DATA_NVS, "nvs1")) {
return nvs.begin(NVS_NAMESPACE, readOnly, "nvs1");
}
return nvs.begin(NVS_NAMESPACE, readOnly, "nvs");
}
static const char * otaStateName(esp_ota_img_states_t state) {
switch (state) {
case ESP_OTA_IMG_NEW:
return "new";
case ESP_OTA_IMG_PENDING_VERIFY:
return "pending";
case ESP_OTA_IMG_VALID:
return "valid";
case ESP_OTA_IMG_INVALID:
return "invalid";
case ESP_OTA_IMG_ABORTED:
return "aborted";
default:
return "";
}
}
static const char * resetReasonName(esp_reset_reason_t reason) {
switch (reason) {
case ESP_RST_POWERON:
return "power_on";
case ESP_RST_EXT:
return "external";
case ESP_RST_SW:
return "software";
case ESP_RST_PANIC:
return "panic";
case ESP_RST_INT_WDT:
case ESP_RST_TASK_WDT:
case ESP_RST_WDT:
return "watchdog";
case ESP_RST_BROWNOUT:
return "brownout";
default:
return "other";
}
}
// one-shot, so a later restart into the recovery doesn't show a stale reason
static void loadRecoveryReason() {
Preferences nvs;
if (!openNvs(nvs, false)) {
return;
}
if (nvs.isKey(NVS_REASON)) {
recovery_reason = nvs.getString(NVS_REASON);
nvs.remove(NVS_REASON);
LOG("EMS-ESP switched to the recovery: %s", recovery_reason.c_str());
}
nvs.end();
}
static void loadPartitions() {
partitions.clear();
Preferences nvs;
const bool has_nvs = openNvs(nvs, true);
const esp_partition_t * running = esp_ota_get_running_partition();
esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_ANY, nullptr);
for (; it != nullptr; it = esp_partition_next(it)) {
const esp_partition_t * part = esp_partition_get(it);
PartitionInfo info;
info.label = part->label;
info.factory = part->subtype == ESP_PARTITION_SUBTYPE_APP_FACTORY;
info.address = part->address;
info.size = part->size;
info.image_size = 0;
info.install_date = 0;
esp_image_metadata_t meta = {};
esp_partition_pos_t pos = {.offset = part->address, .size = part->size};
info.valid = esp_image_verify(ESP_IMAGE_VERIFY_SILENT, &pos, &meta) == ESP_OK;
if (info.valid) {
info.image_size = meta.image_len;
}
esp_ota_img_states_t state;
if (!info.factory && esp_ota_get_state_partition(part, &state) == ESP_OK) {
info.ota_state = otaStateName(state);
}
if (part == running) {
info.version = RECOVERY_VERSION;
// EMS-ESP stores the install date when it installs the recovery. Not set if it was flashed over serial
if (has_nvs) {
info.install_date = nvs.getULong(("d_" + info.label).c_str(), 0);
}
} else if (info.valid) {
// EMS-ESP stores the version and install date of each partition in NVS, keyed by the label
if (has_nvs && nvs.isKey(part->label)) {
info.version = nvs.getString(part->label);
info.install_date = nvs.getULong(("d_" + info.label).c_str(), 0);
}
esp_app_desc_t desc;
if (info.version.isEmpty() && esp_ota_get_partition_description(part, &desc) == ESP_OK) {
info.version = desc.version;
}
}
LOG("Partition %s: %s%s, %uKB at 0x%x%s%s%s%s",
info.label.c_str(),
info.factory ? "factory, " : "",
info.valid ? "valid" : "empty",
static_cast<unsigned>(info.size / 1024),
static_cast<unsigned>(info.address),
info.version.isEmpty() ? "" : ", v",
info.version.c_str(),
info.ota_state.isEmpty() ? "" : ", ota state ",
info.ota_state.c_str());
partitions.push_back(info);
}
esp_partition_iterator_release(it);
if (has_nvs) {
nvs.end();
}
}
static const esp_partition_t * findAppPartition(const char * label) {
return esp_partition_find_first(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_ANY, label);
}
static bool failedToStart(const PartitionInfo & info) {
return info.ota_state == "aborted" || info.ota_state == "invalid";
}
// An empty OTA partition if there is one, then one whose firmware failed to start, so a working firmware
// is kept. Otherwise the one with the oldest install date, keeping the most recent firmware as a fallback.
static const esp_partition_t * defaultUploadTarget() {
const PartitionInfo * best = nullptr;
auto rank = [](const PartitionInfo & info) { return !info.valid ? 0 : failedToStart(info) ? 1 : 2; };
for (const auto & info : partitions) {
if (info.factory) {
continue;
}
if (best == nullptr || rank(info) < rank(*best) || (rank(info) == rank(*best) && info.install_date < best->install_date)) {
best = &info;
}
}
return best ? findAppPartition(best->label.c_str()) : esp_ota_get_next_update_partition(nullptr);
}
static const esp_partition_t * uploadTarget(AsyncWebServerRequest * request) {
if (!request->hasParam("partition")) {
return defaultUploadTarget();
}
const esp_partition_t * part = findAppPartition(request->getParam("partition")->value().c_str());
if (part == nullptr || part->subtype == ESP_PARTITION_SUBTYPE_APP_FACTORY || part == esp_ota_get_running_partition()) {
return nullptr;
}
return part;
}
// tells EMS-ESP it's running a newly flashed firmware, and drops the now stale version info for that partition
static void markFreshFirmware(const String & label) {
Preferences nvs;
if (!openNvs(nvs, false)) {
LOG("Cannot open NVS, EMS-ESP won't know it's a new firmware");
return;
}
nvs.putBool(NVS_FRESH_FW, true);
if (!label.isEmpty()) {
nvs.remove(label.c_str());
nvs.remove(("d_" + label).c_str());
}
nvs.end();
}
// ----------------------------------------------------------------------------
// authentication
// ----------------------------------------------------------------------------
static String generateToken() {
uint8_t bytes[16];
esp_fill_random(bytes, sizeof(bytes));
String token;
token.reserve(sizeof(bytes) * 2);
for (uint8_t b : bytes) {
char hex[3];
snprintf(hex, sizeof(hex), "%02x", b);
token += hex;
}
return token;
}
static bool isAuthorized(AsyncWebServerRequest * request) {
const bool ok = !session_token.isEmpty() && request->hasHeader("Authorization") && request->header("Authorization") == "Bearer " + session_token;
if (!ok) {
LOG("Unauthorized request to %s", request->url().c_str());
}
return ok;
}
// ----------------------------------------------------------------------------
// web handlers
// ----------------------------------------------------------------------------
class StaticContentHandler : public AsyncWebHandler {
public:
bool canHandle(AsyncWebServerRequest * request) const override {
return request->method() == HTTP_GET || request->method() == HTTP_HEAD;
}
void handleRequest(AsyncWebServerRequest * request) override {
const WWWAsset * asset = lookup(request->url().c_str());
if (asset == nullptr) {
asset = lookup("/index.html"); // SPA fallback, also catches captive portal probes
}
if (asset == nullptr) {
request->send(404);
return;
}
const String & inm = request->header(asyncsrv::T_INM);
if (inm.length() != 0 && strcmp(inm.c_str(), asset->etag) == 0) {
request->send(304);
return;
}
AsyncWebServerResponse * response = request->beginResponse(200, asset->contentType, asset->content, asset->len);
response->addHeader(asyncsrv::T_Content_Encoding, asyncsrv::T_gzip, false);
response->addHeader(asyncsrv::T_ETag, asset->etag, false);
request->send(response);
}
private:
static const WWWAsset * lookup(const char * url) {
for (size_t i = 0; i < WWW_ASSETS_COUNT; i++) {
if (strcmp(WWW_ASSETS[i].uri, url) == 0) {
return &WWW_ASSETS[i];
}
}
return nullptr;
}
};
static void handleStatus(AsyncWebServerRequest * request) {
auto * response = new AsyncJsonResponse();
JsonObject root = response->getRoot().to<JsonObject>();
root["recovery_version"] = RECOVERY_VERSION;
root["authenticated"] = isAuthorized(request);
root["hostname"] = settings.hostname;
root["chip"] = ESP.getChipModel();
root["flash_size"] = ESP.getFlashChipSize();
root["psram_size"] = ESP.getPsramSize();
root["free_heap"] = ESP.getFreeHeap();
root["mac"] = WiFi.macAddress();
root["uptime"] = millis() / 1000;
root["reason"] = recovery_reason;
root["reset_reason"] = resetReasonName(esp_reset_reason());
root["ap_ssid"] = ap_ssid;
root["ap_ip"] = WiFi.softAPIP().toString();
root["ap_clients"] = WiFi.softAPgetStationNum();
root["wifi_ssid"] = settings.wifi_ssid;
root["wifi_connected"] = WiFi.isConnected();
if (WiFi.isConnected()) {
root["wifi_ip"] = WiFi.localIP().toString();
root["wifi_rssi"] = WiFi.RSSI();
}
root["wifi_started"] = wifi_started;
root["static_ip"] = settings.static_ip;
root["disable_reset"] = settings.disable_reset;
// only on boards with Ethernet configured
if (eth_state[0] != '\0') {
#if CONFIG_IDF_TARGET_ESP32
if (ethConnected()) {
root["eth_state"] = "connected";
root["eth_ip"] = ETH.localIP().toString();
} else if (strcmp(eth_state, "started") == 0) {
root["eth_state"] = ETH.linkUp() ? "connecting" : "no_link";
} else
#endif
{
root["eth_state"] = eth_state;
}
}
const esp_partition_t * boot = esp_ota_get_boot_partition();
const esp_partition_t * running = esp_ota_get_running_partition();
const esp_partition_t * target = defaultUploadTarget();
root["running"] = running ? running->label : "";
root["boot"] = boot ? boot->label : "";
root["upload_target"] = target ? target->label : "";
JsonArray parts = root["partitions"].to<JsonArray>();
for (const auto & info : partitions) {
JsonObject p = parts.add<JsonObject>();
p["label"] = info.label;
p["factory"] = info.factory;
p["address"] = info.address;
p["size"] = info.size;
p["valid"] = info.valid;
p["image_size"] = info.image_size;
p["version"] = info.version;
p["install_date"] = info.install_date;
p["ota_state"] = info.ota_state;
}
response->setLength();
request->send(response);
}
static void handleSignIn(AsyncWebServerRequest * request, JsonVariant & json) {
const String username = json["username"] | "";
const String password = json["password"] | "";
for (const auto & admin : settings.admins) {
if (admin.username == username && admin.password == password) {
session_token = generateToken();
auto * response = new AsyncJsonResponse();
JsonObject root = response->getRoot().to<JsonObject>();
root["access_token"] = session_token;
response->setLength();
request->send(response);
LOG("User %s signed in", username.c_str());
return;
}
}
LOG("Failed sign in for user %s", username.c_str());
request->send(401);
}
static void handleSetBoot(AsyncWebServerRequest * request, JsonVariant & json) {
if (!isAuthorized(request)) {
request->send(401);
return;
}
const String label = json["partition"] | "";
const esp_partition_t * part = findAppPartition(label.c_str());
if (part == nullptr) {
LOG("Cannot boot from %s: no such partition", label.c_str());
request->send(404);
return;
}
// esp_ota_set_boot_partition() validates the image first
const esp_err_t err = esp_ota_set_boot_partition(part);
if (err != ESP_OK) {
LOG("Cannot boot from %s: %s", part->label, esp_err_to_name(err));
request->send(400);
return;
}
LOG("Next boot from %s, restarting", part->label);
request->send(200);
restart_at = millis() + RESTART_DELAY_MS;
}
static void handleRestart(AsyncWebServerRequest * request) {
if (!isAuthorized(request)) {
request->send(401);
return;
}
LOG("Restart requested");
request->send(200);
restart_at = millis() + RESTART_DELAY_MS;
}
// factory reset, erases all EMS-ESP settings and restarts
// only signed in admins are authorized (the recovery only accepts admin users), and only if not disabled in the settings
static void handleFactoryReset(AsyncWebServerRequest * request) {
if (!isAuthorized(request)) {
request->send(401);
return;
}
if (settings.disable_reset) {
LOG("Factory reset disabled");
request->send(403);
return;
}
LOG("Factory reset requested");
const bool ok = LittleFS.format(); // the only place the recovery writes to the filesystem
LOG("Factory reset %s", ok ? "done" : "failed");
if (!ok) {
request->send(500);
return;
}
request->send(200);
restart_at = millis() + RESTART_DELAY_MS;
}
static void uploadFail(AsyncWebServerRequest * request, int code, const char * reason) {
if (upload_error) {
return;
}
upload_error = code;
LOG("Upload error %d: %s", code, reason);
request->send(code);
if (code == 406) {
request->client()->close();
}
}
static void handleUpload(AsyncWebServerRequest * request, const String & filename, size_t index, uint8_t * data, size_t len, bool final) {
if (!index) {
upload_error = 0;
upload_done = false;
upload_size = 0;
upload_step = 0;
upload_target.clear();
if (!isAuthorized(request)) {
uploadFail(request, 403, "not signed in");
return;
}
const size_t filesize = request->contentLength();
switch (OtaUpdater::classify(filename, filesize, MIN_FIRMWARE_SIZE)) {
case OtaUpdater::FileType::FIRMWARE:
break;
case OtaUpdater::FileType::MD5:
if (ota.setMd5(data, len)) {
LOG("MD5 digest received (%s)", ota.md5());
} else {
uploadFail(request, 406, "invalid MD5 digest");
}
return;
default:
// the recovery app only flashes firmware, it never touches the settings
ota.clearMd5();
LOG("Unsupported file %s (%u bytes)", filename.c_str(), static_cast<unsigned>(filesize));
uploadFail(request, 406, "unsupported file");
return;
}
if (!OtaUpdater::isCompatibleFirmware(data, len)) {
uploadFail(request, 503, "not a firmware image for this chip");
return;
}
if (OtaUpdater::isRecoveryFirmware(data, len)) {
uploadFail(request, 409, "the recovery firmware can't be installed from the recovery");
return;
}
const esp_partition_t * target = uploadTarget(request);
if (target == nullptr) {
if (request->hasParam("partition")) {
LOG("Invalid upload target %s", request->getParam("partition")->value().c_str());
}
uploadFail(request, 400, "no valid target partition");
return;
}
if (!ota.beginFirmware(filesize, target)) {
LOG("Cannot write to %s: %s", target->label, ota.errorString());
uploadFail(request, 507, "cannot start the update");
return;
}
upload_target = target->label;
upload_size = filesize;
LOG("Uploading %s (%uKB) to %s (%s)%s",
filename.c_str(),
static_cast<unsigned>(filesize / 1024),
target->label,
request->hasParam("partition") ? "selected" : "default target",
ota.md5Applied() ? ", MD5 check enabled" : "");
request->onDisconnect([] {
if (!upload_done && !upload_target.isEmpty()) {
LOG("Upload aborted at %uKB, the client disconnected", static_cast<unsigned>(ota.progress() / 1024));
ota.abort();
}
});
}
if (upload_error || upload_target.isEmpty()) {
return;
}
if (!ota.write(data, len)) {
LOG("Write failed at %u: %s", static_cast<unsigned>(ota.progress()), ota.errorString());
ota.abort();
uploadFail(request, 500, "write failed");
return;
}
// log the progress every 10%, the content length is a little more than the image so it may stop just short of 100%
if (upload_size > 0) {
const size_t step = std::min<size_t>(ota.progress() * 10 / upload_size, 10);
if (step > upload_step) {
upload_step = step;
LOG("Uploading to %s, %u%%", upload_target.c_str(), static_cast<unsigned>(step * 10));
}
}
if (final) {
// on success this also makes the new partition the boot partition
if (!ota.end()) {
LOG("Upload failed: %s", ota.errorString());
uploadFail(request, 500, "image check failed");
return;
}
LOG("Upload finished, the image is verified");
upload_done = true;
}
}
static void handleUploadComplete(AsyncWebServerRequest * request) {
if (upload_error) {
return; // response already sent
}
if (upload_done) {
LOG("Firmware written to %s", upload_target.c_str());
markFreshFirmware(upload_target);
loadPartitions();
// treat the new upload as the most recent, so it isn't the default target for the next one
for (auto & info : partitions) {
if (info.label == upload_target) {
info.install_date = UINT32_MAX;
}
}
if (ota.md5Applied()) {
auto * response = new AsyncJsonResponse();
JsonObject root = response->getRoot().to<JsonObject>();
root["md5_ok"] = true;
response->setLength();
request->send(response);
} else {
request->send(200);
}
ota.clearMd5();
return;
}
if (ota.hasMd5()) {
auto * response = new AsyncJsonResponse();
JsonObject root = response->getRoot().to<JsonObject>();
root["md5"] = ota.md5();
response->setLength();
request->send(response);
return;
}
request->send(500);
}
// ----------------------------------------------------------------------------
// setup & loop
// ----------------------------------------------------------------------------
static void joinWiFi() {
wifi_join_at = 0;
if (settings.wifi_ssid.isEmpty()) {
return;
}
WiFi.mode(WIFI_AP_STA);
if (settings.static_ip) {
WiFi.config(settings.local_ip, settings.gateway_ip, settings.subnet_mask, settings.dns_ip_1, settings.dns_ip_2);
}
WiFi.begin(settings.wifi_ssid.c_str(), settings.wifi_password.c_str());
wifi_started = true;
LOG("Connecting to WiFi %s%s", settings.wifi_ssid.c_str(), settings.static_ip ? (" with IP " + settings.local_ip.toString()).c_str() : "");
}
// same bring-up as Network::startEthernet() in src/core/network.cpp
static bool startEthernet() {
#if CONFIG_IDF_TARGET_ESP32
if (settings.phy_type == 0) {
return false;
}
const auto clock = static_cast<eth_clock_mode_t>(settings.eth_clock_mode);
if (ESP.getPsramSize() && (clock == ETH_CLOCK_GPIO16_OUT || clock == ETH_CLOCK_GPIO17_OUT)) {
LOG("Ethernet clock mode %d conflicts with PSRAM, Ethernet disabled", settings.eth_clock_mode);
eth_state = "disabled";
return false;
}
// reset the PHY, it doesn't always start after a warm boot
if (settings.eth_power != -1) {
pinMode(settings.eth_power, OUTPUT);
digitalWrite(settings.eth_power, LOW);
delay(500);
digitalWrite(settings.eth_power, HIGH);
}
if (settings.eth_10mbit) {
ETH.setAutoNegotiation(false);
ETH.setLinkSpeed(10);
ETH.setFullDuplex(false);
}
const eth_phy_type_t type = settings.phy_type == 1 ? ETH_PHY_LAN8720 : settings.phy_type == 2 ? ETH_PHY_TLK110 : ETH_PHY_RTL8201;
if (!ETH.begin(type, settings.eth_phy_addr, 23, 18, settings.eth_power, clock)) {
LOG("Ethernet failed to start, no PHY found");
eth_state = "failed";
return false;
}
if (settings.static_ip) {
ETH.config(settings.local_ip, settings.gateway_ip, settings.subnet_mask, settings.dns_ip_1, settings.dns_ip_2);
}
eth_state = "started";
LOG("Ethernet started%s", settings.static_ip ? (" with IP " + settings.local_ip.toString()).c_str() : "");
return true;
#else
return false;
#endif
}
static bool ethConnected() {
#if CONFIG_IDF_TARGET_ESP32
return strcmp(eth_state, "started") == 0 && ETH.hasIP();
#else
return false;
#endif
}
static void startNetwork() {
// the reason is logged once per change, since a missing network disconnects again every few seconds
WiFi.onEvent(
[](arduino_event_id_t, arduino_event_info_t info) {
static uint8_t last_reason = 0;
if (info.wifi_sta_disconnected.reason != last_reason) {
last_reason = info.wifi_sta_disconnected.reason;
LOG("WiFi disconnected, reason %d", last_reason);
}
},
ARDUINO_EVENT_WIFI_STA_DISCONNECTED);
WiFi.persistent(false);
WiFi.setHostname(settings.hostname.c_str()); // also the default for the Ethernet interface
WiFi.mode(WIFI_AP);
// max SSID length is 32
ap_ssid = settings.ap_ssid.substring(0, 23) + "-recovery";
WiFi.softAPConfig(settings.ap_ip, settings.ap_gateway, settings.ap_subnet);
// WPA2 needs at least 8 characters, otherwise fall back to the factory password
const String & ap_password = settings.ap_password.length() >= 8 ? settings.ap_password : String(FACTORY_AP_PASSWORD);
WiFi.softAP(ap_ssid.c_str(), ap_password.c_str(), settings.ap_channel);
LOG("Access point %s started on %s", ap_ssid.c_str(), WiFi.softAPIP().toString().c_str());
// like EMS-ESP, Ethernet first and WiFi only if Ethernet doesn't get an address, as both share a static IP
if (startEthernet()) {
wifi_join_at = millis() + ETH_WAIT_MS;
} else {
joinWiFi();
}
dnsServer.start(53, "*", WiFi.softAPIP());
if (MDNS.begin(settings.hostname.c_str())) {
MDNS.addService("http", "tcp", 80);
}
}
static void startWebServer() {
server.on("/rest/recovery/status", HTTP_GET, handleStatus);
server.on("/rest/recovery/restart", HTTP_POST, handleRestart);
server.on("/rest/recovery/format", HTTP_POST, handleFactoryReset);
server.on("/rest/uploadFile", HTTP_POST, handleUploadComplete, handleUpload);
server.addHandler(new AsyncCallbackJsonWebHandler("/rest/signIn", handleSignIn));
server.addHandler(new AsyncCallbackJsonWebHandler("/rest/recovery/boot", handleSetBoot));
server.addHandler(new StaticContentHandler()); // must be last
server.begin();
}
void setup() {
Serial.begin(115200);
LOG("EMS-ESP Recovery v%s", RECOVERY_VERSION);
const esp_partition_t * boot = esp_ota_get_boot_partition();
LOG("Reset reason: %s, running %s, next boot %s", resetReasonName(esp_reset_reason()), esp_ota_get_running_partition()->label, boot ? boot->label : "unknown");
loadSettings();
loadRecoveryReason();
loadPartitions();
startNetwork();
startWebServer();
}
void loop() {
dnsServer.processNextRequest();
static bool was_connected = false;
if (WiFi.isConnected() != was_connected) {
was_connected = WiFi.isConnected();
if (was_connected) {
LOG("WiFi connected, IP %s", WiFi.localIP().toString().c_str());
} else {
LOG("WiFi connection lost");
}
}
#if CONFIG_IDF_TARGET_ESP32
static bool eth_was_connected = false;
if (ethConnected() != eth_was_connected) {
eth_was_connected = ethConnected();
if (eth_was_connected) {
LOG("Ethernet connected, IP %s", ETH.localIP().toString().c_str());
}
}
#endif
if (wifi_join_at && (int32_t)(millis() - wifi_join_at) >= 0) {
if (ethConnected()) {
wifi_join_at = 0;
} else {
LOG("No Ethernet connection");
joinWiFi();
}
}
if (restart_at && (int32_t)(millis() - restart_at) >= 0) {
ESP.restart();
}
delay(10);
}
+1
View File
@@ -0,0 +1 @@
#define RECOVERY_VERSION "1.0.0"
+6
View File
@@ -110,6 +110,12 @@ def build_webUI(*args, **kwargs):
env.Exit(0)
# The web interface is only built by the "build" target. Skip any other, e.g. `pio run -t upload` also runs this
# environment when it's one of the default_envs, and it has nothing to upload
if "build" not in [t.strip() for t in COMMAND_LINE_TARGETS]:
print("Skipping the web interface build, the target is not 'build'")
env.Exit(0)
# Create custom target that only runs the script and then exits, without continuing with the pio workflow
env.AddCustomTarget(
name="build",
+82
View File
@@ -0,0 +1,82 @@
# Builds the Recovery WebUI into recovery/WWWData.h when it's missing or older than its sources.
# In the build-recovery environment it only builds the WebUI, always.
from pathlib import Path
import os
import shutil
import subprocess
Import("env")
INTERFACE_DIR = Path("interface")
OUTPUT_FILE = Path("recovery") / "WWWData.h"
SOURCES = [
INTERFACE_DIR / "recovery.html",
INTERFACE_DIR / "vite.config.ts",
INTERFACE_DIR / "progmem-generator.js",
INTERFACE_DIR / "src" / "recovery",
INTERFACE_DIR / "src" / "components",
INTERFACE_DIR / "src" / "CustomTheme.tsx",
INTERFACE_DIR / "public",
]
def newest_mtime(paths):
newest = 0.0
for path in paths:
if path.is_dir():
for f in path.rglob("*"):
if f.is_file():
newest = max(newest, f.stat().st_mtime)
elif path.exists():
newest = max(newest, path.stat().st_mtime)
return newest
def is_stale():
if not OUTPUT_FILE.exists():
return True
return newest_mtime(SOURCES) > OUTPUT_FILE.stat().st_mtime
def build():
pnpm = next((n for n in ("pnpm", "pnpm.cmd", "pnpm.exe") if shutil.which(n)), None)
if pnpm is None:
print("Error: pnpm not found in PATH, cannot build the Recovery WebUI")
env.Exit(1)
os.environ["CI"] = "true"
for command in (f"{pnpm} install", f"{pnpm} build-recovery"):
print(f"Running: {command}")
result = subprocess.run(command, shell=True, cwd=INTERFACE_DIR)
if result.returncode != 0:
print(f"Error: '{command}' failed")
env.Exit(1)
if env.get("PIOENV") == "build-recovery":
# the WebUI is only built by the "build" target. Skip any other, e.g. `pio run -t upload` also runs this environment
# when it's one of the default_envs, and it has nothing to upload
if "build" not in [t.strip() for t in COMMAND_LINE_TARGETS]:
print("Skipping the Recovery WebUI build, the target is not 'build'")
env.Exit(0)
# `pio run -e build-recovery` only builds the WebUI, always, and exits without building any firmware
def build_recovery_webUI(*args, **kwargs):
print("Building Recovery WebUI...")
build()
print("Recovery WebUI build completed successfully!")
env.Exit(0)
env.AddCustomTarget(
name="build",
dependencies=None,
actions=[build_recovery_webUI],
title="build recovery web interface",
description="installs pnpm packages and builds the recovery web UI into recovery/WWWData.h",
always_build=True,
)
elif is_stale():
print("Building Recovery WebUI...")
build()
else:
print("Recovery WebUI is up to date")
+124 -17
View File
@@ -1,26 +1,133 @@
#!/usr/bin/env bash
# Run like:
# ./scripts/do_fresh_install.sh
#
# Full fresh install of EMS-ESP on an ESP32 16MB board (s_16M_P).
#
# Flash map (partitions/esp32_partition_16M.csv):
# 0x1000 bootloader
# 0x8000 partition table
# 0xe000 otadata (boot_app0.bin selects app0)
# 0x10000 recovery (factory / boot)
# 0x490000 EMS-ESP (app0)
# 0xdf0000 LittleFS
#
# Because otadata points at app0, the device boots EMS-ESP. Recovery stays
# installed in factory for fallback. Do not write recovery at 0x1000 - that
# is the bootloader, and the image overlaps the partition table at 0x8000.
set -euo pipefail
cd "$(dirname "$0")"
# Bold ANSI: 36 cyan, 33 yellow, 35 magenta, 32 green, 31 red.
step() {
local color=$1
shift
printf '\n\033[1;%sm▶ %s\033[0m\n' "$color" "$*"
}
note() {
printf ' \033[2m%s\033[0m\n' "$*"
}
ESPTOOL=~/.platformio/penv/bin/esptool
PYTHON=~/.platformio/penv/bin/python
PORT=/dev/ttyUSB0
BUILD=../.pio/build/s_16M_P
BOOT_APP0=~/.platformio/packages/framework-arduinoespressif32/tools/partitions/boot_app0.bin
BAUD=${BAUD:-2000000}
# Fast is the installer's default: NVS, otadata, and the first 4 KB of every
# app partition, so nothing old can boot. About a second. ERASE=full still
# wipes the whole chip.
ERASE=${ERASE:-fast}
# erase
$ESPTOOL --chip esp32 --port $PORT --after no-reset erase-flash
BUILD_DIR=../.pio/build/s_16M_P
RECOVERY_DIR=../.pio/build/recovery_s_16M_P
FIRMWARE_DIR=../build/firmware
# create the filesystem
~/.platformio/penv/bin/python build_littlefs.py data $BUILD/littlefs.bin
# bin files
BOOT_APP0_BIN=~/.platformio/packages/framework-arduinoespressif32/tools/partitions/boot_app0.bin
FIRMWARE_BIN=$BUILD_DIR/firmware.bin
RECOVERY_BIN=$RECOVERY_DIR/firmware.bin
BOOTLOADER_BIN=$BUILD_DIR/bootloader.bin
PARTITIONS_BIN=$BUILD_DIR/partitions.bin
LITTLEFS_BIN=$FIRMWARE_DIR/emsesp_settings_v39.bin
# upload filesystem (spiffs partition @ 0xdf0000), leave halted so it doesn't boot yet
$ESPTOOL --chip esp32 --port $PORT --baud 460800 \
--before default-reset --after no-reset \
write-flash -z --flash-mode dio --flash-freq 80m --flash-size detect \
0xdf0000 $BUILD/littlefs.bin
# welcome message
step 33 "Welcome to the EMS-ESP fresh install"
note "This will install the latest EMS-ESP firmware and LittleFS"
# flash bootloader + partition table + app (app0 @ 0x490000), then boot
$ESPTOOL --chip esp32 --port $PORT --baud 460800 \
# check if platformio is installed
if [[ ! -f $PYTHON ]]; then
printf '\033[1;31mPlatformIO is not installed - please install it first\033[0m\n' >&2
exit 1
fi
# check if esptool is installed
if [[ ! -f $ESPTOOL ]]; then
printf '\033[1;31mESPTool is not installed - please install it first\033[0m\n' >&2
exit 1
fi
# Build LittleFS from data folder
step 33 "Building LittleFS from data folder"
note "packs data/pre_load.json -> $LITTLEFS_BIN"
$PYTHON build_littlefs.py data $LITTLEFS_BIN
# Check if all files are present
step 33 "Checking images..."
for f in \
"$BOOTLOADER_BIN" \
"$PARTITIONS_BIN" \
"$FIRMWARE_BIN" \
"$RECOVERY_BIN" \
"$BOOT_APP0_BIN" \
"$LITTLEFS_BIN"; do
if [[ ! -f $f ]]; then
printf '\033[1;31mMissing %s - build s_16M_P and recovery_s_16M_P first\033[0m\n' "$f" >&2
exit 1
fi
note "ok $f"
done
# show port, erase and baud rate
step 33 "Port: $PORT, Erase: $ERASE, Baud: $BAUD"
# Fast erase on the 16 MB table: nvs, otadata, nvs1, and the starts of boot,
# app0 and app1. Stay in the bootloader for the write that follows.
if [[ $ERASE == full ]]; then
step 33 "Full erase - wiping the whole chip"
$ESPTOOL --chip esp32 --port $PORT --after no-reset erase-flash
else
step 33 "Fast erase - NVS, otadata, and the first 4 KB of every app"
note "nothing old can boot; takes about a second"
before=default-reset
for spec in \
"0x9000 0x5000 nvs" \
"0xe000 0x2000 otadata" \
"0x10000 0x1000 boot" \
"0x490000 0x1000 app0" \
"0x920000 0x1000 app1" \
"0xdb0000 0x40000 nvs1"; do
set -- $spec
printf ' \033[33merase-region %s %s (%s)\033[0m\n' "$1" "$2" "$3"
$ESPTOOL --chip esp32 --port $PORT --before $before --after no-reset \
erase-region "$1" "$2"
before=no-reset
done
fi
step 33 "Writing flash, then hard-reset into EMS-ESP"
note "0x1000 bootloader 0x8000 partitions 0xe000 otadata"
note "0x10000 recovery 0x490000 firmware 0xdf0000 LittleFS"
$ESPTOOL --chip esp32 --port $PORT --baud $BAUD \
--before default-reset --after hard-reset \
write-flash -z --flash-mode dio --flash-freq 80m --flash-size detect \
0x1000 $BUILD/bootloader.bin \
0x8000 $BUILD/partitions.bin \
0xe000 $BOOT_APP0 \
0x490000 $BUILD/firmware.bin
0x1000 $BOOTLOADER_BIN \
0x8000 $PARTITIONS_BIN \
0xe000 $BOOT_APP0_BIN \
0x10000 $RECOVERY_BIN \
0x490000 $FIRMWARE_BIN \
0xdf0000 $LITTLEFS_BIN
step 32 "Done - gateway should boot EMS-ESP from app0"
+27
View File
@@ -0,0 +1,27 @@
# Wrap the C/C++ compilers with ccache when it is on PATH.
# No-op locally unless ccache is installed; CI installs it via ccache-action.
Import("env")
import shutil
ccache = shutil.which("ccache")
if ccache:
def wrap(tool):
cmd = env.get(tool)
if not cmd:
return
resolved = env.subst(cmd) if isinstance(cmd, str) else cmd
if isinstance(resolved, str):
if resolved.startswith("ccache"):
return
env[tool] = f"{ccache} {resolved}"
return
if isinstance(resolved, (list, tuple)):
if resolved and "ccache" in str(resolved[0]):
return
env[tool] = [ccache, *resolved]
wrap("CC")
wrap("CXX")
print("ccache enabled")
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env python3
"""Generate src/ESP32React/tz_zones.h from interface/src/app/settings/TZ.tsx.
Run from the repo root:
python3 scripts/generate_tz_zones.py
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
TZ_TSX = ROOT / "interface" / "src" / "app" / "settings" / "TZ.tsx"
OUT_H = ROOT / "src" / "ESP32React" / "tz_zones.h"
def parse_time_zones(text: str) -> list[tuple[str, str]]:
match = re.search(
r"export const TIME_ZONES: Record<string, string> = \{([\s\S]*?)\n\};",
text,
)
if not match:
raise SystemExit("TIME_ZONES table not found in TZ.tsx")
entries = re.findall(r"'((?:\\'|[^'])*)': '((?:\\'|[^'])*)'", match.group(1))
if not entries:
raise SystemExit("no TIME_ZONES entries parsed")
return sorted(entries, key=lambda item: item[0])
def c_string(value: str) -> str:
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
def main() -> int:
entries = parse_time_zones(TZ_TSX.read_text(encoding="utf-8"))
max_format = max(len(fmt) for _, fmt in entries)
lines = [
"/* Auto-generated by scripts/generate_tz_zones.py — do not edit. */",
"#pragma once",
"",
"#include <stdlib.h>",
"#include <string.h>",
"",
"struct EmsespTzZone {",
" const char * label;",
" const char * format;",
"};",
"",
f"// Longest POSIX TZ value in the table is {max_format} chars.",
"static constexpr size_t EMSESP_TZ_ZONE_COUNT = "
f"{len(entries)};",
"static const EmsespTzZone EMSESP_TZ_ZONES[EMSESP_TZ_ZONE_COUNT] = {",
]
for label, fmt in entries:
lines.append(f" {{{c_string(label)}, {c_string(fmt)}}},")
lines += [
"};",
"",
"inline int emsesp_tz_zone_cmp(const void * key, const void * element) {",
" return strcmp(static_cast<const char *>(key), static_cast<const EmsespTzZone *>(element)->label);",
"}",
"",
"// Returns the POSIX TZ string for an IANA label, or nullptr if unknown.",
"inline const char * emsesp_tz_format_for_label(const char * label) {",
" if (label == nullptr || *label == '\\0') {",
" return nullptr;",
" }",
" const auto * found = static_cast<const EmsespTzZone *>(",
" bsearch(label, EMSESP_TZ_ZONES, EMSESP_TZ_ZONE_COUNT, sizeof(EmsespTzZone), emsesp_tz_zone_cmp));",
" return found != nullptr ? found->format : nullptr;",
"}",
"",
]
OUT_H.write_text("\n".join(lines), encoding="utf-8")
print(f"Wrote {OUT_H.relative_to(ROOT)} ({len(entries)} zones, max format {max_format})")
return 0
if __name__ == "__main__":
sys.exit(main())
+1 -1
View File
@@ -35,7 +35,7 @@ summary=$(awk '
}
' "${added}" "${CHANGELOG}")
echo "Development build of EMS-ESP v${VERSION}."
echo "Development build of EMS-ESP v${VERSION}"
echo
if [ -n "${summary}" ]; then
+8 -6
View File
@@ -11,14 +11,16 @@ OUTPUT_DIR = Path("build")
def bin_copy(source, target, env):
"""Optimized firmware renaming and copying function."""
# Get the application version from emsesp_version.h
version_file = Path('./src/emsesp_version.h')
# Get the application version, from emsesp_version.h unless overridden in the env
fw_name = env.GetProjectOption("custom_fw_name", "EMS-ESP")
version_file = Path(env.GetProjectOption("custom_version_file", "./src/emsesp_version.h"))
version_define = env.GetProjectOption("custom_version_define", "EMSESP_APP_VERSION")
if not version_file.exists():
print("Error: emsesp_version.h not found!")
print(f"Error: {version_file} not found!")
return
app_version = None
version_pattern = re.compile(r'^#define EMSESP_APP_VERSION\s+"(\S+)"')
version_pattern = re.compile(rf'^#define {version_define}\s+"(\S+)"')
with version_file.open('r') as f:
for line in f:
@@ -28,7 +30,7 @@ def bin_copy(source, target, env):
break
if not app_version:
print("Error: Could not find EMSESP_APP_VERSION in emsesp_version.h!")
print(f"Error: Could not find {version_define} in {version_file}!")
return
# Get the chip type, in uppercase
@@ -61,7 +63,7 @@ def bin_copy(source, target, env):
# Convert . to _ so Windows doesn't complain
# Format is EMS-ESP-<version>-<mcu>-<flash> with + at the end if it has PSRAM
variant = f"EMS-ESP-{app_version.replace('.', '_')}-{mcu}-{flash_mem}{'+' if psram else ''}"
variant = f"{fw_name}-{app_version.replace('.', '_')}-{mcu}-{flash_mem}{'+' if psram else ''}"
# Create output directories
firmware_dir = OUTPUT_DIR / "firmware"
+69
View File
@@ -0,0 +1,69 @@
# Writes the project name (custom_fw_name) and version (custom_version_file/custom_version_define) into the
# esp_app_desc_t of firmware.bin, which the prebuilt Arduino libraries leave empty. The application can then
# identify the image with esp_ota_get_partition_description(), or from the first bytes of an upload.
# Must run before rename_fw.py so the renamed copy and its MD5 include the change.
import hashlib
import re
import struct
from pathlib import Path
Import("env")
IMAGE_HEADER_LEN = 24
SEGMENT_HEADER_LEN = 8
APP_DESC_OFFSET = IMAGE_HEADER_LEN + SEGMENT_HEADER_LEN
APP_DESC_MAGIC = 0xABCD5432
HASH_APPENDED_OFFSET = 23
def read_version(env):
version_file = Path(env.GetProjectOption("custom_version_file", "./src/emsesp_version.h"))
version_define = env.GetProjectOption("custom_version_define", "EMSESP_APP_VERSION")
pattern = re.compile(rf'^#define {version_define}\s+"(\S+)"')
for line in version_file.read_text().splitlines():
match = pattern.match(line)
if match:
return match.group(1)
raise ValueError(f"{version_define} not found in {version_file}")
def put_string(image, offset, value):
encoded = value.encode()[:31]
image[offset:offset + 32] = encoded + b"\0" * (32 - len(encoded))
def set_app_desc(source, target, env):
path = Path(target[0].get_abspath())
image = bytearray(path.read_bytes())
(magic,) = struct.unpack_from("<I", image, APP_DESC_OFFSET)
if magic != APP_DESC_MAGIC:
print(f"Error: no app description found in {path}")
env.Exit(1)
name = env.GetProjectOption("custom_fw_name", "EMS-ESP")
version = read_version(env)
put_string(image, APP_DESC_OFFSET + 16, version)
put_string(image, APP_DESC_OFFSET + 48, name)
# the description lives in the first segment, so the image checksum and SHA256 need updating
checksum = 0xEF
pos = IMAGE_HEADER_LEN
for _ in range(image[1]):
_, length = struct.unpack_from("<II", image, pos)
pos += SEGMENT_HEADER_LEN
for b in image[pos:pos + length]:
checksum ^= b
pos += length
pos += 15 - (pos % 16)
image[pos] = checksum
pos += 1
if image[HASH_APPENDED_OFFSET] == 1:
image[pos:pos + 32] = hashlib.sha256(image[:pos]).digest()
path.write_bytes(image)
print(f"App description set to {name} {version}")
env.AddPostAction("$BUILD_DIR/${PROGNAME}.bin", set_app_desc)
+12 -2
View File
@@ -6,18 +6,28 @@
# abort on first error so a failed lint/build isn't silently swallowed
set -e
# pnpm refuses to self-update when it's a Corepack shim; Corepack pins the version via packageManager instead
update_pnpm() {
if readlink "$(command -v pnpm)" | grep -q corepack; then
corepack use pnpm@latest
else
pnpm self-update
fi
}
cd interface
rm -rf node_modules
pnpm self-update
update_pnpm
pnpm update
pnpm install
pnpm format
pnpm build-webUI
pnpm build-recovery
pnpm lint
cd ../mock-api
rm -rf node_modules
pnpm self-update
update_pnpm
pnpm update
pnpm install
pnpm format
+157 -67
View File
@@ -14,12 +14,17 @@
# and
# extra_scripts = scripts/upload.py
#
# Prefer a numeric IP over .local — mDNS (especially under WSL2) adds latency
# and can stall on IPv6 before falling back to IPv4.
import hashlib
import os
import socket
import time
from urllib.parse import urlparse
import requests
import hashlib
from urllib.parse import urlparse
import time
import os
from requests.adapters import HTTPAdapter
Import("env")
@@ -35,6 +40,10 @@ except ImportError:
from tqdm import tqdm
from termcolor import cprint
# http.client/urllib3 default is 8–16 KiB, which starves a LAN TCP window.
UPLOAD_BLOCK_SIZE = 64 * 1024
UPLOAD_SNDBUF = 256 * 1024
def print_success(x):
cprint(x, 'green')
@@ -44,6 +53,43 @@ def print_fail(x):
cprint(f'Error: {x}', 'red')
def resolve_ipv4(host):
"""Resolve hostname to IPv4 so we skip mDNS/IPv6 retries on every request."""
if host.startswith('['):
return host
if ':' in host and host.count(':') == 1:
host = host.rsplit(':', 1)[0]
try:
socket.inet_pton(socket.AF_INET, host)
return host
except OSError:
pass
infos = socket.getaddrinfo(host, 80, socket.AF_INET, socket.SOCK_STREAM)
if not infos:
raise socket.gaierror(f'No IPv4 address for {host}')
return infos[0][4][0]
class FastHTTPAdapter(HTTPAdapter):
"""Larger write chunks and socket send buffer for streaming POSTs."""
def init_poolmanager(self, connections, maxsize, block=False, **pool_kwargs):
socket_options = [
(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1),
(socket.SOL_SOCKET, socket.SO_SNDBUF, UPLOAD_SNDBUF),
]
pool_kwargs.setdefault('socket_options', socket_options)
pool_kwargs.setdefault('blocksize', UPLOAD_BLOCK_SIZE)
super().init_poolmanager(connections, maxsize, block=block, **pool_kwargs)
def make_session():
session = requests.Session()
adapter = FastHTTPAdapter(pool_connections=2, pool_maxsize=2)
session.mount('http://', adapter)
return session
def build_headers(host_ip, emsesp_url, content_type='application/json', access_token=None, extra_headers=None):
"""Build common HTTP headers with optional overrides."""
headers = {
@@ -51,21 +97,66 @@ def build_headers(host_ip, emsesp_url, content_type='application/json', access_t
'User-Agent': 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/118.0',
'Accept': '*/*',
'Accept-Language': 'en-US',
'Accept-Encoding': 'gzip, deflate',
'Referer': emsesp_url,
'Content-Type': content_type,
'Connection': 'keep-alive'
}
if access_token:
headers['Authorization'] = f'Bearer {access_token}'
if extra_headers:
headers.update(extra_headers)
return headers
def post_file(session, upload_url, host_ip, emsesp_url, access_token, filename, data, progress_desc=None):
"""POST one multipart file to /rest/uploadFile (WebUI-compatible)."""
encoder = MultipartEncoder(fields={
'file': (filename, data, 'application/octet-stream')
})
bar = None
if progress_desc is not None:
bar = tqdm(
desc=progress_desc,
total=encoder.len,
dynamic_ncols=True,
unit='B',
unit_scale=True,
unit_divisor=1024,
mininterval=0.2
)
monitor = MultipartEncoderMonitor(
encoder, lambda m: bar.update(m.bytes_read - bar.n))
body = monitor
content_type = monitor.content_type
content_length = monitor.len
else:
body = encoder
content_type = encoder.content_type
content_length = encoder.len
headers = build_headers(
host_ip,
emsesp_url,
content_type=content_type,
access_token=access_token,
extra_headers={
'Content-Length': str(content_length),
'Origin': emsesp_url
}
)
started = time.perf_counter()
response = session.post(upload_url, data=body, headers=headers, timeout=None)
elapsed = time.perf_counter() - started
if bar is not None:
bar.close()
return response, encoder.len, elapsed
def on_upload(source, target, env):
# make sure we have set the upload_protocol to custom
@@ -79,90 +170,89 @@ def on_upload(source, target, env):
username = env.GetProjectOption('custom_username')
password = env.GetProjectOption('custom_password')
emsesp_ip = env.GetProjectOption('custom_emsesp_ip')
except Exception as e:
except Exception:
print_fail(f'Missing settings. Add these to your pio_local.ini file:\n\ncustom_username=username\ncustom_password=password\ncustom_emsesp_ip=ems-esp.local\n')
return
emsesp_url = f"http://{emsesp_ip}"
parsed_url = urlparse(emsesp_url)
host_ip = parsed_url.netloc
signon_url = f"{emsesp_url}/rest/signIn"
signon_headers = build_headers(host_ip, emsesp_url)
username_password = {
"username": username,
"password": password
}
response = requests.post(
signon_url, json=username_password, headers=signon_headers)
if response.status_code != 200:
print_fail("Authentication with EMS-ESP failed (code " +
str(response.status_code) + ")")
parsed_url = urlparse(f"http://{emsesp_ip}")
host_name = parsed_url.hostname or emsesp_ip
try:
host_ip = resolve_ipv4(host_name)
except OSError as e:
print_fail(f"Could not resolve {host_name} to IPv4: {e}")
return
print_success("Authentication with EMS-ESP successful")
access_token = response.json().get('access_token')
if host_ip != host_name:
print_success(f"Resolved {host_name} -> {host_ip}")
# start the upload
firmware_path = str(source[0])
emsesp_url = f"http://{host_ip}"
session = make_session()
with open(firmware_path, 'rb') as firmware:
md5 = hashlib.md5(firmware.read()).hexdigest()
try:
signon_url = f"{emsesp_url}/rest/signIn"
signon_headers = build_headers(host_ip, emsesp_url)
firmware.seek(0)
response = session.post(
signon_url,
json={"username": username, "password": password},
headers=signon_headers,
timeout=15)
encoder = MultipartEncoder(fields={
'MD5': md5,
'file': (firmware_path, firmware, 'application/octet-stream')}
)
if response.status_code != 200:
print_fail("Authentication with EMS-ESP failed (code " +
str(response.status_code) + ")")
return
bar = tqdm(desc='Upload Progress',
total=encoder.len,
dynamic_ncols=True,
unit='B',
unit_scale=True,
unit_divisor=1024
)
print_success("Authentication with EMS-ESP successful")
access_token = response.json().get('access_token')
monitor = MultipartEncoderMonitor(
encoder, lambda monitor: bar.update(monitor.bytes_read - bar.n))
post_headers = build_headers(
host_ip,
emsesp_url,
content_type=monitor.content_type,
access_token=access_token,
extra_headers={
'Content-Length': str(monitor.len),
'Origin': emsesp_url
}
)
firmware_path = str(source[0])
with open(firmware_path, 'rb') as firmware_file:
firmware = firmware_file.read()
md5 = hashlib.md5(firmware).hexdigest()
filename = os.path.basename(firmware_path)
# Device enables MD5 check only after a file whose name ends in .md5
# (same as the WebUI). An ElegantOTA "MD5" form field is ignored.
md5_name = os.path.splitext(filename)[0] + '.md5'
upload_url = f"{emsesp_url}/rest/uploadFile"
response = requests.post(
upload_url, data=monitor, headers=post_headers)
bar.close()
time.sleep(0.1)
print(f"Uploading MD5 ({md5})")
response, _, _ = post_file(
session, upload_url, host_ip, emsesp_url, access_token,
md5_name, (md5 + '\n').encode('ascii'))
if response.status_code != 200:
print_fail(f"MD5 upload failed (code {response.status_code}).")
return
print_success("MD5 accepted")
print(f"Uploading firmware ({filename})")
response, size, elapsed = post_file(
session, upload_url, host_ip, emsesp_url, access_token,
filename, firmware, progress_desc='Upload Progress')
print()
if response.status_code != 200:
print_fail(f"Upload failed (code {response.status_code}).")
else:
print_success("Upload successful. Rebooting device.")
kibs = (size / 1024) / elapsed if elapsed > 0 else 0
print_success(
f"Upload successful ({kibs:.0f} KiB/s in {elapsed:.1f}s). Rebooting device.")
restart_headers = build_headers(
host_ip, emsesp_url, access_token=access_token)
restart_url = f"{emsesp_url}/api/system/restart"
response = requests.get(restart_url, headers=restart_headers)
if response.status_code != 200:
print_fail(f"Restart failed (code {response.status_code})")
try:
response = session.get(
restart_url, headers=restart_headers, timeout=10)
if response.status_code != 200:
print_fail(f"Restart failed (code {response.status_code})")
except requests.RequestException:
# Device often drops the connection as it reboots — that's OK.
pass
print()
finally:
session.close()
if env.get('UPLOAD_PROTOCOL') == 'custom':
+131 -69
View File
@@ -9,21 +9,27 @@
#
# Run using for example:
# python3 upload_cli.py -i 10.10.10.175 -f ../build/firmware/EMS-ESP-3_7_0-dev_34-ESP32S3-16MB+.bin
#
# Prefer a numeric IP over .local — mDNS (especially under WSL2) adds latency
# and can stall on IPv6 before falling back to IPv4.
import argparse
import hashlib
import socket
import sys
import time
from pathlib import Path
from urllib.parse import urlparse
import requests
from requests.adapters import HTTPAdapter
from requests_toolbelt import MultipartEncoder, MultipartEncoderMonitor
from termcolor import cprint
from tqdm import tqdm
# Constants
USER_AGENT = 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/118.0'
CHUNK_SIZE = 8192 # 8KB chunks for MD5 calculation
UPLOAD_BLOCK_SIZE = 64 * 1024
UPLOAD_SNDBUF = 256 * 1024
def print_success(x):
@@ -34,13 +40,34 @@ def print_fail(x):
return cprint(x, 'red')
def calculate_md5(file_path):
"""Calculate MD5 hash of a file in chunks for memory efficiency."""
md5_hash = hashlib.md5()
with open(file_path, 'rb') as f:
for chunk in iter(lambda: f.read(CHUNK_SIZE), b''):
md5_hash.update(chunk)
return md5_hash.hexdigest()
def resolve_ipv4(host):
"""Resolve hostname to IPv4 so we skip mDNS/IPv6 retries on every request."""
if host.startswith('['):
return host
if ':' in host and host.count(':') == 1:
host = host.rsplit(':', 1)[0]
try:
socket.inet_pton(socket.AF_INET, host)
return host
except OSError:
pass
infos = socket.getaddrinfo(host, 80, socket.AF_INET, socket.SOCK_STREAM)
if not infos:
raise socket.gaierror(f'No IPv4 address for {host}')
return infos[0][4][0]
class FastHTTPAdapter(HTTPAdapter):
"""Larger write chunks and socket send buffer for streaming POSTs."""
def init_poolmanager(self, connections, maxsize, block=False, **pool_kwargs):
socket_options = [
(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1),
(socket.SOL_SOCKET, socket.SO_SNDBUF, UPLOAD_SNDBUF),
]
pool_kwargs.setdefault('socket_options', socket_options)
pool_kwargs.setdefault('blocksize', UPLOAD_BLOCK_SIZE)
super().init_poolmanager(connections, maxsize, block=block, **pool_kwargs)
def create_base_headers(host_ip, emsesp_url):
@@ -50,43 +77,89 @@ def create_base_headers(host_ip, emsesp_url):
'User-Agent': USER_AGENT,
'Accept': '*/*',
'Accept-Language': 'en-US',
'Accept-Encoding': 'gzip, deflate',
'Referer': emsesp_url,
'Connection': 'keep-alive'
}
def post_file(session, upload_url, host_ip, emsesp_url, access_token, filename, data, progress_desc=None):
"""POST one multipart file to /rest/uploadFile (WebUI-compatible)."""
encoder = MultipartEncoder(fields={
'file': (filename, data, 'application/octet-stream')
})
bar = None
if progress_desc is not None:
bar = tqdm(
desc=progress_desc,
total=encoder.len,
dynamic_ncols=True,
unit='B',
unit_scale=True,
unit_divisor=1024,
mininterval=0.2
)
monitor = MultipartEncoderMonitor(
encoder, lambda m: bar.update(m.bytes_read - bar.n))
body = monitor
content_type = monitor.content_type
content_length = monitor.len
else:
body = encoder
content_type = encoder.content_type
content_length = encoder.len
headers = create_base_headers(host_ip, emsesp_url)
headers.update({
'Content-Type': content_type,
'Content-Length': str(content_length),
'Origin': emsesp_url,
'Authorization': f'Bearer {access_token}'
})
started = time.perf_counter()
response = session.post(upload_url, data=body, headers=headers, timeout=None)
elapsed = time.perf_counter() - started
if bar is not None:
bar.close()
return response, encoder.len, elapsed
def upload(file, ip, username, password):
"""Upload firmware to EMS-ESP device."""
# Print welcome message
print()
print("EMS-ESP Firmware Upload")
# Validate file exists
file_path = Path(file)
if not file_path.exists():
print_fail(f"File not found: {file}")
return
# Setup URLs and headers
emsesp_url = f"http://{ip}"
host_ip = ip
# Use a session for connection pooling and persistence
parsed_url = urlparse(f"http://{ip}")
host_name = parsed_url.hostname or ip
try:
host_ip = resolve_ipv4(host_name)
except OSError as e:
print_fail(f"Could not resolve {host_name} to IPv4: {e}")
return
if host_ip != host_name:
print_success(f"Resolved {host_name} -> {host_ip}")
emsesp_url = f"http://{host_ip}"
session = requests.Session()
session.mount('http://', FastHTTPAdapter(pool_connections=2, pool_maxsize=2))
try:
# Authentication
signon_url = f"{emsesp_url}/rest/signIn"
signon_headers = create_base_headers(host_ip, emsesp_url)
signon_headers['Content-Type'] = 'application/json'
username_password = {
"username": username,
"password": password
}
response = session.post(signon_url, json=username_password, headers=signon_headers)
response = session.post(
signon_url,
json={"username": username, "password": password},
headers=signon_headers,
timeout=15)
if response.status_code != 200:
print_fail(f"Authentication failed (code {response.status_code})")
@@ -95,56 +168,46 @@ def upload(file, ip, username, password):
print_success("Authentication successful")
access_token = response.json().get('access_token')
# Calculate MD5 hash
print("Calculating MD5 hash...")
md5 = calculate_md5(file_path)
firmware = file_path.read_bytes()
md5 = hashlib.md5(firmware).hexdigest()
# Device enables MD5 check only after a file whose name ends in .md5
# (same as the WebUI). An ElegantOTA "MD5" form field is ignored.
md5_name = file_path.with_suffix('.md5').name
upload_url = f"{emsesp_url}/rest/uploadFile"
# Start the upload
with open(file_path, 'rb') as firmware:
encoder = MultipartEncoder(fields={
'MD5': md5,
'file': (file, firmware, 'application/octet-stream')
})
print(f"Uploading MD5 ({md5})")
response, _, _ = post_file(
session, upload_url, host_ip, emsesp_url, access_token,
md5_name, (md5 + '\n').encode('ascii'))
if response.status_code != 200:
print_fail(f"MD5 upload failed (code {response.status_code})")
return
print_success("MD5 accepted")
bar = tqdm(
desc='Upload Progress',
total=encoder.len,
dynamic_ncols=True,
unit='B',
unit_scale=True,
unit_divisor=1024
)
print(f"Uploading firmware ({file_path.name})")
response, size, elapsed = post_file(
session, upload_url, host_ip, emsesp_url, access_token,
file_path.name, firmware, progress_desc='Upload Progress')
monitor = MultipartEncoderMonitor(
encoder, lambda monitor: bar.update(monitor.bytes_read - bar.n))
post_headers = create_base_headers(host_ip, emsesp_url)
post_headers.update({
'Content-Type': monitor.content_type,
'Content-Length': str(monitor.len),
'Origin': emsesp_url,
if response.status_code != 200:
print_fail(f"Upload failed (code {response.status_code})")
else:
kibs = (size / 1024) / elapsed if elapsed > 0 else 0
print_success(
f"Upload successful ({kibs:.0f} KiB/s in {elapsed:.1f}s). Rebooting device.")
restart_headers = create_base_headers(host_ip, emsesp_url)
restart_headers.update({
'Content-Type': 'application/json',
'Authorization': f'Bearer {access_token}'
})
upload_url = f"{emsesp_url}/rest/uploadFile"
response = session.post(upload_url, data=monitor, headers=post_headers)
bar.close()
time.sleep(0.1)
if response.status_code != 200:
print_fail(f"Upload failed (code {response.status_code})")
else:
print_success("Upload successful. Rebooting device.")
restart_headers = create_base_headers(host_ip, emsesp_url)
restart_headers.update({
'Content-Type': 'application/json',
'Authorization': f'Bearer {access_token}'
})
restart_url = f"{emsesp_url}/api/system/restart"
response = session.get(restart_url, headers=restart_headers)
restart_url = f"{emsesp_url}/api/system/restart"
try:
response = session.get(
restart_url, headers=restart_headers, timeout=10)
if response.status_code != 200:
print_fail(f"Restart failed (code {response.status_code})")
except requests.RequestException:
pass
except requests.RequestException as e:
print_fail(f"Network error: {e}")
@@ -161,7 +224,6 @@ def upload(file, ip, username, password):
print()
# main
parser = argparse.ArgumentParser(description="EMS-ESP Firmware Upload")
parser.add_argument("-f", "--file", metavar="FILE",
required=True, type=str, help="firmware file")

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