From 6411b6ffbd143bc384afd1a293b6abeee08ff5de Mon Sep 17 00:00:00 2001 From: proddy Date: Tue, 6 Oct 2026 22:57:25 +0200 Subject: [PATCH 01/13] add recovery firmware with OTA updater and recovery web interface Co-authored-by: Cursor --- .github/workflows/dev_release.yml | 4 + .github/workflows/stable_release.yml | 4 + .github/workflows/test_release.yml | 4 + .gitignore | 2 + CHANGELOG_LATEST.md | 5 + docs/Recovery-Testing.md | 156 ++++ docs/dump_telegrams.csv | 8 +- interface/.prettierignore | 1 + interface/eslint.config.js | 1 + interface/package.json | 6 +- interface/pnpm-lock.yaml | 282 +++--- interface/progmem-generator.js | 16 +- interface/recovery.html | 13 + interface/src/app/settings/Version.tsx | 20 +- .../src/components/upload/SingleUpload.tsx | 13 +- interface/src/i18n/cs/index.ts | 5 +- interface/src/i18n/de/index.ts | 5 +- interface/src/i18n/en/index.ts | 5 +- interface/src/i18n/fr/index.ts | 5 +- interface/src/i18n/it/index.ts | 5 +- interface/src/i18n/nl/index.ts | 5 +- interface/src/i18n/no/index.ts | 5 +- interface/src/i18n/pl/index.ts | 5 +- interface/src/i18n/sk/index.ts | 5 +- interface/src/i18n/sv/index.ts | 5 +- interface/src/i18n/tr/index.ts | 5 +- interface/src/recovery/Dashboard.tsx | 380 ++++++++ interface/src/recovery/FirmwareUpload.tsx | 208 +++++ interface/src/recovery/RecoveryApp.tsx | 132 +++ interface/src/recovery/RestartMonitor.tsx | 149 ++++ interface/src/recovery/SignIn.tsx | 142 +++ interface/src/recovery/api.ts | 107 +++ interface/src/recovery/index.tsx | 10 + interface/src/recovery/types.ts | 54 ++ interface/src/types/system.ts | 1 + interface/vite.config.ts | 60 ++ lib/OtaUpdater/OtaUpdater.cpp | 236 +++++ lib/OtaUpdater/OtaUpdater.h | 76 ++ mock-api/package.json | 2 +- mock-api/pnpm-lock.yaml | 122 +-- mock-api/recoveryMock.js | 214 +++++ mock-api/restServer.ts | 10 + platformio.ini | 65 +- project-words.txt | 6 +- recovery/main.cpp | 840 ++++++++++++++++++ recovery/recovery_version.h | 1 + scripts/build_recovery_interface.py | 59 ++ scripts/rename_fw.py | 14 +- scripts/set_app_desc.py | 69 ++ scripts/update_all.sh | 13 +- scripts/upload_factory.py | 28 + src/ESP32React/UploadFileService.cpp | 175 ++-- src/ESP32React/UploadFileService.h | 14 +- src/core/emsesp.cpp | 2 + src/core/emsesp.h | 2 + src/core/main.cpp | 20 + src/core/system.cpp | 117 +++ src/core/system.h | 13 + src/web/WebStatusService.cpp | 3 + 59 files changed, 3595 insertions(+), 339 deletions(-) create mode 100644 docs/Recovery-Testing.md create mode 100644 interface/recovery.html create mode 100644 interface/src/recovery/Dashboard.tsx create mode 100644 interface/src/recovery/FirmwareUpload.tsx create mode 100644 interface/src/recovery/RecoveryApp.tsx create mode 100644 interface/src/recovery/RestartMonitor.tsx create mode 100644 interface/src/recovery/SignIn.tsx create mode 100644 interface/src/recovery/api.ts create mode 100644 interface/src/recovery/index.tsx create mode 100644 interface/src/recovery/types.ts create mode 100644 lib/OtaUpdater/OtaUpdater.cpp create mode 100644 lib/OtaUpdater/OtaUpdater.h create mode 100644 mock-api/recoveryMock.js create mode 100644 recovery/main.cpp create mode 100644 recovery/recovery_version.h create mode 100644 scripts/build_recovery_interface.py create mode 100644 scripts/set_app_desc.py create mode 100644 scripts/upload_factory.py diff --git a/.github/workflows/dev_release.yml b/.github/workflows/dev_release.yml index f518634e4..20e7b480d 100644 --- a/.github/workflows/dev_release.yml +++ b/.github/workflows/dev_release.yml @@ -71,6 +71,10 @@ jobs: run: | platformio run + - name: Build the recovery firmware for the 16MB boards + 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 with: diff --git a/.github/workflows/stable_release.yml b/.github/workflows/stable_release.yml index f1f3b7dfe..c96af1acb 100644 --- a/.github/workflows/stable_release.yml +++ b/.github/workflows/stable_release.yml @@ -60,6 +60,10 @@ jobs: run: | platformio run + - name: Build the recovery firmware for the 16MB boards + run: | + platformio run -e recovery_s_16M -e recovery_s_16M_P -e recovery_s3_16M_P + - name: Create GitHub Release uses: emsesp/action-automatic-releases@v1.0.1 with: diff --git a/.github/workflows/test_release.yml b/.github/workflows/test_release.yml index ff961b26a..cc46c6a2c 100644 --- a/.github/workflows/test_release.yml +++ b/.github/workflows/test_release.yml @@ -61,6 +61,10 @@ jobs: run: | platformio run + - name: Build the recovery firmware for the 16MB boards + run: | + platformio run -e recovery_s_16M -e recovery_s_16M_P -e recovery_s3_16M_P + - name: Create GitHub Release id: 'automatic_releases' uses: emsesp/action-automatic-releases@v1.0.1 diff --git a/.gitignore b/.gitignore index 4039aeca4..28a1b7355 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/CHANGELOG_LATEST.md b/CHANGELOG_LATEST.md index 78089d484..132ca0314 100644 --- a/CHANGELOG_LATEST.md +++ b/CHANGELOG_LATEST.md @@ -20,6 +20,11 @@ This release is based on the latest Espressif/Arduino core version 3. It brings - 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 `-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---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` ## Fixed diff --git a/docs/Recovery-Testing.md b/docs/Recovery-Testing.md new file mode 100644 index 000000000..2177451a5 --- /dev/null +++ b/docs/Recovery-Testing.md @@ -0,0 +1,156 @@ +# Testing the Recovery Firmware, Rollback and Crash-Loop Fallback + +This guide covers testing on hardware of: + +- the EMS-ESP Recovery firmware in the factory `boot` partition (16MB boards only); +- installing the recovery from the EMS-ESP WebUI; +- the bootloader rollback of a new firmware that fails to start; +- the fallback to the recovery after repeated crashes; +- Ethernet and static IP support in the recovery. + +It's written for an ESP32-S3 16MB board, since the crash test build is only defined for `s3_16M_P`. Ethernet testing needs an E32V2. Keep a USB serial cable connected with a serial monitor open throughout, as most of the checks are log lines. + +## 0. Preparation + +Build everything: + +```bash +pio run -e s3_16M_P -e crashtest_s3_16M_P -e recovery_s3_16M_P +pio run -e s_16M -e s_16M_P -e s_4M -e recovery_s_16M -e recovery_s_16M_P +``` + +The firmware files are written to `build/firmware/`: + +| File | What it is | +| --- | --- | +| `EMS-ESP--ESP32S3-16MB+.bin` | EMS-ESP | +| `EMS-ESP-CrashTest--ESP32S3-16MB+.bin` | crash test build | +| `EMS-ESP-Recovery--ESP32S3-16MB+.bin` | the recovery | + +Each has a matching `.md5` file. + +Then: + +1. Run the unit tests with `pio run -e native-test -t exec`. All tests should pass. +2. Optionally, check the recovery UI against mock data first: `cd interface && pnpm recovery`, then open . +3. Open the serial monitor with `pio device monitor -e s3_16M_P`. Recovery log lines start with `[recovery]`. + +The crash counter is kept in RTC memory. It survives crashes and software restarts but not a power cycle, so don't unplug the board during the crash tests. + +## 1. Baseline: EMS-ESP over serial, no recovery + +1. Flash with `pio run -e s3_16M_P -t upload`. This also writes the current bootloader, which has rollback enabled. Boards flashed with an older bootloader don't roll back until they've been flashed over serial once. +2. In the WebUI, open **Settings → Version**. Check that: + - the **Recovery** row shows "not installed"; + - the firmware partition list doesn't include `boot`. +3. Wait more than 2 minutes. "New firmware confirmed as working" should **not** appear in the log, because a serial flash isn't in the pending-verify state. + +## 2. Install the recovery from the EMS-ESP WebUI + +1. On **Settings → Version**, upload `EMS-ESP-Recovery-…bin`. Expect: + - a "recovery installed" toast; + - **no** restart; + - the Recovery row now shows the recovery version; + - the install logged. +2. Repeat, uploading the `.md5` first and then the `.bin`. The MD5 check should pass. +3. Negative tests: + - Upload the recovery built for another chip, e.g. the ESP32 file on an S3. It should be rejected, and nothing should be written. + - Start a recovery upload and close the browser tab halfway through. EMS-ESP should keep running and the Recovery row should show "not installed". Reinstall it properly afterwards. +4. Restart EMS-ESP normally. It should boot EMS-ESP again, not the recovery, because installing the recovery doesn't change the boot partition. + +## 3. Enter the recovery on request + +1. Enter the recovery in one of these ways: + - in the console (telnet or serial) as admin, run `restart boot`; + - long-press the button. +2. On the serial monitor, check that: + - `[recovery] EMS-ESP Recovery v…` is logged; + - LittleFS is mounted; + - the access point has started; + - WiFi connects. +3. Join the access point. Its name is the AP SSID plus `-recovery` (default `ems-esp-recovery`), and it uses the AP password (default `ems-esp-neo`). Open ; a captive portal page may open by itself. Alternatively, use the device's normal IP or `http://.local` from the LAN. +4. Sign in with an EMS-ESP admin account (default `admin`/`admin`). A non-admin user must be refused. +5. On the dashboard, check that: + - there's **no** red reason box, because the restart was requested; + - `Recovery (boot)` has the "running" chip; + - the installed firmware shows the correct version; + - the System section shows the chip, flash, MAC, access point, and WiFi with its IP. +6. Click **Restart**. It should come back into the recovery, still without a reason box, since the reason is only shown once. +7. Click **Start** on the EMS-ESP slot. EMS-ESP should boot, and the Version page should still show the recovery version. + +## 4. Upload firmware from the recovery + +1. Enter the recovery again with `restart boot`. +2. Check the "upload target" chip: + - if there's an empty slot, that slot is chosen; + - if both slots hold a firmware, the one with the older install date is chosen, so the newest firmware is kept. +3. Upload the `.md5`, then the EMS-ESP `.bin`. EMS-ESP should start from that slot. About 2 minutes after it boots, "New firmware confirmed as working" should appear in the log. +4. The recovery must refuse these uploads: + - a recovery `.bin`: a 409 with a message that it can't install itself; + - a settings `.json`: a 406; + - an EMS-ESP `.bin` for another chip: rejected as incompatible. +5. In **Write to**, pick the other slot and upload there. That should also work. + +## 5. Bootloader rollback (crash test mode 1) + +`platformio.ini` ships with `-D EMSESP_CRASH_TEST=1`. + +1. Boot a working EMS-ESP and let it run for at least 2 minutes so it's confirmed. +2. Upload `EMS-ESP-CrashTest-…bin` from the **WebUI**, not over serial. +3. On the serial monitor you should see: + - `Crash test, aborting`, a panic, and a restart; + - the bootloader rolling back to the previous slot, which then starts normally. +4. In the EMS-ESP log (serial, or **Status → System Log**) you should see: + - `Restarted after a crash (1 in a row)`; + - `Firmware in partition appX failed to start and was rolled back`. +5. Enter the recovery with `restart boot`. Check that: + - the red box says the firmware in appX failed to start; + - that slot has the "failed to start" chip; + - that slot is the upload target. +6. Click **Start** on the failed slot. The dialog should show a warning; cancel it. +7. Start the good slot. + +If step 3 ends in a crash loop instead of a rollback, the bootloader on the board doesn't have rollback enabled. Flash over serial as in section 1, step 1. + +## 6. Crash-loop fallback to the recovery (crash test mode 2) + +1. In `platformio.ini`, change `-D EMSESP_CRASH_TEST=1` to `=2`, then run `pio run -e crashtest_s3_16M_P`. +2. Upload the crash test build from the WebUI. It runs normally. +3. Wait for "New firmware confirmed as working", about 2 minutes after boot. +4. Restart it from the WebUI. It should crash 5 times in a row, logging the "N in a row" count each time. +5. After the 5th crash you should see `Crashed 5 times in a row, restarting into the boot partition`, and the recovery starts. +6. In the recovery: + - the red box should say "EMS-ESP crashed repeatedly and switched to the recovery"; + - after restarting the recovery once, the box should be gone. +7. Clean up: in **Write to**, pick the crash test slot **by hand** and upload a good EMS-ESP, or Start the good slot. + - Switching to the recovery wipes the per-slot rollback states, so the default upload target may be the slot with the good firmware. +8. Change the flag back to `=1`. + +Optional, without a recovery installed: + +1. Erase the boot partition with `esptool --chip esp32s3 erase-region 0x10000 0x480000`. Older esptool versions use `erase_region`. +2. Repeat steps 2 to 4. It should keep crashing past 5, and needs to be reflashed over serial. + +## 7. Network in the recovery + +| Test | Setup | Expected in the recovery | +| --- | --- | --- | +| WiFi with static IP | Enable a static IP in the EMS-ESP Network settings, then `restart boot` | Same IP as EMS-ESP, and the WiFi row ends with ", static IP" | +| Ethernet (E32V2) | Cable plugged in, `recovery_s_16M_P` installed, then `restart boot` | Log shows "Ethernet started" and "Ethernet connected, IP …". The Ethernet row shows "connected", and WiFi shows "not used, Ethernet is connected" | +| Ethernet with static IP | As above, with a static IP set | Ethernet uses the static IP and WiFi isn't started | +| No cable | Unplugged, then `restart boot` | Ethernet shows "no cable connected" and WiFi shows "waiting for Ethernet". After about 15 seconds the log shows "No Ethernet connection" and WiFi connects | +| Cable plugged in later | Plug in after WiFi has joined | Ethernet gets an IP as well, and WiFi stays connected | +| Access point | Any of the above | `ems-esp-recovery` is always reachable on 192.168.4.1 | + +## 8. Other checks + +- **Online update during the pending window:** after a WebUI upgrade, start an online update from the Version page within 2 minutes. It should install, not fail. +- **4MB boards:** flash `s_4M` and check that: + - there's no Recovery row; + - a normal WebUI upgrade still gets confirmed after 2 minutes. +- **Translations:** switch the WebUI language and check the Recovery row and toast. +- **CI:** run the dev release workflow manually (`workflow_dispatch`) and check that the `EMS-ESP-Recovery-*` files for all three 16MB boards are among the release assets. + +## Reset to a clean state + +`pio run -e s3_16M_P -t upload` restores EMS-ESP at any point. It leaves the recovery in `boot` and the settings untouched. diff --git a/docs/dump_telegrams.csv b/docs/dump_telegrams.csv index dc630de2a..8686d8ef6 100644 --- a/docs/dump_telegrams.csv +++ b/docs/dump_telegrams.csv @@ -29,7 +29,7 @@ telegram_type_id,name,is_fetched 0x3B,Energy, 0x3D,RC35Set, 0x3E,RC35Monitor, -0x3F,RC30Timer, +0x3F,RC35Timer, 0x40,RC30Temp, 0x41,RC30Monitor, 0x42,RC35Timer2, @@ -112,9 +112,9 @@ telegram_type_id,name,is_fetched 0x02A0,RC300Curves, 0x02A1,RC300Curves, 0x02A2,RC300Curves, -0x02A5,CRFMonitor, -0x02A6,RC300Monitor, -0x02A7,RC300Monitor, +0x02A5,RC300Monitor, +0x02A6,CRFMonitor, +0x02A7,CRFMonitor, 0x02A8,RC300Monitor, 0x02A9,RC300Monitor, 0x02AA,RC300Monitor, diff --git a/interface/.prettierignore b/interface/.prettierignore index 920369742..0bc827e2a 100644 --- a/interface/.prettierignore +++ b/interface/.prettierignore @@ -1,6 +1,7 @@ node_modules/ build/ dist/ +dist-recovery/ src/i18n/* .prettierrc diff --git a/interface/eslint.config.js b/interface/eslint.config.js index bacff3965..42b5c483f 100644 --- a/interface/eslint.config.js +++ b/interface/eslint.config.js @@ -18,6 +18,7 @@ export default defineConfig( { ignores: [ 'dist/*', + 'dist-recovery/*', '*.mjs', 'build/*', '*.js', diff --git a/interface/package.json b/interface/package.json index 0426a9136..ce0e5cd1d 100644 --- a/interface/package.json +++ b/interface/package.json @@ -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\"" @@ -54,9 +56,9 @@ "rollup-plugin-visualizer": "^7.1.1", "terser": "^5.51.2", "typescript": "^6.0.3", - "typescript-eslint": "^8.71.0", + "typescript-eslint": "^8.71.1", "vite": "^8.3.2", "vite-plugin-imagemin": "^0.6.1" }, - "packageManager": "pnpm@12.8.1" + "packageManager": "pnpm@12.9.1" } diff --git a/interface/pnpm-lock.yaml b/interface/pnpm-lock.yaml index faaee8b4f..eab249366 100644 --- a/interface/pnpm-lock.yaml +++ b/interface/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.8.1 - version: 12.8.1 + specifier: 12.9.1 + version: 12.9.1 packages: - '@pnpm/exe.android-arm64@12.8.1': - resolution: {integrity: sha512-siDxcidfz5eM6L2tJfik+i2bbUEw8UC7MZ3Yl2HYdRmqbdhHGAOQACtOOu6QVW4Jul4/w5wVKm+wM8ACtgh1Pg==} + '@pnpm/exe.android-arm64@12.9.1': + resolution: {integrity: sha512-Hgm1XdqMTfBC8PdGLL+NjZWv4xyi7D6XNrN9fb24XGYlyTBmUtdCefKnCLmHujShRD8BEO1zqM30SNcKBDLysw==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.8.1': - resolution: {integrity: sha512-0F/uqPRc3aN4vLHsa0f+6fTtmsigFQ9/yxEU43fionF//pNHEd5e97b3w/nKIgqYMCUQCJphmBRMA9Q8/wlo9Q==} + '@pnpm/exe.android-x64@12.9.1': + resolution: {integrity: sha512-WDGCe6jZRVZP3RC/q6hOkZylkPkIQIi1/GcrR/d/YzbihhAHXCczrk+UYnV5TgbsEnAYO7Cx+2rX3nfhHvy24w==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.8.1': - resolution: {integrity: sha512-/rwavvMQJsl2RIxOHBnDF+4Gk+fhQFAY4M3PQZoKskJOEnaJcygG9p1xby6wcQcbS4d9dIubv09CQhufTgfmpw==} + '@pnpm/exe.darwin-arm64@12.9.1': + resolution: {integrity: sha512-+7iiEEBgYN9szPWUnAuN6eOVMTI6Hrki5bcuC9n9xl9tTnT53Tpxpo45Hml+W4IrAU0d+Tmd0sPnVIbX59PD6w==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.8.1': - resolution: {integrity: sha512-htYt2gN7zqJKLhC99Ft6EUiO7myjZAZXfmGCTJUL1v+o7vE9hZdE1+fAkk4Oj1ZHNRti6b2woOxEkRkAusi8OQ==} + '@pnpm/exe.darwin-x64@12.9.1': + resolution: {integrity: sha512-SK7TxJiR12GbJZGffRFHItkc+Mx+99v6xMMJ9/X//8NwWV1wo17a2bwfGI8qZpl17YwsrmPEFOOqhzE1GIlrtQ==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.8.1': - resolution: {integrity: sha512-PB5BULNgytxvExoOXUUeseS/DcpPENs+cg3iA6xHHeaLT4ctEGKSGXEbuj7USeqvo8JBpAx039hivnbzGqDwnA==} + '@pnpm/exe.freebsd-x64@12.9.1': + resolution: {integrity: sha512-4AjLk6uvTs2iO6wMit2hcxJ2M32xb7OV5SNFk+w6G8/NzcjcMB7ENEpyeXyLiP0kcnp8uaCGUct2NfrLCJ35eQ==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.8.1': - resolution: {integrity: sha512-kKeyC/ArjrgdciQRBrOL0j9HtOI4gDRgoyP+eJ1CQKyadpKlf/DhDDyspXe07R3B9KagTVLUF2nlt6Dr5quFIg==} + '@pnpm/exe.linux-arm64-musl@12.9.1': + resolution: {integrity: sha512-dZL2ES1ckDWM9v+EtxoOklZ0WNdtayooi9iLiDUubopudX3zXPNR2mlrxBRgjbDgECOndQen2SmyxL5eZiBuKw==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.8.1': - resolution: {integrity: sha512-q4s9a9X2O3N9aeUFooW24orNrxvu20+jyVUFR5RanPCqOKKPBrgs2iywMkBBx1aXkkzdWT54/mfz8Be8sJlWEw==} + '@pnpm/exe.linux-arm64@12.9.1': + resolution: {integrity: sha512-cx+uF6xqwEtm7B8NV+1ve0xBg9gcuzQZ5R+0WgOWvKG/h978UljS6BgWs1ipIGuGgUdeVhm3PKmBltXIcczLXA==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.8.1': - resolution: {integrity: sha512-F7XSjKJthwCh/L6abZiMpAfo2Cm61SZIT3e4dfgiIjESndlWaXCnfLKngSgZ/SEMsdmHT6dud3+kWIB3tw0/xQ==} + '@pnpm/exe.linux-ppc64@12.9.1': + resolution: {integrity: sha512-MpjPsy4XR17zAtLziQZfsN+wveWa/bUJggZtwfo3+SuyRmXnSvVkFD4+v8/3RTWyYaM6mwLUTjZDJ+g3urL+ew==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.8.1': - resolution: {integrity: sha512-z1ecpIK/ZY08Unk69KWWE9867oaKUytGUjrHnBoGFAAhsK+q+0cfvBH+f5fWobJ7FX97leMlLO+gkJolddM45Q==} + '@pnpm/exe.linux-riscv64@12.9.1': + resolution: {integrity: sha512-Oo67TKqqH+Bno+L6X7Xyorwp1QG82zKIkL0vbo1x2kcPTDiyPbdM+d6lIs4+hzeB1KHT5FDoGzZj2dad7Ft+eg==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.8.1': - resolution: {integrity: sha512-KU6tnrBfu4uKXTq6N5qJwSm8FSrs9Wc8b5AUEsHK9bRXwNdsZ1/lRxG1++X29SKRW9OSNuDSjxASv5mAvGo1gA==} + '@pnpm/exe.linux-s390x@12.9.1': + resolution: {integrity: sha512-QAD5FRCdW36HfD58BV7DoDm6z8yv+GgSqcUV1+ERwYjpVQuG6ZTpf5o0x3KDas8eTLIW7MLYVdDdlAurWeArEg==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.8.1': - resolution: {integrity: sha512-eUE8BWJkDr2tJb/yrk0yVnszlmNqFarVxUlqiZ9BAkct28Nq/5g2H0tvMDmPA7fAQW3STNj+omJPPRVT+4s5sQ==} + '@pnpm/exe.linux-x64-musl@12.9.1': + resolution: {integrity: sha512-mQDhBsGqcTkrLQM4q5igmRFcr6EAl4UM/FGawY+0CWGcqPpUmYUvDy9LHy80xxkHIWVJ886gvvso2XOjfav1Jw==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.8.1': - resolution: {integrity: sha512-8bDZ0lZlCdi2rvnFul7EYgcC7zKeWSe76y8JV2oXobaS8p9i9d6PSf6LgLtTM0fI7R9Oh4eLCbveXyNDMmvZ+g==} + '@pnpm/exe.linux-x64@12.9.1': + resolution: {integrity: sha512-+M5N74hM4fc87AAckxx/UIW+dUnWf2eko+HchJs/NZUxehLhekNyMI/GNaNXlvd+kWR+97XXBD51XRkkNiUtrA==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.8.1': - resolution: {integrity: sha512-MsT0dlrRxnCRCHCaosNbhWpEiAFnFuMEu3KuDJDU41BXgTwiINxU8goloUSUDejWZUqKaUqNC/qUTihl9nFtqg==} + '@pnpm/exe.win32-arm64@12.9.1': + resolution: {integrity: sha512-mI28C2wxobrDi6x1OYhBRyG10hnDq/4sEIyy5RzoX6LB4kLuv6K/d25HOpMwlW20cuM4M72k9NZpOdcmUxMbEA==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.8.1': - resolution: {integrity: sha512-SWtPe0PsbTTk//gJND10tMdOfCYjrkq5+qV49hzVhY7xz2iJ339Rc+xwASlcvbwTfrvH39YNqpKYibD+CRGLwA==} + '@pnpm/exe.win32-x64@12.9.1': + resolution: {integrity: sha512-SWoDMT0oD7NrDSIoPpkKD09D9fAQKNjwFZ3lezlCUhAfXxMv70zsfrNT2/P+GoCK2kefXF9OysUZkH1l5vvvfA==} cpu: [x64] os: [win32] - pnpm@12.8.1: - resolution: {integrity: sha512-9kupB1B/XOr+BsjTjmBS0BeURFgOwSed3Vv8EctIqoomRLZlmOB+dh2oSHKp/FfV+QK4f6SdAkGY1VhhKqu+RQ==} + pnpm@12.9.1: + resolution: {integrity: sha512-BrBV//XNINwSeB3hc87TMQzglRvy7GICEaFgRdVETVyTpmMhB2TvKaZTphBFm6A2jw50+E2AxUcjz5Wq57wNbQ==} engines: {node: '>=18.*'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.8.1': + '@pnpm/exe.android-arm64@12.9.1': optional: true - '@pnpm/exe.android-x64@12.8.1': + '@pnpm/exe.android-x64@12.9.1': optional: true - '@pnpm/exe.darwin-arm64@12.8.1': + '@pnpm/exe.darwin-arm64@12.9.1': optional: true - '@pnpm/exe.darwin-x64@12.8.1': + '@pnpm/exe.darwin-x64@12.9.1': optional: true - '@pnpm/exe.freebsd-x64@12.8.1': + '@pnpm/exe.freebsd-x64@12.9.1': optional: true - '@pnpm/exe.linux-arm64-musl@12.8.1': + '@pnpm/exe.linux-arm64-musl@12.9.1': optional: true - '@pnpm/exe.linux-arm64@12.8.1': + '@pnpm/exe.linux-arm64@12.9.1': optional: true - '@pnpm/exe.linux-ppc64@12.8.1': + '@pnpm/exe.linux-ppc64@12.9.1': optional: true - '@pnpm/exe.linux-riscv64@12.8.1': + '@pnpm/exe.linux-riscv64@12.9.1': optional: true - '@pnpm/exe.linux-s390x@12.8.1': + '@pnpm/exe.linux-s390x@12.9.1': optional: true - '@pnpm/exe.linux-x64-musl@12.8.1': + '@pnpm/exe.linux-x64-musl@12.9.1': optional: true - '@pnpm/exe.linux-x64@12.8.1': + '@pnpm/exe.linux-x64@12.9.1': optional: true - '@pnpm/exe.win32-arm64@12.8.1': + '@pnpm/exe.win32-arm64@12.9.1': optional: true - '@pnpm/exe.win32-x64@12.8.1': + '@pnpm/exe.win32-x64@12.9.1': optional: true - pnpm@12.8.1: + pnpm@12.9.1: optionalDependencies: - '@pnpm/exe.android-arm64': 12.8.1 - '@pnpm/exe.android-x64': 12.8.1 - '@pnpm/exe.darwin-arm64': 12.8.1 - '@pnpm/exe.darwin-x64': 12.8.1 - '@pnpm/exe.freebsd-x64': 12.8.1 - '@pnpm/exe.linux-arm64': 12.8.1 - '@pnpm/exe.linux-arm64-musl': 12.8.1 - '@pnpm/exe.linux-ppc64': 12.8.1 - '@pnpm/exe.linux-riscv64': 12.8.1 - '@pnpm/exe.linux-s390x': 12.8.1 - '@pnpm/exe.linux-x64': 12.8.1 - '@pnpm/exe.linux-x64-musl': 12.8.1 - '@pnpm/exe.win32-arm64': 12.8.1 - '@pnpm/exe.win32-x64': 12.8.1 + '@pnpm/exe.android-arm64': 12.9.1 + '@pnpm/exe.android-x64': 12.9.1 + '@pnpm/exe.darwin-arm64': 12.9.1 + '@pnpm/exe.darwin-x64': 12.9.1 + '@pnpm/exe.freebsd-x64': 12.9.1 + '@pnpm/exe.linux-arm64': 12.9.1 + '@pnpm/exe.linux-arm64-musl': 12.9.1 + '@pnpm/exe.linux-ppc64': 12.9.1 + '@pnpm/exe.linux-riscv64': 12.9.1 + '@pnpm/exe.linux-s390x': 12.9.1 + '@pnpm/exe.linux-x64': 12.9.1 + '@pnpm/exe.linux-x64-musl': 12.9.1 + '@pnpm/exe.win32-arm64': 12.9.1 + '@pnpm/exe.win32-x64': 12.9.1 --- lockfileVersion: '9.0' @@ -255,8 +255,8 @@ importers: specifier: ^6.0.3 version: 6.0.3 typescript-eslint: - specifier: ^8.71.0 - version: 8.71.0(eslint@10.12.0(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) + specifier: ^8.71.1 + version: 8.71.1(eslint@10.12.0(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) vite: specifier: ^8.3.2 version: 8.3.2(@types/node@26.6.4)(terser@5.51.2) @@ -870,63 +870,63 @@ packages: '@types/svgo@2.6.4': resolution: {integrity: sha512-l4cmyPEckf8moNYHdJ+4wkHvFxjyW6ulm9l4YGaOxeyBWPhBOT0gvni1InpFPdzx1dKf/2s62qGITwxNWnPQng==} - '@typescript-eslint/eslint-plugin@8.71.0': - resolution: {integrity: sha512-pqcS9c1HxZTHt7End4nXqd0s5lJrrFzrgCkKFJrsbUnaL6M3+6oBFZaslg6Gjsl3argl2DDRFROnXARaZ2e4Nw==} + '@typescript-eslint/eslint-plugin@8.71.1': + resolution: {integrity: sha512-nNlBf6HuotqMgZvSkWD5hNdemiBIxQ2NmrlgfcUIeZRs+BbakjscCQRq/Hb74EFVnFiSWWdL12JruqwBy3ERDw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.71.0 + '@typescript-eslint/parser': ^8.71.1 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/parser@8.71.0': - resolution: {integrity: sha512-CG4nPk1f2zc8yw4pALqHsFYH2hdo+h1T9daSp21+Hnxi9LOE3GT9hAfTKJCBXVNM2GmYs1eMEP615wPoeOgk3A==} + '@typescript-eslint/parser@8.71.1': + resolution: {integrity: sha512-oM/5sAqz/l1v/zYK1qTOPrn2+JIrBGC1V6uNYucRO5gh6eT9vWJ/RhPbGE59byvZEUQ4VP6XkLBaS5Jti5jpvw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/project-service@8.71.0': - resolution: {integrity: sha512-aABjw5rjBacYONVPaPiWOCjJu0vEF4a25iQuodlmQYL1trtLZ0X/y+2Vzl3BKI1odM4LnwLE1oUDXYp1wzx1TQ==} + '@typescript-eslint/project-service@8.71.1': + resolution: {integrity: sha512-Oijc9RUsohHjncg6iGi7gCtjnEjYt9FWv9u6ygK8uKcHK5eLjLAkqERQ9BaytbpceOFdk6kifPCS/zAZE+A99g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/scope-manager@8.71.0': - resolution: {integrity: sha512-gWF0BhUcnjZxSpLE8ngS/59n2SB0J3YqRxvX1+2aoRJk9hNtHSLOV+TcarFiOr5ipXm3yc1QrI4c9YZc8zyCxw==} + '@typescript-eslint/scope-manager@8.71.1': + resolution: {integrity: sha512-1YUDZXdTnXLlob03SFHBipdyMBsDVm7zoSz0ytTJ39dk5J1TgsTK03VlR/dLXG/RLLUrPcYbD4rp23BmHxHP0w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.71.0': - resolution: {integrity: sha512-Z1UlWHADEK2Mlb9NpWfDeSjqoZ5EyrOv4R3eQpbkzqn/EwaIdOpXXupEA1+0ZIOSJSZZDBHG0BrQyN8zUG6Pwg==} + '@typescript-eslint/tsconfig-utils@8.71.1': + resolution: {integrity: sha512-oTvkml5SxXhgg+WWKhod5qWQICDXtOB+/Hi9VdPKFAANJ2b7YOgMffgvzXuqog7R6A/JtrzRf128dsxummyWhg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/type-utils@8.71.0': - resolution: {integrity: sha512-i8uO1qbdxeKgRnS5sCRt6On3/nfo2d2DwQe3Yvjx543zLy7r8ySqRuPPiIIXAhS03U0v5NfAFx+rUgxFzKKwNw==} + '@typescript-eslint/type-utils@8.71.1': + resolution: {integrity: sha512-kH3t3jZCYv2PZSBEWPKTariLrCaoF1CLoPitYFBJOhAGEwWnSRAtGEifrfrpBa4K9JlwWNZotgA5w3/wLSk/FQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/types@8.71.0': - resolution: {integrity: sha512-cJ4OoxPGWvFnBTnSZyaU+qJzGTqPTGJY+gDchj6cRyLRdmIdt4rcsE4twj+zPfrNiWuVi38wijHzShL++Z9atQ==} + '@typescript-eslint/types@8.71.1': + resolution: {integrity: sha512-ybYnPTUwg3VQQOFEiBPLs8Jxl1ry2vXdn1kVKguomBbvxpetdu3MKTNDqFNUrlG8ylP9MpZosmXMFYlGQHk1oQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.71.0': - resolution: {integrity: sha512-PEEF4G5sLLWAS5BpPrUvms4ySZkiBQQZM4z+3ReI46axK5Vqr/vXBQatJQIZZOYdGyPUAKTtsrWzpqKuU+3DEw==} + '@typescript-eslint/typescript-estree@8.71.1': + resolution: {integrity: sha512-pSKSEK1JJpHs6KiKVtABCE4iJK5d7FLFzDXQNBQ2oRY7LEd3UwOt3TZemTgu9syTIf56dJWlTn/pdmrO9oKUXQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/utils@8.71.0': - resolution: {integrity: sha512-pKR/tEMVrXZG23UFKUn5BQf3zfmfk7KQceI2cGzywZ5nxM5Eu3hEJU1utjWzydtzBbcJAQhHN8iPCxobHpPcZQ==} + '@typescript-eslint/utils@8.71.1': + resolution: {integrity: sha512-CBjT6gfAz3DW2j9Q4moFnqt208Aq+506sVGXfpRcyAeqn0EpayL0mjdWoCO2t3RC7GewNrFxdeehuEaPg6DMzQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/visitor-keys@8.71.0': - resolution: {integrity: sha512-8eQ9R218XORK+KLosnf4bu/QsUXvUyVwTbArg7/0NMB1Pu87OJKvj4nhFblkYE8gQV73mW1dx1ptlPCkwRGa7A==} + '@typescript-eslint/visitor-keys@8.71.1': + resolution: {integrity: sha512-0GxiUGqMU0qXJt58cBsiG3DP8lUmBaFo2vChtUX+CT7PnR3v/VGLLrzVP4K9r1lNiZXjHYefMBbPunplr09v6w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} acorn-jsx@5.3.2: @@ -934,8 +934,8 @@ packages: peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - acorn@8.18.0: - resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} + acorn@8.19.0: + resolution: {integrity: sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==} engines: {node: '>=0.4.0'} hasBin: true @@ -2304,8 +2304,8 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - nanoid@3.3.19: - resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==} + nanoid@3.3.20: + resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -2523,8 +2523,8 @@ packages: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} - postcss@8.5.28: - resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} + postcss@8.5.29: + resolution: {integrity: sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==} engines: {node: ^10 || ^12 || >=14} powershell-utils@0.2.1: @@ -2993,8 +2993,8 @@ packages: peerDependencies: typescript: '>=3.5.1' - typescript-eslint@8.71.0: - resolution: {integrity: sha512-fBdHYiqQ14RW6mOMXD14Svn82ZsCYAoQSzGRzyEjR59S5A2Krh/l7fGTOQ7iCr8gGy/mHVXtEF7s5fgjEdV0Pw==} + typescript-eslint@8.71.1: + resolution: {integrity: sha512-oiNVPC3/NbV1FpaHU07l8O6ynqG9v4PAiW2WvTrAmZdR0f2HI/gE668ElMLCK7Xbsu5k2fK5C4fXNiq05j7/nQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -3791,14 +3791,14 @@ snapshots: dependencies: '@types/node': 26.6.4 - '@typescript-eslint/eslint-plugin@8.71.0(@typescript-eslint/parser@8.71.0(eslint@10.12.0(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.12.0(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)': + '@typescript-eslint/eslint-plugin@8.71.1(@typescript-eslint/parser@8.71.1(eslint@10.12.0(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.12.0(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.71.0(eslint@10.12.0(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) - '@typescript-eslint/scope-manager': 8.71.0 - '@typescript-eslint/type-utils': 8.71.0(eslint@10.12.0(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) - '@typescript-eslint/utils': 8.71.0(eslint@10.12.0(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) - '@typescript-eslint/visitor-keys': 8.71.0 + '@typescript-eslint/parser': 8.71.1(eslint@10.12.0(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.71.1 + '@typescript-eslint/type-utils': 8.71.1(eslint@10.12.0(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/utils': 8.71.1(eslint@10.12.0(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.71.1 eslint: 10.12.0(supports-color@10.2.2) ignore: 7.0.12 natural-compare: 1.4.0 @@ -3807,41 +3807,41 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.71.0(eslint@10.12.0(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)': + '@typescript-eslint/parser@8.71.1(eslint@10.12.0(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)': dependencies: - '@typescript-eslint/scope-manager': 8.71.0 - '@typescript-eslint/types': 8.71.0 - '@typescript-eslint/typescript-estree': 8.71.0(supports-color@10.2.2)(typescript@6.0.3) - '@typescript-eslint/visitor-keys': 8.71.0 + '@typescript-eslint/scope-manager': 8.71.1 + '@typescript-eslint/types': 8.71.1 + '@typescript-eslint/typescript-estree': 8.71.1(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.71.1 debug: 4.4.3(supports-color@10.2.2) eslint: 10.12.0(supports-color@10.2.2) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.71.0(supports-color@10.2.2)(typescript@6.0.3)': + '@typescript-eslint/project-service@8.71.1(supports-color@10.2.2)(typescript@6.0.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.71.0(typescript@6.0.3) - '@typescript-eslint/types': 8.71.0 + '@typescript-eslint/tsconfig-utils': 8.71.1(typescript@6.0.3) + '@typescript-eslint/types': 8.71.1 debug: 4.4.3(supports-color@10.2.2) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.71.0': + '@typescript-eslint/scope-manager@8.71.1': dependencies: - '@typescript-eslint/types': 8.71.0 - '@typescript-eslint/visitor-keys': 8.71.0 + '@typescript-eslint/types': 8.71.1 + '@typescript-eslint/visitor-keys': 8.71.1 - '@typescript-eslint/tsconfig-utils@8.71.0(typescript@6.0.3)': + '@typescript-eslint/tsconfig-utils@8.71.1(typescript@6.0.3)': dependencies: typescript: 6.0.3 - '@typescript-eslint/type-utils@8.71.0(eslint@10.12.0(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)': + '@typescript-eslint/type-utils@8.71.1(eslint@10.12.0(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)': dependencies: - '@typescript-eslint/types': 8.71.0 - '@typescript-eslint/typescript-estree': 8.71.0(supports-color@10.2.2)(typescript@6.0.3) - '@typescript-eslint/utils': 8.71.0(eslint@10.12.0(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/types': 8.71.1 + '@typescript-eslint/typescript-estree': 8.71.1(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/utils': 8.71.1(eslint@10.12.0(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) debug: 4.4.3(supports-color@10.2.2) eslint: 10.12.0(supports-color@10.2.2) ts-api-utils: 2.5.0(typescript@6.0.3) @@ -3849,14 +3849,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.71.0': {} + '@typescript-eslint/types@8.71.1': {} - '@typescript-eslint/typescript-estree@8.71.0(supports-color@10.2.2)(typescript@6.0.3)': + '@typescript-eslint/typescript-estree@8.71.1(supports-color@10.2.2)(typescript@6.0.3)': dependencies: - '@typescript-eslint/project-service': 8.71.0(supports-color@10.2.2)(typescript@6.0.3) - '@typescript-eslint/tsconfig-utils': 8.71.0(typescript@6.0.3) - '@typescript-eslint/types': 8.71.0 - '@typescript-eslint/visitor-keys': 8.71.0 + '@typescript-eslint/project-service': 8.71.1(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.71.1(typescript@6.0.3) + '@typescript-eslint/types': 8.71.1 + '@typescript-eslint/visitor-keys': 8.71.1 debug: 4.4.3(supports-color@10.2.2) minimatch: 10.2.6 semver: 7.8.5 @@ -3866,27 +3866,27 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.71.0(eslint@10.12.0(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)': + '@typescript-eslint/utils@8.71.1(eslint@10.12.0(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)': dependencies: '@eslint-community/eslint-utils': 4.10.1(eslint@10.12.0(supports-color@10.2.2)) - '@typescript-eslint/scope-manager': 8.71.0 - '@typescript-eslint/types': 8.71.0 - '@typescript-eslint/typescript-estree': 8.71.0(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.71.1 + '@typescript-eslint/types': 8.71.1 + '@typescript-eslint/typescript-estree': 8.71.1(supports-color@10.2.2)(typescript@6.0.3) eslint: 10.12.0(supports-color@10.2.2) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.71.0': + '@typescript-eslint/visitor-keys@8.71.1': dependencies: - '@typescript-eslint/types': 8.71.0 + '@typescript-eslint/types': 8.71.1 eslint-visitor-keys: 5.0.1 - acorn-jsx@5.3.2(acorn@8.18.0): + acorn-jsx@5.3.2(acorn@8.19.0): dependencies: - acorn: 8.18.0 + acorn: 8.19.0 - acorn@8.18.0: {} + acorn@8.19.0: {} ajv@6.15.0: dependencies: @@ -4536,8 +4536,8 @@ snapshots: espree@11.2.0: dependencies: - acorn: 8.18.0 - acorn-jsx: 5.3.2(acorn@8.18.0) + acorn: 8.19.0 + acorn-jsx: 5.3.2(acorn@8.19.0) eslint-visitor-keys: 5.0.1 esquery@1.7.0: @@ -5296,7 +5296,7 @@ snapshots: ms@2.1.3: {} - nanoid@3.3.19: {} + nanoid@3.3.20: {} natural-compare@1.4.0: {} @@ -5493,9 +5493,9 @@ snapshots: possible-typed-array-names@1.1.0: {} - postcss@8.5.28: + postcss@8.5.29: dependencies: - nanoid: 3.3.19 + nanoid: 3.3.20 picocolors: 1.1.1 source-map-js: 1.2.2 @@ -5874,7 +5874,7 @@ snapshots: terser@5.51.2: dependencies: '@jridgewell/source-map': 0.3.11 - acorn: 8.18.0 + acorn: 8.19.0 commander: 2.20.3 source-map-support: 0.5.21 @@ -5931,12 +5931,12 @@ snapshots: dependencies: typescript: 6.0.3 - typescript-eslint@8.71.0(eslint@10.12.0(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3): + typescript-eslint@8.71.1(eslint@10.12.0(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.71.0(@typescript-eslint/parser@8.71.0(eslint@10.12.0(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.12.0(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) - '@typescript-eslint/parser': 8.71.0(eslint@10.12.0(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) - '@typescript-eslint/typescript-estree': 8.71.0(supports-color@10.2.2)(typescript@6.0.3) - '@typescript-eslint/utils': 8.71.0(eslint@10.12.0(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/eslint-plugin': 8.71.1(@typescript-eslint/parser@8.71.1(eslint@10.12.0(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.12.0(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/parser': 8.71.1(eslint@10.12.0(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.71.1(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/utils': 8.71.1(eslint@10.12.0(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) eslint: 10.12.0(supports-color@10.2.2) typescript: 6.0.3 transitivePeerDependencies: @@ -6025,7 +6025,7 @@ snapshots: dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 - postcss: 8.5.28 + postcss: 8.5.29 rolldown: 1.2.12 tinyglobby: 0.2.17 optionalDependencies: diff --git a/interface/progmem-generator.js b/interface/progmem-generator.js index 211721a2c..74cbae222 100644 --- a/interface/progmem-generator.js +++ b/interface/progmem-generator.js @@ -10,10 +10,17 @@ import mime from 'mime-types'; import { relative, resolve, sep } from 'path'; import zlib from 'zlib'; +// optional args: --src --out --index +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 \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, diff --git a/interface/recovery.html b/interface/recovery.html new file mode 100644 index 000000000..59ffcf406 --- /dev/null +++ b/interface/recovery.html @@ -0,0 +1,13 @@ + + + + + + + EMS-ESP Recovery + + +
+ + + diff --git a/interface/src/app/settings/Version.tsx b/interface/src/app/settings/Version.tsx index e45a1c46c..a7d68e82b 100644 --- a/interface/src/app/settings/Version.tsx +++ b/interface/src/app/settings/Version.tsx @@ -71,6 +71,7 @@ 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; } @@ -721,6 +722,19 @@ const Version = () => { + + {data.recovery_version !== undefined && ( + <> + + {LL.RECOVERY()} + + + + {data.recovery_version || LL.RECOVERY_NOT_INSTALLED()} + + + + )} {internetLive ? ( @@ -848,7 +862,11 @@ const Version = () => { {LL.UPLOAD()} - + void loadData()} + /> )} diff --git a/interface/src/components/upload/SingleUpload.tsx b/interface/src/components/upload/SingleUpload.tsx index 781994e28..30a521256 100644 --- a/interface/src/components/upload/SingleUpload.tsx +++ b/interface/src/components/upload/SingleUpload.tsx @@ -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(); const [file, setFile] = useState(); 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()); diff --git a/interface/src/i18n/cs/index.ts b/interface/src/i18n/cs/index.ts index ec1c474c5..76284dbee 100644 --- a/interface/src/i18n/cs/index.ts +++ b/interface/src/i18n/cs/index.ts @@ -352,7 +352,10 @@ 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: 'Obnova', + RECOVERY_NOT_INSTALLED: 'Nenainstalováno', + RECOVERY_INSTALLED: 'Firmware pro obnovu nainstalován' }; export default cs; diff --git a/interface/src/i18n/de/index.ts b/interface/src/i18n/de/index.ts index 9254364cc..352de1152 100644 --- a/interface/src/i18n/de/index.ts +++ b/interface/src/i18n/de/index.ts @@ -352,7 +352,10 @@ 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: 'Wiederherstellung', + RECOVERY_NOT_INSTALLED: 'Nicht installiert', + RECOVERY_INSTALLED: 'Wiederherstellungs-Firmware installiert' }; export default de; diff --git a/interface/src/i18n/en/index.ts b/interface/src/i18n/en/index.ts index 390ced0f2..49c3878b1 100644 --- a/interface/src/i18n/en/index.ts +++ b/interface/src/i18n/en/index.ts @@ -352,7 +352,10 @@ 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: 'Recovery', + RECOVERY_NOT_INSTALLED: 'Not installed', + RECOVERY_INSTALLED: 'Recovery firmware installed' }; export default en; diff --git a/interface/src/i18n/fr/index.ts b/interface/src/i18n/fr/index.ts index e3df37e48..303f23510 100644 --- a/interface/src/i18n/fr/index.ts +++ b/interface/src/i18n/fr/index.ts @@ -352,7 +352,10 @@ 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: 'Récupération', + RECOVERY_NOT_INSTALLED: 'Non installé', + RECOVERY_INSTALLED: 'Firmware de récupération installé' }; export default fr; diff --git a/interface/src/i18n/it/index.ts b/interface/src/i18n/it/index.ts index 75ed58542..39654977c 100644 --- a/interface/src/i18n/it/index.ts +++ b/interface/src/i18n/it/index.ts @@ -352,7 +352,10 @@ 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: 'Ripristino', + RECOVERY_NOT_INSTALLED: 'Non installato', + RECOVERY_INSTALLED: 'Firmware di ripristino installato' }; export default it; diff --git a/interface/src/i18n/nl/index.ts b/interface/src/i18n/nl/index.ts index 2e9e39d37..15e764226 100644 --- a/interface/src/i18n/nl/index.ts +++ b/interface/src/i18n/nl/index.ts @@ -352,7 +352,10 @@ 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: 'Herstel', + RECOVERY_NOT_INSTALLED: 'Niet geïnstalleerd', + RECOVERY_INSTALLED: 'Herstelfirmware geïnstalleerd' }; export default nl; diff --git a/interface/src/i18n/no/index.ts b/interface/src/i18n/no/index.ts index e0824ac2e..788ee326b 100644 --- a/interface/src/i18n/no/index.ts +++ b/interface/src/i18n/no/index.ts @@ -352,7 +352,10 @@ 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: 'Gjenoppretting', + RECOVERY_NOT_INSTALLED: 'Ikke installert', + RECOVERY_INSTALLED: 'Gjenopprettingsfirmware installert' }; export default no; diff --git a/interface/src/i18n/pl/index.ts b/interface/src/i18n/pl/index.ts index 89ef48b5e..3f6c47e62 100644 --- a/interface/src/i18n/pl/index.ts +++ b/interface/src/i18n/pl/index.ts @@ -352,7 +352,10 @@ 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: 'Odzyskiwanie', + RECOVERY_NOT_INSTALLED: 'Nie zainstalowano', + RECOVERY_INSTALLED: 'Zainstalowano firmware odzyskiwania' }; export default pl; diff --git a/interface/src/i18n/sk/index.ts b/interface/src/i18n/sk/index.ts index 1d2940aef..f8a8bcf9e 100644 --- a/interface/src/i18n/sk/index.ts +++ b/interface/src/i18n/sk/index.ts @@ -352,7 +352,10 @@ const sk: Translation = { 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: 'Obnova', + RECOVERY_NOT_INSTALLED: 'Nenainštalované', + RECOVERY_INSTALLED: 'Firmvér obnovy nainštalovaný' }; export default sk; diff --git a/interface/src/i18n/sv/index.ts b/interface/src/i18n/sv/index.ts index 384351dab..049c73eda 100644 --- a/interface/src/i18n/sv/index.ts +++ b/interface/src/i18n/sv/index.ts @@ -352,7 +352,10 @@ 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: 'Återställning', + RECOVERY_NOT_INSTALLED: 'Inte installerad', + RECOVERY_INSTALLED: 'Återställningsfirmware installerad' }; export default sv; diff --git a/interface/src/i18n/tr/index.ts b/interface/src/i18n/tr/index.ts index 8449843a7..d95885513 100644 --- a/interface/src/i18n/tr/index.ts +++ b/interface/src/i18n/tr/index.ts @@ -352,7 +352,10 @@ 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: 'Kurtarma', + RECOVERY_NOT_INSTALLED: 'Yüklü değil', + RECOVERY_INSTALLED: 'Kurtarma firmware yüklendi' }; export default tr; diff --git a/interface/src/recovery/Dashboard.tsx b/interface/src/recovery/Dashboard.tsx new file mode 100644 index 000000000..3ae9ba806 --- /dev/null +++ b/interface/src/recovery/Dashboard.tsx @@ -0,0 +1,380 @@ +import { type ReactNode, useState } from 'react'; + +import CancelIcon from '@mui/icons-material/Cancel'; +import HealthAndSafetyIcon from '@mui/icons-material/HealthAndSafety'; +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 { + Avatar, + Box, + Button, + Chip, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Divider, + List, + ListItem, + ListItemAvatar, + ListItemText, + MenuItem, + 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, restart, setBootPartition } from './api'; +import type { RecoveryPartition, RecoveryStatus } from './types'; + +const formatMB = (bytes: number) => `${(bytes / 1048576).toFixed(2)} MB`; + +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, + 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 }) => ( + + {children} + +); + +const InfoRow = ({ label, value }: { label: string; value: ReactNode }) => ( + + + {label} + + {value} + +); + +interface DashboardProps { + status: RecoveryStatus; + onRestarting: (target: string) => void; + onSignOut: () => void; +} + +const Dashboard = ({ status, onRestarting, onSignOut }: DashboardProps) => { + const [confirmBoot, setConfirmBoot] = useState(); + const [pickedTarget, setPickedTarget] = useState(); + 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 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 ( + <> + + {reason && } + + {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.'} + + + + + Firmware + + {status.partitions.map((p, index) => ( + + {index > 0 && } + } + variant="outlined" + color="primary" + size="small" + onClick={() => setConfirmBoot(p)} + > + Start + + ) + } + > + + + {p.factory ? : } + + + + {p.factory ? `Recovery (${p.label})` : p.label} + {p.label === status.running && ( + + )} + {p.label === status.boot && p.label !== status.running && ( + + )} + {p.label === uploadTarget && ( + + )} + {!p.valid && ( + + )} + {p.valid && failedToStart(p) && ( + + )} + {p.valid && p.ota_state === 'pending' && ( + + )} + + } + secondary={ + p.valid + ? `${p.version ? 'v' + p.version + ' · ' : ''}${formatMB(p.image_size)} of ${formatMB(p.size)}` + : formatMB(p.size) + } + /> + + + ))} + + + + + Upload Firmware + + The firmware is started automatically after the upload. To enable the MD5 + check, upload the matching .md5 file first. + + setPickedTarget(e.target.value)} + > + {otaPartitions.map((p) => ( + + {p.label} + {p.valid + ? ` (replaces ${p.version ? 'v' + p.version : 'installed firmware'})` + : ' (empty)'} + + ))} + + void doRestart(uploadTarget)} + onUnauthorized={onSignOut} + /> + + + + System + + + + + + + {status.eth_state && ( + + )} + + + + + + + + + + setConfirmBoot(undefined)} + > + Start Firmware + + Restart and run + {confirmBoot?.version ? ` EMS-ESP v${confirmBoot.version}` : ''} from{' '} + {confirmBoot?.label}? + {confirmBoot && failedToStart(confirmBoot) && ( + + )} + + + + + + + + ); +}; + +export default Dashboard; diff --git a/interface/src/recovery/FirmwareUpload.tsx b/interface/src/recovery/FirmwareUpload.tsx new file mode 100644 index 000000000..b4d3822a6 --- /dev/null +++ b/interface/src/recovery/FirmwareUpload.tsx @@ -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(); + const [dragged, setDragged] = useState(false); + const [md5, setMd5] = useState(); + const [progress, setProgress] = useState(); + const uploadRef = useRef(null); + const inputRef = useRef(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) => { + selectFile(e.target.files?.[0]); + e.target.value = ''; // allow the same file to be selected again + }; + + const onDrop = (e: DragEvent) => { + 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 ( + <> + + Uploading {file?.name} to {target}… + + + + + ); + } + + return ( + <> + { + e.preventDefault(); + setDragged(true); + }} + onDragLeave={() => setDragged(false)} + onClick={() => inputRef.current?.click()} + > + + + + {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'} + + + + {file && ( + <> + + {file.name} + + + + + + + )} + + {md5 && ( + + {'MD5: ' + md5} + + )} + + ); +}; + +export default FirmwareUpload; diff --git a/interface/src/recovery/RecoveryApp.tsx b/interface/src/recovery/RecoveryApp.tsx new file mode 100644 index 000000000..22fb89372 --- /dev/null +++ b/interface/src/recovery/RecoveryApp.tsx @@ -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(); + const [error, setError] = useState(); + const [restartTarget, setRestartTarget] = useState(); + + 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 = ( + + ); + } else if (!status) { + content = ( + + {error ? ( + + + + ) : ( + + + + )} + + ); + } else if (!status.authenticated) { + content = void loadStatus()} />; + } else { + content = ( + <> + + + EMS-ESP + EMS-ESP + +   |   + + Recovery + + + + + + + + ); + } + + return ( + + {content} + + + ); +}; + +export default RecoveryApp; diff --git a/interface/src/recovery/RestartMonitor.tsx b/interface/src/recovery/RestartMonitor.tsx new file mode 100644 index 000000000..9e6e64088 --- /dev/null +++ b/interface/src/recovery/RestartMonitor.tsx @@ -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; + 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 ( + + + EMS-ESP + + Restarting{target ? ` from ${target}` : ''} + + + {gaveUp ? ( + <> + + 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`} + + + + ) : ( + <> + + Please wait… + + + + + {viaAccessPoint && ( + + 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 + + )} + + )} + + + ); +}; + +export default RestartMonitor; diff --git a/interface/src/recovery/SignIn.tsx b/interface/src/recovery/SignIn.tsx new file mode 100644 index 000000000..3fbd10285 --- /dev/null +++ b/interface/src/recovery/SignIn.tsx @@ -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 ( + ({ + display: 'flex', + height: '100vh', + margin: 'auto', + padding: 2, + justifyContent: 'center', + flexDirection: 'column', + maxWidth: theme.breakpoints.values.sm + })} + > + ({ + textAlign: 'center', + padding: theme.spacing(2), + paddingTop: '172px', + backgroundImage: 'url("/app/icon.png")', + backgroundRepeat: 'no-repeat', + backgroundPosition: '50% ' + theme.spacing(2), + width: '100%' + })} + > + EMS-ESP + + Recovery + + + setUsername(e.target.value)} + slotProps={{ input: { autoCapitalize: 'none', autoCorrect: 'off' } }} + /> + setPassword(e.target.value)} + onKeyDown={onKeyDown} + slotProps={{ + input: { + endAdornment: ( + + setShowPassword(!showPassword)} + edge="end" + > + {showPassword ? : } + + + ) + } + }} + /> + + + + + + ); +}; + +export default SignIn; diff --git a/interface/src/recovery/api.ts b/interface/src/recovery/api.ts new file mode 100644 index 000000000..4ce7a3dd6 --- /dev/null +++ b/interface/src/recovery/api.ts @@ -0,0 +1,107 @@ +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 => { + const token = getToken(); + return token ? { Authorization: `Bearer ${token}` } : {}; +}; + +const request = async ( + method: string, + url: string, + body?: unknown +): Promise => { + 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('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('POST', '/rest/recovery/boot', { partition }); + +export const restart = () => request('POST', '/rest/recovery/restart'); + +export interface UploadHandle { + promise: Promise; + 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((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() }; +}; diff --git a/interface/src/recovery/index.tsx b/interface/src/recovery/index.tsx new file mode 100644 index 000000000..6f0b5afd4 --- /dev/null +++ b/interface/src/recovery/index.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; + +import RecoveryApp from './RecoveryApp'; + +createRoot(document.getElementById('root')!).render( + + + +); diff --git a/interface/src/recovery/types.ts b/interface/src/recovery/types.ts new file mode 100644 index 000000000..fa1ac57df --- /dev/null +++ b/interface/src/recovery/types.ts @@ -0,0 +1,54 @@ +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; + // 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; +} diff --git a/interface/src/types/system.ts b/interface/src/types/system.ts index 32538bc5b..94e8d62f2 100644 --- a/interface/src/types/system.ts +++ b/interface/src/types/system.ts @@ -52,6 +52,7 @@ export interface SystemStatus { model: string; board: string; has_loader: boolean; + recovery_version?: string; has_partition: boolean; partitions: { partition: string; diff --git a/interface/vite.config.ts b/interface/vite.config.ts index c87ab4e41..d788d153a 100644 --- a/interface/vite.config.ts +++ b/interface/vite.config.ts @@ -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( diff --git a/lib/OtaUpdater/OtaUpdater.cpp b/lib/OtaUpdater/OtaUpdater.cpp new file mode 100644 index 000000000..c94f07084 --- /dev/null +++ b/lib/OtaUpdater/OtaUpdater.cpp @@ -0,0 +1,236 @@ +#include "OtaUpdater.h" + +#include +#include +#include + +#include + +static String getFilenameExtension(const String & filename) { + const auto pos = filename.lastIndexOf('.'); + if (pos != -1) { + return filename.substring(static_cast(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" && filesize >= min_firmware_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 & 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(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(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; +} + +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; + } + const esp_err_t err = esp_ota_begin(target, OTA_WITH_SEQUENTIAL_WRITES, &_handle); + if (err != ESP_OK) { + _error = esp_err_to_name(err); + return false; + } + _target = target; + _written = 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) { + const esp_err_t err = esp_ota_write(_handle, data, len); + if (err != ESP_OK) { + _error = esp_err_to_name(err); + return false; + } + if (_md5_applied) { + _md5Builder.add(data, len); + } + _written += len; + return true; + } +#endif + return Update.write(const_cast(data), len) == len; +} + +bool OtaUpdater::end() { +#ifdef OTA_UPDATER_ANY_PARTITION + if (_target != nullptr) { + const esp_partition_t * target = _target; + _target = nullptr; + + if (_md5_applied) { + _md5Builder.calculate(); + if (strcmp(_md5Builder.toString().c_str(), _md5.data()) != 0) { + esp_ota_abort(_handle); + _error = "MD5 check failed"; + return false; + } + } + + // esp_ota_end() validates the image + esp_err_t err = esp_ota_end(_handle); + if (err == ESP_OK && _set_boot) { + err = esp_ota_set_boot_partition(target); + } + if (err != ESP_OK) { + _error = esp_err_to_name(err); + return false; + } + return true; + } +#endif + return Update.end(true); +} + +void OtaUpdater::abort() { +#ifdef OTA_UPDATER_ANY_PARTITION + if (_target != nullptr) { + esp_ota_abort(_handle); + _target = nullptr; + return; + } +#endif + 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(); +} diff --git a/lib/OtaUpdater/OtaUpdater.h b/lib/OtaUpdater/OtaUpdater.h new file mode 100644 index 000000000..e35c512c2 --- /dev/null +++ b/lib/OtaUpdater/OtaUpdater.h @@ -0,0 +1,76 @@ +#ifndef OtaUpdater_h +#define OtaUpdater_h + +#include +#include + +#include + +#ifdef OTA_UPDATER_ANY_PARTITION +#include +#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 }; + + 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"; + + // 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 & 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 _md5{}; + bool _md5_applied = false; + const char * _error = ""; + +#ifdef OTA_UPDATER_ANY_PARTITION + // set when writing to an explicit target partition with the IDF OTA API instead of Update + const esp_partition_t * _target = nullptr; + esp_ota_handle_t _handle = 0; + size_t _written = 0; + bool _set_boot = true; + MD5Builder _md5Builder; +#endif +}; + +#endif diff --git a/mock-api/package.json b/mock-api/package.json index 64a729272..799c6f2d1 100644 --- a/mock-api/package.json +++ b/mock-api/package.json @@ -17,5 +17,5 @@ "@trivago/prettier-plugin-sort-imports": "^6.0.2", "prettier": "^3.9.9" }, - "packageManager": "pnpm@12.8.1" + "packageManager": "pnpm@12.9.1" } diff --git a/mock-api/pnpm-lock.yaml b/mock-api/pnpm-lock.yaml index a0c58b979..b431bd0a6 100644 --- a/mock-api/pnpm-lock.yaml +++ b/mock-api/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.8.1 - version: 12.8.1 + specifier: 12.9.1 + version: 12.9.1 packages: - '@pnpm/exe.android-arm64@12.8.1': - resolution: {integrity: sha512-siDxcidfz5eM6L2tJfik+i2bbUEw8UC7MZ3Yl2HYdRmqbdhHGAOQACtOOu6QVW4Jul4/w5wVKm+wM8ACtgh1Pg==} + '@pnpm/exe.android-arm64@12.9.1': + resolution: {integrity: sha512-Hgm1XdqMTfBC8PdGLL+NjZWv4xyi7D6XNrN9fb24XGYlyTBmUtdCefKnCLmHujShRD8BEO1zqM30SNcKBDLysw==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.8.1': - resolution: {integrity: sha512-0F/uqPRc3aN4vLHsa0f+6fTtmsigFQ9/yxEU43fionF//pNHEd5e97b3w/nKIgqYMCUQCJphmBRMA9Q8/wlo9Q==} + '@pnpm/exe.android-x64@12.9.1': + resolution: {integrity: sha512-WDGCe6jZRVZP3RC/q6hOkZylkPkIQIi1/GcrR/d/YzbihhAHXCczrk+UYnV5TgbsEnAYO7Cx+2rX3nfhHvy24w==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.8.1': - resolution: {integrity: sha512-/rwavvMQJsl2RIxOHBnDF+4Gk+fhQFAY4M3PQZoKskJOEnaJcygG9p1xby6wcQcbS4d9dIubv09CQhufTgfmpw==} + '@pnpm/exe.darwin-arm64@12.9.1': + resolution: {integrity: sha512-+7iiEEBgYN9szPWUnAuN6eOVMTI6Hrki5bcuC9n9xl9tTnT53Tpxpo45Hml+W4IrAU0d+Tmd0sPnVIbX59PD6w==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.8.1': - resolution: {integrity: sha512-htYt2gN7zqJKLhC99Ft6EUiO7myjZAZXfmGCTJUL1v+o7vE9hZdE1+fAkk4Oj1ZHNRti6b2woOxEkRkAusi8OQ==} + '@pnpm/exe.darwin-x64@12.9.1': + resolution: {integrity: sha512-SK7TxJiR12GbJZGffRFHItkc+Mx+99v6xMMJ9/X//8NwWV1wo17a2bwfGI8qZpl17YwsrmPEFOOqhzE1GIlrtQ==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.8.1': - resolution: {integrity: sha512-PB5BULNgytxvExoOXUUeseS/DcpPENs+cg3iA6xHHeaLT4ctEGKSGXEbuj7USeqvo8JBpAx039hivnbzGqDwnA==} + '@pnpm/exe.freebsd-x64@12.9.1': + resolution: {integrity: sha512-4AjLk6uvTs2iO6wMit2hcxJ2M32xb7OV5SNFk+w6G8/NzcjcMB7ENEpyeXyLiP0kcnp8uaCGUct2NfrLCJ35eQ==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.8.1': - resolution: {integrity: sha512-kKeyC/ArjrgdciQRBrOL0j9HtOI4gDRgoyP+eJ1CQKyadpKlf/DhDDyspXe07R3B9KagTVLUF2nlt6Dr5quFIg==} + '@pnpm/exe.linux-arm64-musl@12.9.1': + resolution: {integrity: sha512-dZL2ES1ckDWM9v+EtxoOklZ0WNdtayooi9iLiDUubopudX3zXPNR2mlrxBRgjbDgECOndQen2SmyxL5eZiBuKw==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.8.1': - resolution: {integrity: sha512-q4s9a9X2O3N9aeUFooW24orNrxvu20+jyVUFR5RanPCqOKKPBrgs2iywMkBBx1aXkkzdWT54/mfz8Be8sJlWEw==} + '@pnpm/exe.linux-arm64@12.9.1': + resolution: {integrity: sha512-cx+uF6xqwEtm7B8NV+1ve0xBg9gcuzQZ5R+0WgOWvKG/h978UljS6BgWs1ipIGuGgUdeVhm3PKmBltXIcczLXA==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.8.1': - resolution: {integrity: sha512-F7XSjKJthwCh/L6abZiMpAfo2Cm61SZIT3e4dfgiIjESndlWaXCnfLKngSgZ/SEMsdmHT6dud3+kWIB3tw0/xQ==} + '@pnpm/exe.linux-ppc64@12.9.1': + resolution: {integrity: sha512-MpjPsy4XR17zAtLziQZfsN+wveWa/bUJggZtwfo3+SuyRmXnSvVkFD4+v8/3RTWyYaM6mwLUTjZDJ+g3urL+ew==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.8.1': - resolution: {integrity: sha512-z1ecpIK/ZY08Unk69KWWE9867oaKUytGUjrHnBoGFAAhsK+q+0cfvBH+f5fWobJ7FX97leMlLO+gkJolddM45Q==} + '@pnpm/exe.linux-riscv64@12.9.1': + resolution: {integrity: sha512-Oo67TKqqH+Bno+L6X7Xyorwp1QG82zKIkL0vbo1x2kcPTDiyPbdM+d6lIs4+hzeB1KHT5FDoGzZj2dad7Ft+eg==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.8.1': - resolution: {integrity: sha512-KU6tnrBfu4uKXTq6N5qJwSm8FSrs9Wc8b5AUEsHK9bRXwNdsZ1/lRxG1++X29SKRW9OSNuDSjxASv5mAvGo1gA==} + '@pnpm/exe.linux-s390x@12.9.1': + resolution: {integrity: sha512-QAD5FRCdW36HfD58BV7DoDm6z8yv+GgSqcUV1+ERwYjpVQuG6ZTpf5o0x3KDas8eTLIW7MLYVdDdlAurWeArEg==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.8.1': - resolution: {integrity: sha512-eUE8BWJkDr2tJb/yrk0yVnszlmNqFarVxUlqiZ9BAkct28Nq/5g2H0tvMDmPA7fAQW3STNj+omJPPRVT+4s5sQ==} + '@pnpm/exe.linux-x64-musl@12.9.1': + resolution: {integrity: sha512-mQDhBsGqcTkrLQM4q5igmRFcr6EAl4UM/FGawY+0CWGcqPpUmYUvDy9LHy80xxkHIWVJ886gvvso2XOjfav1Jw==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.8.1': - resolution: {integrity: sha512-8bDZ0lZlCdi2rvnFul7EYgcC7zKeWSe76y8JV2oXobaS8p9i9d6PSf6LgLtTM0fI7R9Oh4eLCbveXyNDMmvZ+g==} + '@pnpm/exe.linux-x64@12.9.1': + resolution: {integrity: sha512-+M5N74hM4fc87AAckxx/UIW+dUnWf2eko+HchJs/NZUxehLhekNyMI/GNaNXlvd+kWR+97XXBD51XRkkNiUtrA==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.8.1': - resolution: {integrity: sha512-MsT0dlrRxnCRCHCaosNbhWpEiAFnFuMEu3KuDJDU41BXgTwiINxU8goloUSUDejWZUqKaUqNC/qUTihl9nFtqg==} + '@pnpm/exe.win32-arm64@12.9.1': + resolution: {integrity: sha512-mI28C2wxobrDi6x1OYhBRyG10hnDq/4sEIyy5RzoX6LB4kLuv6K/d25HOpMwlW20cuM4M72k9NZpOdcmUxMbEA==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.8.1': - resolution: {integrity: sha512-SWtPe0PsbTTk//gJND10tMdOfCYjrkq5+qV49hzVhY7xz2iJ339Rc+xwASlcvbwTfrvH39YNqpKYibD+CRGLwA==} + '@pnpm/exe.win32-x64@12.9.1': + resolution: {integrity: sha512-SWoDMT0oD7NrDSIoPpkKD09D9fAQKNjwFZ3lezlCUhAfXxMv70zsfrNT2/P+GoCK2kefXF9OysUZkH1l5vvvfA==} cpu: [x64] os: [win32] - pnpm@12.8.1: - resolution: {integrity: sha512-9kupB1B/XOr+BsjTjmBS0BeURFgOwSed3Vv8EctIqoomRLZlmOB+dh2oSHKp/FfV+QK4f6SdAkGY1VhhKqu+RQ==} + pnpm@12.9.1: + resolution: {integrity: sha512-BrBV//XNINwSeB3hc87TMQzglRvy7GICEaFgRdVETVyTpmMhB2TvKaZTphBFm6A2jw50+E2AxUcjz5Wq57wNbQ==} engines: {node: '>=18.*'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.8.1': + '@pnpm/exe.android-arm64@12.9.1': optional: true - '@pnpm/exe.android-x64@12.8.1': + '@pnpm/exe.android-x64@12.9.1': optional: true - '@pnpm/exe.darwin-arm64@12.8.1': + '@pnpm/exe.darwin-arm64@12.9.1': optional: true - '@pnpm/exe.darwin-x64@12.8.1': + '@pnpm/exe.darwin-x64@12.9.1': optional: true - '@pnpm/exe.freebsd-x64@12.8.1': + '@pnpm/exe.freebsd-x64@12.9.1': optional: true - '@pnpm/exe.linux-arm64-musl@12.8.1': + '@pnpm/exe.linux-arm64-musl@12.9.1': optional: true - '@pnpm/exe.linux-arm64@12.8.1': + '@pnpm/exe.linux-arm64@12.9.1': optional: true - '@pnpm/exe.linux-ppc64@12.8.1': + '@pnpm/exe.linux-ppc64@12.9.1': optional: true - '@pnpm/exe.linux-riscv64@12.8.1': + '@pnpm/exe.linux-riscv64@12.9.1': optional: true - '@pnpm/exe.linux-s390x@12.8.1': + '@pnpm/exe.linux-s390x@12.9.1': optional: true - '@pnpm/exe.linux-x64-musl@12.8.1': + '@pnpm/exe.linux-x64-musl@12.9.1': optional: true - '@pnpm/exe.linux-x64@12.8.1': + '@pnpm/exe.linux-x64@12.9.1': optional: true - '@pnpm/exe.win32-arm64@12.8.1': + '@pnpm/exe.win32-arm64@12.9.1': optional: true - '@pnpm/exe.win32-x64@12.8.1': + '@pnpm/exe.win32-x64@12.9.1': optional: true - pnpm@12.8.1: + pnpm@12.9.1: optionalDependencies: - '@pnpm/exe.android-arm64': 12.8.1 - '@pnpm/exe.android-x64': 12.8.1 - '@pnpm/exe.darwin-arm64': 12.8.1 - '@pnpm/exe.darwin-x64': 12.8.1 - '@pnpm/exe.freebsd-x64': 12.8.1 - '@pnpm/exe.linux-arm64': 12.8.1 - '@pnpm/exe.linux-arm64-musl': 12.8.1 - '@pnpm/exe.linux-ppc64': 12.8.1 - '@pnpm/exe.linux-riscv64': 12.8.1 - '@pnpm/exe.linux-s390x': 12.8.1 - '@pnpm/exe.linux-x64': 12.8.1 - '@pnpm/exe.linux-x64-musl': 12.8.1 - '@pnpm/exe.win32-arm64': 12.8.1 - '@pnpm/exe.win32-x64': 12.8.1 + '@pnpm/exe.android-arm64': 12.9.1 + '@pnpm/exe.android-x64': 12.9.1 + '@pnpm/exe.darwin-arm64': 12.9.1 + '@pnpm/exe.darwin-x64': 12.9.1 + '@pnpm/exe.freebsd-x64': 12.9.1 + '@pnpm/exe.linux-arm64': 12.9.1 + '@pnpm/exe.linux-arm64-musl': 12.9.1 + '@pnpm/exe.linux-ppc64': 12.9.1 + '@pnpm/exe.linux-riscv64': 12.9.1 + '@pnpm/exe.linux-s390x': 12.9.1 + '@pnpm/exe.linux-x64': 12.9.1 + '@pnpm/exe.linux-x64-musl': 12.9.1 + '@pnpm/exe.win32-arm64': 12.9.1 + '@pnpm/exe.win32-x64': 12.9.1 --- lockfileVersion: '9.0' diff --git a/mock-api/recoveryMock.js b/mock-api/recoveryMock.js new file mode 100644 index 000000000..a6aeb82b7 --- /dev/null +++ b/mock-api/recoveryMock.js @@ -0,0 +1,214 @@ +// 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, + 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/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); + }); + } + }; +} diff --git a/mock-api/restServer.ts b/mock-api/restServer.ts index c606b951b..ea1fd57c0 100644 --- a/mock-api/restServer.ts +++ b/mock-api/restServer.ts @@ -117,6 +117,7 @@ let system_status = { psram_size: 8189, free_psram: 8166, has_loader: true, + recovery_version: '', // upload an EMS-ESP-Recovery-*.bin to set it has_partition: true, partitions: [ { @@ -4691,6 +4692,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'); diff --git a/platformio.ini b/platformio.ini index 9fa302349..8a428a492 100644 --- a/platformio.ini +++ b/platformio.ini @@ -73,6 +73,10 @@ 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 ; platform = https://github.com/tasmota/platform-espressif32/releases/download/2026.09.50/platform-espressif32.zip ; Tasmota Arduino Core 3.3.12 based on IDF 5.5.5 +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] @@ -163,12 +167,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_s3_16M_P] +extends = env:s3_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] @@ -199,6 +260,7 @@ build_src_filter = -<../lib/uuid-syslog> -<../lib/eModbus> -<../lib/OneWire> + -<../lib/OtaUpdater> -<../src/core/ModuleLibrary.cpp> lib_ldf_mode = off lib_deps = @@ -242,6 +304,7 @@ build_src_filter = -<../lib/uuid-syslog> -<../lib/eModbus> -<../lib/OneWire> + -<../lib/OtaUpdater> -<../src/core/ModuleLibrary.cpp> lib_ldf_mode = off lib_deps = Unity diff --git a/project-words.txt b/project-words.txt index 15da3c6be..cdc228250 100644 --- a/project-words.txt +++ b/project-words.txt @@ -1351,4 +1351,8 @@ zyxwvutsrqponmlkjihgfedcba öffnen česky živanović -neopixel \ No newline at end of file +neopixel +firmwares +recognises +POWERON +crashtest diff --git a/recovery/main.cpp b/recovery/main.cpp new file mode 100644 index 000000000..5e3870927 --- /dev/null +++ b/recovery/main.cpp @@ -0,0 +1,840 @@ +// 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. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if CONFIG_IDF_TARGET_ESP32 +#include +#endif + +#include +#include +#include +#include + +#include + +#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; +static constexpr uint32_t RESTART_DELAY_MS = 500; +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; + std::vector 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 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; + +// ---------------------------------------------------------------------------- +// 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; + } + + doc.clear(); + if (mounted && readJsonFile("/config/securitySettings.json", doc)) { + for (JsonObject user : doc["users"].as()) { + if (user["admin"] | false) { + settings.admins.push_back({user["username"] | "", user["password"] | ""}); + } + } + } + if (settings.admins.empty()) { + 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); + } + 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; + } 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; + } + } + + 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)) { + 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) { + if (session_token.isEmpty() || !request->hasHeader("Authorization")) { + return false; + } + return request->header("Authorization") == "Bearer " + session_token; +} + +// ---------------------------------------------------------------------------- +// 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(); + + 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; + + // 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(); + for (const auto & info : partitions) { + JsonObject p = parts.add(); + 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(); + 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) { + 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; +} + +static void uploadFail(AsyncWebServerRequest * request, int code) { + if (upload_error) { + return; + } + upload_error = code; + LOG("Upload error %d", code); + 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_target.clear(); + + if (!isAuthorized(request)) { + uploadFail(request, 403); + 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); + } + 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(filesize)); + uploadFail(request, 406); + return; + } + + if (!OtaUpdater::isCompatibleFirmware(data, len)) { + uploadFail(request, 503); + return; + } + if (OtaUpdater::isRecoveryFirmware(data, len)) { + uploadFail(request, 409); + return; + } + + const esp_partition_t * target = uploadTarget(request); + if (target == nullptr) { + uploadFail(request, 400); + return; + } + if (!ota.beginFirmware(filesize, target)) { + LOG("Cannot write to %s: %s", target->label, ota.errorString()); + uploadFail(request, 507); + return; + } + upload_target = target->label; + LOG("Uploading %s (%uKB) to %s%s", filename.c_str(), static_cast(filesize / 1024), target->label, ota.md5Applied() ? ", MD5 check enabled" : ""); + + request->onDisconnect([] { + if (!upload_done && !upload_target.isEmpty()) { + ota.abort(); + } + }); + } + + if (upload_error || upload_target.isEmpty()) { + return; + } + + if (!ota.write(data, len)) { + LOG("Write failed at %u: %s", static_cast(ota.progress()), ota.errorString()); + ota.abort(); + uploadFail(request, 500); + return; + } + + 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); + return; + } + 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(); + 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(); + 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(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() { + 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/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); + + 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()); + } + } + +#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); +} diff --git a/recovery/recovery_version.h b/recovery/recovery_version.h new file mode 100644 index 000000000..3b757c2a0 --- /dev/null +++ b/recovery/recovery_version.h @@ -0,0 +1 @@ +#define RECOVERY_VERSION "1.0.0" diff --git a/scripts/build_recovery_interface.py b/scripts/build_recovery_interface.py new file mode 100644 index 000000000..23a82bdde --- /dev/null +++ b/scripts/build_recovery_interface.py @@ -0,0 +1,59 @@ +# Builds the Recovery WebUI into recovery/WWWData.h when it's missing or older than its sources +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 is_stale(): + print("Building Recovery WebUI...") + build() +else: + print("Recovery WebUI is up to date") diff --git a/scripts/rename_fw.py b/scripts/rename_fw.py index d63ff2f97..847634433 100755 --- a/scripts/rename_fw.py +++ b/scripts/rename_fw.py @@ -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--- 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" diff --git a/scripts/set_app_desc.py b/scripts/set_app_desc.py new file mode 100644 index 000000000..05ba3f227 --- /dev/null +++ b/scripts/set_app_desc.py @@ -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(" -#include #include -static String getFilenameExtension(const String & filename) { - const auto pos = filename.lastIndexOf('.'); - if (pos != -1) { - return filename.substring(static_cast(pos) + 1); - } - return {}; -} - -// 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 & 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(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(c - 'A' + 'a') : c; - } - out[32] = '\0'; - return true; -} - UploadFileService::UploadFileService(AsyncWebServer * server, SecurityManager * securityManager) : _securityManager(securityManager) , _is_firmware(false) , _is_filesystem(false) - , _md5_applied(false) - , _md5() { + , _is_recovery(false) { server->on( UPLOAD_FILE_PATH, HTTP_POST, @@ -60,68 +29,60 @@ void UploadFileService::handleUpload(AsyncWebServerRequest * request, const Stri // at init if (!index) { // check details of the file, to see if its a valid bin or json file - const String extension = getFilenameExtension(filename); - const std::size_t filesize = request->contentLength(); + const std::size_t filesize = request->contentLength(); _is_firmware = false; _is_filesystem = false; + _is_recovery = false; - if (extension == "bin" && filename.endsWith("littlefs.bin")) { - // LittleFS filesystem image + switch (OtaUpdater::classify(filename, filesize, emsesp::System::MIN_FIRMWARE_SIZE)) { + case OtaUpdater::FileType::FILESYSTEM: _is_filesystem = true; - _md5[0] = '\0'; // clear any stale md5 so Update.end() doesn't compare against it - _md5_applied = false; - } else if ((extension == "bin") && (filesize >= emsesp::System::MIN_FIRMWARE_SIZE)) { + _ota.clearMd5(); // clear any stale md5 so Update.end() doesn't compare against it + break; + case OtaUpdater::FileType::FIRMWARE: _is_firmware = true; - } else if (extension == "json") { - _md5[0] = '\0'; // clear md5 - _md5_applied = false; - } else if (extension == "md5") { - if (!parseMd5Digest(data, len, _md5)) { + break; + case OtaUpdater::FileType::JSON: + _ota.clearMd5(); + break; + case OtaUpdater::FileType::MD5: + if (!_ota.setMd5(data, len)) { emsesp::EMSESP::logger().err("Invalid MD5 digest file"); handleError(request, 406); // Not Acceptable } else { - emsesp::EMSESP::logger().info("MD5 digest received (%s). Now upload the firmware BIN", _md5.data()); + emsesp::EMSESP::logger().info("MD5 digest received (%s). Now upload the firmware BIN", _ota.md5()); } return; - } else { - _md5.front() = '\0'; - _md5_applied = false; + default: + _ota.clearMd5(); emsesp::EMSESP::logger().err("Unsupported file type: %s, size: %u", filename.c_str(), filesize); handleError(request, 406); // Not Acceptable - unsupported file type return; } if (_is_firmware) { - // Check firmware header, 0xE9 magic offset 0 indicates esp bin, chip offset 12: esp32:0, S2:2, C3:5 -#if CONFIG_IDF_TARGET_ESP32 // ESP32/PICO-D4 - if (len > 12 && (data[0] != ESP_IMAGE_HEADER_MAGIC || data[12] != ESP_CHIP_ID_ESP32)) { - handleError(request, 503); // service unavailable - return; - } -#elif CONFIG_IDF_TARGET_ESP32S2 - if (len > 12 && (data[0] != ESP_IMAGE_HEADER_MAGIC || data[12] != ESP_CHIP_ID_ESP32S2)) { - handleError(request, 503); // service unavailable - return; - } -#elif CONFIG_IDF_TARGET_ESP32C3 - if (len > 12 && (data[0] != ESP_IMAGE_HEADER_MAGIC || data[12] != ESP_CHIP_ID_ESP32C3)) { - handleError(request, 503); // service unavailable - return; - } -#elif CONFIG_IDF_TARGET_ESP32S3 - if (len > 12 && (data[0] != ESP_IMAGE_HEADER_MAGIC || data[12] != ESP_CHIP_ID_ESP32S3)) { - handleError(request, 503); // service unavailable - return; - } -#elif CONFIG_IDF_TARGET_ESP32C6 - if (len > 12 && (data[0] != ESP_IMAGE_HEADER_MAGIC || data[12] != ESP_CHIP_ID_ESP32C6)) { + if (!OtaUpdater::isCompatibleFirmware(data, len)) { handleError(request, 503); // service unavailable return; } + + const esp_partition_t * target = nullptr; +#ifdef EMSESP_HAS_RECOVERY + if (OtaUpdater::isRecoveryFirmware(data, len)) { + target = esp_partition_find_first(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_APP_FACTORY, nullptr); + if (target == nullptr || target == esp_ota_get_running_partition()) { + emsesp::EMSESP::logger().err("The recovery firmware can't be installed on this board"); + handleError(request, 406); + return; + } + _is_recovery = true; + emsesp::EMSESP::logger().info("Recovery firmware uploading to the %s partition (size: %dKB). Please wait...", target->label, filesize / 1024); + } else #endif - // it's firmware - initialize the ArduinoOTA updater - emsesp::EMSESP::logger().info("Firmware uploading (file %s, size: %dKB). Please wait...", filename.c_str(), filesize / 1024); + { + emsesp::EMSESP::logger().info("Firmware uploading (file %s, size: %dKB). Please wait...", filename.c_str(), filesize / 1024); + } // turn off UART to prevent interference with the upload emsesp::EMSuart::stop(); @@ -129,12 +90,9 @@ void UploadFileService::handleUpload(AsyncWebServerRequest * request, const Stri // tell main loop we're uploading so services as paused (e.g. MQTT) emsesp::EMSESP::system_.systemStatus(emsesp::SYSTEM_STATUS::SYSTEM_STATUS_UPLOADING); - if (Update.begin(filesize - sizeof(esp_image_header_t))) { - _md5_applied = false; - if (strlen(_md5.data()) == _md5.size() - 1) { - Update.setMD5(_md5.data()); - _md5_applied = true; - emsesp::EMSESP::logger().info("Firmware MD5 check enabled (%s)", _md5.data()); + if (_ota.beginFirmware(filesize, target, !_is_recovery)) { + if (_ota.md5Applied()) { + emsesp::EMSESP::logger().info("Firmware MD5 check enabled (%s)", _ota.md5()); } request->onDisconnect([this] { handleDisconnect(); }); // success, let's make sure we end the update if the client hangs up } else { @@ -147,14 +105,10 @@ void UploadFileService::handleUpload(AsyncWebServerRequest * request, const Stri emsesp::EMSuart::stop(); LittleFS.end(); // unmount LittleFS before we overwrite the partition under it - // request->contentLength() is the multipart HTTP body size, not the file size, - // so it can exceed the partition by a few hundred bytes. Use UPDATE_SIZE_UNKNOWN - // and let the Update library size against the whole partition. - if (Update.begin(UPDATE_SIZE_UNKNOWN, U_SPIFFS)) { - // emsesp::EMSESP::logger().info("Update.begin(U_SPIFFS) ok, partition size %u bytes", static_cast(Update.size())); + if (_ota.beginFilesystem()) { request->onDisconnect([this] { handleDisconnect(); }); } else { - emsesp::EMSESP::logger().err("Update.begin(U_SPIFFS) failed: %s", Update.errorString()); + emsesp::EMSESP::logger().err("Update.begin(U_SPIFFS) failed: %s", _ota.errorString()); handleError(request, 507); return; } @@ -167,17 +121,18 @@ void UploadFileService::handleUpload(AsyncWebServerRequest * request, const Stri if (_is_firmware || _is_filesystem) { if (!request->_tempObject) { //continue with the OTA update - if (Update.write(data, len) != len) { + if (!_ota.write(data, len)) { emsesp::EMSESP::logger().err("OTA update failed at offset %u (chunk %u): %s", - static_cast(Update.progress()), + static_cast(_ota.progress()), static_cast(len), - Update.errorString()); + _ota.errorString()); + _ota.abort(); handleError(request, 500); // internal error, failed return; } if (final) { - if (!Update.end(true)) { - emsesp::EMSESP::logger().err("OTA update failed: %s", Update.errorString()); + if (!_ota.end()) { + emsesp::EMSESP::logger().err("OTA update failed: %s", _ota.errorString()); handleError(request, 500); // internal error, failed return; } @@ -206,21 +161,39 @@ void UploadFileService::uploadComplete(AsyncWebServerRequest * request) { // check if it was a firmware or filesystem image upgrade // if no error, send the success response and request a restart +#ifdef EMSESP_HAS_RECOVERY + if (_is_recovery && !request->_tempObject) { + emsesp::EMSESP::system_.recovery_installed(); + auto * response = new emsesp::PsramAsyncJsonResponse(false); + JsonObject root = response->getRoot(); + root["recovery"] = true; + if (_ota.md5Applied()) { + emsesp::EMSESP::logger().info("Firmware MD5 matches"); + root["md5_ok"] = true; + } + response->setLength(); + request->send(response); + _ota.clearMd5(); + _is_recovery = false; + emsesp::EMSESP::system_.systemStatus(emsesp::SYSTEM_STATUS::SYSTEM_STATUS_NORMAL); // no restart needed + return; + } +#endif + if ((_is_firmware || _is_filesystem) && !request->_tempObject) { if (_is_firmware) { // set NVS to tell EMS-ESP this is a new fresh firmware on next restart emsesp::EMSESP::nvs_.putBool(emsesp::EMSESP_NVS_BOOT_NEW_FIRMWARE, true); } - if (_is_firmware && _md5_applied) { + if (_is_firmware && _ota.md5Applied()) { emsesp::EMSESP::logger().info("Firmware MD5 matches"); auto * response = new emsesp::PsramAsyncJsonResponse(false); JsonObject root = response->getRoot(); root["md5_ok"] = true; response->setLength(); request->send(response); - _md5.front() = '\0'; - _md5_applied = false; + _ota.clearMd5(); } else { AsyncWebServerResponse * response = request->beginResponse(200); request->send(response); @@ -231,10 +204,10 @@ void UploadFileService::uploadComplete(AsyncWebServerRequest * request) { } // add MD5 to the response - if (strlen(_md5.data()) == _md5.size() - 1) { + if (_ota.hasMd5()) { auto * response = new emsesp::PsramAsyncJsonResponse(false); JsonObject root = response->getRoot(); - root["md5"] = _md5.data(); + root["md5"] = _ota.md5(); response->setLength(); request->send(response); return; @@ -262,7 +235,8 @@ void UploadFileService::handleError(AsyncWebServerRequest * request, int code) { request->client()->close(); _is_firmware = false; _is_filesystem = false; - Update.abort(); + _is_recovery = false; + _ota.abort(); } // if we aborted a filesystem upload, remount LittleFS so the device keeps working @@ -275,6 +249,15 @@ void UploadFileService::handleDisconnect() { emsesp::EMSESP::logger().info("Upload finished"); emsesp::EMSESP::system_.uart_init(); // re-enable UART +#ifdef EMSESP_HAS_RECOVERY + if (_is_recovery) { + // the client hung up before the upload completed + _ota.abort(); + emsesp::EMSESP::system_.systemStatus(emsesp::SYSTEM_STATUS::SYSTEM_STATUS_NORMAL); + } +#endif + _is_firmware = false; _is_filesystem = false; + _is_recovery = false; } diff --git a/src/ESP32React/UploadFileService.h b/src/ESP32React/UploadFileService.h index 9da611c7a..c3249b3c2 100644 --- a/src/ESP32React/UploadFileService.h +++ b/src/ESP32React/UploadFileService.h @@ -6,11 +6,9 @@ #include #include #include -#include +#include #include -#include - #define UPLOAD_FILE_PATH "/rest/uploadFile" #define TEMP_FILENAME_PATH "/pre_load.json" // for uploaded json files, handled by System::check_restore() @@ -20,11 +18,11 @@ class UploadFileService { UploadFileService(AsyncWebServer * server, SecurityManager * securityManager); private: - SecurityManager * _securityManager; - bool _is_firmware; - bool _is_filesystem; - bool _md5_applied; // true if an MD5 digest was applied to the current firmware upload - std::array _md5; + SecurityManager * _securityManager; + bool _is_firmware; + bool _is_filesystem; + bool _is_recovery; // firmware for the factory partition, which doesn't change the boot partition + OtaUpdater _ota; void handleUpload(AsyncWebServerRequest * request, const String & filename, size_t index, uint8_t * data, size_t len, bool final); void uploadComplete(AsyncWebServerRequest * request); diff --git a/src/core/emsesp.cpp b/src/core/emsesp.cpp index e460992f0..5ca642a76 100644 --- a/src/core/emsesp.cpp +++ b/src/core/emsesp.cpp @@ -1895,6 +1895,8 @@ void EMSESP::start() { nvs_.begin("ems-esp", false, "nvs"); #endif + System::check_crash_loop(); + // set valid GPIOs list based on ESP32 chip/platform type system_.set_valid_system_gpios(); diff --git a/src/core/emsesp.h b/src/core/emsesp.h index ac15d7fb5..638a6ad15 100644 --- a/src/core/emsesp.h +++ b/src/core/emsesp.h @@ -116,6 +116,8 @@ class EMSESPShell; class Shower; static constexpr const char * EMSESP_NVS_BOOT_NEW_FIRMWARE = "fresh_firmware"; // max 15 characters +// why EMS-ESP switched to the recovery firmware, read and removed by the recovery app (recovery/main.cpp) +static constexpr const char * EMSESP_NVS_RECOVERY_REASON = "rec_reason"; class EMSESP { public: diff --git a/src/core/main.cpp b/src/core/main.cpp index b7271ee31..72c4cd5af 100644 --- a/src/core/main.cpp +++ b/src/core/main.cpp @@ -18,12 +18,32 @@ #include "emsesp.h" +#ifdef EMSESP_CRASH_TEST +#include +#endif + using namespace emsesp; static EMSESP application; // the main application +#ifdef EMSESP_CRASH_TEST +// test builds for the bootloader rollback and the recovery fallback, see [env:crashtest_s3_16M_P] in platformio.ini +static void crash_test() { + esp_ota_img_states_t state = ESP_OTA_IMG_UNDEFINED; + esp_ota_get_state_partition(esp_ota_get_running_partition(), &state); + if (EMSESP_CRASH_TEST == 1 || state == ESP_OTA_IMG_VALID) { + Serial.println("Crash test, aborting"); + Serial.flush(); + abort(); + } +} +#endif + void setup() { application.start(); +#ifdef EMSESP_CRASH_TEST + crash_test(); +#endif } void loop() { diff --git a/src/core/system.cpp b/src/core/system.cpp index 3c65f7c82..28e1fb112 100644 --- a/src/core/system.cpp +++ b/src/core/system.cpp @@ -27,6 +27,7 @@ #include #include "esp_efuse.h" #include +#include #endif #include @@ -43,10 +44,24 @@ #define ENABLE_SMTP #include #include +#include + +// Keeps new firmware in the bootloader's pending verification state until System::check_firmware_health() +// confirms it, so a crash or restart before then rolls back to the previous image +extern "C" bool verifyRollbackLater() { + return true; +} #endif namespace emsesp { +#ifndef EMSESP_STANDALONE +// survives restarts, panics and watchdog resets but not a power cycle, hence the magic +static constexpr uint32_t CRASH_COUNT_MAGIC = 0x43524153; +RTC_NOINIT_ATTR static uint32_t crash_count_magic_; +RTC_NOINIT_ATTR static uint32_t crash_count_; +#endif + // Languages supported. Note: the order is important // and must match locale_translations.h and common.h #if defined(EMSESP_TEST) @@ -517,6 +532,10 @@ void System::get_partition_info() { } } +#ifdef EMSESP_HAS_RECOVERY + // the recovery firmware is shown separately + is_valid = is_valid && !OtaUpdater::isRecoveryPartition(part); +#endif // get the version from the NVS store, and add to map if (is_valid) { PartitionInfo p_info; @@ -547,6 +566,31 @@ void System::get_partition_info() { #endif } +void System::recovery_installed() { +#ifdef EMSESP_HAS_RECOVERY + // drop the version info of an EMS-ESP firmware that was in the factory partition before + const esp_partition_t * factory = esp_partition_find_first(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_APP_FACTORY, nullptr); + if (factory != nullptr) { + char c[20]; + snprintf(c, sizeof(c), "d_%s", factory->label); + EMSESP::nvs_.remove(factory->label); + EMSESP::nvs_.remove(c); + partition_info_.erase(factory->label); // on the AsyncTCP task, same as the web handlers reading it + } + LOG_INFO("Recovery firmware v%s installed", recovery_version().c_str()); +#endif +} + +std::string System::recovery_version() const { +#ifdef EMSESP_HAS_RECOVERY + esp_app_desc_t desc; + if (OtaUpdater::isRecoveryPartition(esp_partition_find_first(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_APP_FACTORY, nullptr), &desc)) { + return desc.version; + } +#endif + return ""; +} + // set install time/date for the current partition, in UTC // assumes NTP is connected and working void System::set_partition_install_date() { @@ -610,7 +654,13 @@ void System::system_restart(const char * partitionname) { // Factory partition - label will be "factory" const esp_partition_t * partition = esp_partition_find_first(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_APP_FACTORY, NULL); if (partition && !strcmp(partition->label, partitionname)) { +#ifdef EMSESP_HAS_RECOVERY + if (esp_ota_set_boot_partition(partition) == ESP_OK) { + EMSESP::nvs_.putString(EMSESP_NVS_RECOVERY_REASON, "request"); + } +#else esp_ota_set_boot_partition(partition); +#endif } else // try and find the partition by name if (strcmp(esp_ota_get_running_partition()->label, partitionname)) { @@ -762,11 +812,74 @@ void System::store_settings(WebSettings & settings) { auto_fw_check_ = settings.auto_fw_check; } +// counts consecutive crashes and falls back to the recovery firmware in the factory partition (16MB boards) +void System::check_crash_loop() { +#ifndef EMSESP_STANDALONE + if (crash_count_magic_ != CRASH_COUNT_MAGIC) { + crash_count_magic_ = CRASH_COUNT_MAGIC; + crash_count_ = 0; + } + + switch (esp_reset_reason()) { + case ESP_RST_PANIC: + case ESP_RST_INT_WDT: + case ESP_RST_TASK_WDT: + case ESP_RST_WDT: + crash_count_++; + break; + default: + crash_count_ = 0; + return; + } + +#ifdef EMSESP_HAS_RECOVERY + if (crash_count_ < CRASH_LOOP_LIMIT) { + return; + } + + const esp_partition_t * factory = esp_partition_find_first(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_APP_FACTORY, nullptr); + if (factory == nullptr || factory == esp_ota_get_running_partition()) { + return; + } + // fails if the factory partition doesn't hold a valid image, then keep trying the current one + if (esp_ota_set_boot_partition(factory) == ESP_OK) { + crash_count_ = 0; + EMSESP::nvs_.putString(EMSESP_NVS_RECOVERY_REASON, "crash"); + Serial.printf("Crashed %d times in a row, restarting into the %s partition\n", CRASH_LOOP_LIMIT, factory->label); + Serial.flush(); + esp_restart(); + } +#endif +#endif +} + +// once the firmware has run long enough, confirm it to the bootloader so it isn't rolled back +void System::check_firmware_health() { +#ifndef EMSESP_STANDALONE + if (firmware_healthy_ || uuid::get_uptime_sec() < FIRMWARE_HEALTHY_UPTIME) { + return; + } + firmware_healthy_ = true; + crash_count_ = 0; + if (OtaUpdater::confirmRunningApp()) { + LOG_INFO("New firmware confirmed as working"); + } +#endif +} + // Starts up core services void System::start() { get_partition_info(); // get the partition info #ifndef EMSESP_STANDALONE + if (crash_count_) { + LOG_WARNING("Restarted after a crash (%u in a row)", crash_count_); + } + const esp_partition_t * invalid = esp_ota_get_last_invalid_partition(); + if (invalid != nullptr) { + LOG_WARNING("Firmware in partition %s failed to start and was rolled back", invalid->label); + } + // disable bluetooth module // periph_module_disable(PERIPH_BT_MODULE); if (low_clock_) { @@ -1028,6 +1141,8 @@ void System::system_check() { if (!last_system_check_ || ((uint32_t)(current_uptime - last_system_check_) >= SYSTEM_CHECK_FREQUENCY)) { last_system_check_ = current_uptime; + check_firmware_health(); + #ifndef EMSESP_STANDALONE #if CONFIG_IDF_TARGET_ESP32S3 || CONFIG_IDF_TARGET_ESP32C3 || CONFIG_IDF_TARGET_ESP32S2 temperature_sensor_get_celsius(temperature_handle_, &temperature_); @@ -3346,6 +3461,8 @@ bool System::uploadFirmwareURL(const char * url) { return false; // error } + OtaUpdater::confirmRunningApp(); + // check we have enough space for the upload in the ota partition if (!Update.begin(firmware_size)) { LOG_ERROR("Firmware upload failed - no space"); diff --git a/src/core/system.h b/src/core/system.h index 66b1c8fc0..0793ba7cb 100644 --- a/src/core/system.h +++ b/src/core/system.h @@ -81,6 +81,11 @@ class System { void start(); bool loop(); // returns true if the LED flash is active + static void check_crash_loop(); // call at boot once NVS is open, may restart into the factory partition + + void recovery_installed(); // called on the AsyncTCP task after the recovery firmware was written + std::string recovery_version() const; // empty if there's no recovery firmware + // commands static bool command_read(const char * value, const int8_t id); static bool command_send(const char * value, const int8_t id); @@ -385,6 +390,14 @@ class System { void set_partition_install_date(); + // firmware is confirmed to the bootloader and the crash counter cleared once it has been up this long + static constexpr uint32_t FIRMWARE_HEALTHY_UPTIME = 120; // seconds + // consecutive crashes before falling back to the recovery firmware in the factory partition + static constexpr uint8_t CRASH_LOOP_LIMIT = 5; + + bool firmware_healthy_ = false; + void check_firmware_health(); + // button static PButton myPButton_; // PButton instance static void button_OnClick(PButton & b); diff --git a/src/web/WebStatusService.cpp b/src/web/WebStatusService.cpp index 642d84a52..fbc9f4874 100644 --- a/src/web/WebStatusService.cpp +++ b/src/web/WebStatusService.cpp @@ -137,6 +137,9 @@ void WebStatusService::systemStatus(AsyncWebServerRequest * request) { // check for a factory partition first const esp_partition_t * partition = esp_partition_find_first(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_APP_FACTORY, nullptr); root["has_loader"] = partition != NULL && partition != esp_ota_get_running_partition(); + if (partition != NULL) { + root["recovery_version"] = EMSESP::system_.recovery_version(); // empty if not installed + } partition = esp_ota_get_next_update_partition(nullptr); if (partition) { uint64_t buffer; From 965d93661abd8aaccf6ea4ba21731628bf26891b Mon Sep 17 00:00:00 2001 From: proddy Date: Wed, 7 Oct 2026 23:00:14 +0200 Subject: [PATCH 02/13] crashmode --- docs/Recovery-Testing.md | 29 +- docs/Recovery.md | 104 ++++++ interface/package.json | 2 +- interface/pnpm-lock.yaml | 42 +-- interface/src/app/main/UserProfile.tsx | 2 + .../src/app/settings/ApplicationSettings.tsx | 2 + interface/src/app/settings/Version.tsx | 333 +++++++++++------- interface/src/types/system.ts | 1 - lib/OtaUpdater/OtaUpdater.cpp | 104 ++++-- lib/OtaUpdater/OtaUpdater.h | 18 +- mock-api/restServer.ts | 46 +-- platformio.ini | 4 +- scripts/do_fresh_install.sh | 60 +++- src/ESP32React/UploadFileService.cpp | 48 ++- src/ESP32React/UploadFileService.h | 1 + src/core/system.cpp | 65 +++- src/core/system.h | 3 +- src/emsesp_version.h | 2 +- src/web/WebStatusService.cpp | 7 +- 19 files changed, 600 insertions(+), 273 deletions(-) create mode 100644 docs/Recovery.md diff --git a/docs/Recovery-Testing.md b/docs/Recovery-Testing.md index 2177451a5..7400c0686 100644 --- a/docs/Recovery-Testing.md +++ b/docs/Recovery-Testing.md @@ -8,24 +8,23 @@ This guide covers testing on hardware of: - the fallback to the recovery after repeated crashes; - Ethernet and static IP support in the recovery. -It's written for an ESP32-S3 16MB board, since the crash test build is only defined for `s3_16M_P`. Ethernet testing needs an E32V2. Keep a USB serial cable connected with a serial monitor open throughout, as most of the checks are log lines. +It's written for an ESP32 16MB board with PSRAM (`s_16M_P`, e.g. a BBQKees E32V2). Other 16MB boards (`s_16M`, `s3_16M_P`) follow the same steps with their matching PlatformIO envs; Ethernet is E32V2 only. Keep a USB serial cable connected with a serial monitor open throughout, as most of the checks are log lines. ## 0. Preparation Build everything: ```bash -pio run -e s3_16M_P -e crashtest_s3_16M_P -e recovery_s3_16M_P -pio run -e s_16M -e s_16M_P -e s_4M -e recovery_s_16M -e recovery_s_16M_P +pio run -e s_16M_P -e crashtest_s_16M_P -e recovery_s_16M_P ``` The firmware files are written to `build/firmware/`: | File | What it is | | --- | --- | -| `EMS-ESP--ESP32S3-16MB+.bin` | EMS-ESP | -| `EMS-ESP-CrashTest--ESP32S3-16MB+.bin` | crash test build | -| `EMS-ESP-Recovery--ESP32S3-16MB+.bin` | the recovery | +| `EMS-ESP--ESP32-16MB+.bin` | EMS-ESP | +| `EMS-ESP-CrashTest--ESP32-16MB+.bin` | crash test build | +| `EMS-ESP-Recovery--ESP32-16MB+.bin` | the recovery | Each has a matching `.md5` file. @@ -33,13 +32,13 @@ Then: 1. Run the unit tests with `pio run -e native-test -t exec`. All tests should pass. 2. Optionally, check the recovery UI against mock data first: `cd interface && pnpm recovery`, then open . -3. Open the serial monitor with `pio device monitor -e s3_16M_P`. Recovery log lines start with `[recovery]`. +3. Open the serial monitor with `pio device monitor -e s_16M_P`. Recovery log lines start with `[recovery]`. The crash counter is kept in RTC memory. It survives crashes and software restarts but not a power cycle, so don't unplug the board during the crash tests. ## 1. Baseline: EMS-ESP over serial, no recovery -1. Flash with `pio run -e s3_16M_P -t upload`. This also writes the current bootloader, which has rollback enabled. Boards flashed with an older bootloader don't roll back until they've been flashed over serial once. +1. Flash with `pio run -e s_16M_P -t upload`. This also writes the current bootloader, which has rollback enabled. Boards flashed with an older bootloader don't roll back until they've been flashed over serial once. 2. In the WebUI, open **Settings → Version**. Check that: - the **Recovery** row shows "not installed"; - the firmware partition list doesn't include `boot`. @@ -54,7 +53,7 @@ The crash counter is kept in RTC memory. It survives crashes and software restar - the install logged. 2. Repeat, uploading the `.md5` first and then the `.bin`. The MD5 check should pass. 3. Negative tests: - - Upload the recovery built for another chip, e.g. the ESP32 file on an S3. It should be rejected, and nothing should be written. + - Upload the recovery built for another chip, e.g. the ESP32-S3 file on this ESP32. It should be rejected, and nothing should be written. - Start a recovery upload and close the browser tab halfway through. EMS-ESP should keep running and the Recovery row should show "not installed". Reinstall it properly afterwards. 4. Restart EMS-ESP normally. It should boot EMS-ESP again, not the recovery, because installing the recovery doesn't change the boot partition. @@ -67,14 +66,14 @@ The crash counter is kept in RTC memory. It survives crashes and software restar - `[recovery] EMS-ESP Recovery v…` is logged; - LittleFS is mounted; - the access point has started; - - WiFi connects. + - Ethernet or WiFi connects. 3. Join the access point. Its name is the AP SSID plus `-recovery` (default `ems-esp-recovery`), and it uses the AP password (default `ems-esp-neo`). Open ; a captive portal page may open by itself. Alternatively, use the device's normal IP or `http://.local` from the LAN. 4. Sign in with an EMS-ESP admin account (default `admin`/`admin`). A non-admin user must be refused. 5. On the dashboard, check that: - there's **no** red reason box, because the restart was requested; - `Recovery (boot)` has the "running" chip; - the installed firmware shows the correct version; - - the System section shows the chip, flash, MAC, access point, and WiFi with its IP. + - the System section shows the chip, flash, MAC, access point, and the active network (Ethernet or WiFi) with its IP. 6. Click **Restart**. It should come back into the recovery, still without a reason box, since the reason is only shown once. 7. Click **Start** on the EMS-ESP slot. EMS-ESP should boot, and the Version page should still show the recovery version. @@ -114,7 +113,7 @@ If step 3 ends in a crash loop instead of a rollback, the bootloader on the boar ## 6. Crash-loop fallback to the recovery (crash test mode 2) -1. In `platformio.ini`, change `-D EMSESP_CRASH_TEST=1` to `=2`, then run `pio run -e crashtest_s3_16M_P`. +1. In `platformio.ini`, change `-D EMSESP_CRASH_TEST=1` to `=2`, then run `pio run -e crashtest_s_16M_P`. 2. Upload the crash test build from the WebUI. It runs normally. 3. Wait for "New firmware confirmed as working", about 2 minutes after boot. 4. Restart it from the WebUI. It should crash 5 times in a row, logging the "N in a row" count each time. @@ -128,7 +127,7 @@ If step 3 ends in a crash loop instead of a rollback, the bootloader on the boar Optional, without a recovery installed: -1. Erase the boot partition with `esptool --chip esp32s3 erase-region 0x10000 0x480000`. Older esptool versions use `erase_region`. +1. Erase the boot partition with `esptool --chip esp32 erase-region 0x10000 0x480000`. Older esptool versions use `erase_region`. 2. Repeat steps 2 to 4. It should keep crashing past 5, and needs to be reflashed over serial. ## 7. Network in the recovery @@ -136,7 +135,7 @@ Optional, without a recovery installed: | Test | Setup | Expected in the recovery | | --- | --- | --- | | WiFi with static IP | Enable a static IP in the EMS-ESP Network settings, then `restart boot` | Same IP as EMS-ESP, and the WiFi row ends with ", static IP" | -| Ethernet (E32V2) | Cable plugged in, `recovery_s_16M_P` installed, then `restart boot` | Log shows "Ethernet started" and "Ethernet connected, IP …". The Ethernet row shows "connected", and WiFi shows "not used, Ethernet is connected" | +| Ethernet | Cable plugged in, then `restart boot` | Log shows "Ethernet started" and "Ethernet connected, IP …". The Ethernet row shows "connected", and WiFi shows "not used, Ethernet is connected" | | Ethernet with static IP | As above, with a static IP set | Ethernet uses the static IP and WiFi isn't started | | No cable | Unplugged, then `restart boot` | Ethernet shows "no cable connected" and WiFi shows "waiting for Ethernet". After about 15 seconds the log shows "No Ethernet connection" and WiFi connects | | Cable plugged in later | Plug in after WiFi has joined | Ethernet gets an IP as well, and WiFi stays connected | @@ -153,4 +152,4 @@ Optional, without a recovery installed: ## Reset to a clean state -`pio run -e s3_16M_P -t upload` restores EMS-ESP at any point. It leaves the recovery in `boot` and the settings untouched. +`pio run -e s_16M_P -t upload` restores EMS-ESP at any point. It leaves the recovery in `boot` and the settings untouched. diff --git a/docs/Recovery.md b/docs/Recovery.md new file mode 100644 index 000000000..603c06030 --- /dev/null +++ b/docs/Recovery.md @@ -0,0 +1,104 @@ +# EMS-ESP Recovery Firmware + +Small standalone app that lives in the factory (`boot`) partition on **16MB** boards. If EMS-ESP will not start, the recovery still can: it brings up an access point (and the saved WiFi/Ethernet if it can) and serves a cut-down WebUI to upload a new EMS-ESP image into `app0`/`app1` or choose which slot to boot. + +4MB boards have no factory partition and no recovery. + +Hardware test steps are in [Recovery-Testing.md](Recovery-Testing.md). + +## Why it exists + +ESP-IDF already rolls a failed OTA back to the **other** OTA slot (`app0` ↔ `app1`). That does not help when: + +- both OTA slots are empty, corrupt, or crash on start +- a serial flash left otadata pointing at an empty slot +- the user needs a known-good way back in without a USB cable + +Recovery is a third, smaller image that EMS-ESP does not overwrite during a normal upgrade. Installing it does **not** change which app boots. + +## Partitions (16MB) + +From `partitions/esp32_partition_16M.csv`: + +| Label | Subtype | Role | +| --- | --- | --- | +| `boot` | factory | Recovery image | +| `app0` | ota_0 | EMS-ESP | +| `app1` | ota_1 | EMS-ESP (the other OTA slot) | +| `otadata` | ota | Which OTA slot the bootloader starts | + +The bootloader reads **otadata**, not “whatever was flashed last”: + +1. Blank otadata (`0xFF`, e.g. after `erase-flash` or a recovery serial upload) → start **factory** (`boot`). +2. Valid otadata → start that OTA slot (`app0` or `app1`). +3. If that image is invalid, it walks **down** toward factory, then **up** through remaining OTA slots. A valid recovery in `boot` is therefore preferred over a valid image sitting only in `app1` while otadata still points at empty `app0`. + +PlatformIO’s `boot_app0.bin` always selects `app0`. The recovery upload script (`scripts/upload_factory.py`) replaces it with a blank otadata so a serial flash of recovery actually boots recovery. + +A normal serial flash of EMS-ESP goes to `app0` and writes `boot_app0.bin`, so the board starts EMS-ESP. Recovery, if already in `boot`, is left in place. + +## How recovery is installed + +From a running EMS-ESP (**Settings → Version**, or the upload drop zone): + +- Drop `EMS-ESP-Recovery-*.bin` (optional `.md5` first). +- EMS-ESP recognises it from the app description (`project_name` = `EMS-ESP-Recovery`) and writes it to the factory partition **without** calling `esp_ota_set_boot_partition`. +- EMS-ESP keeps running. The Version page shows the recovery version once it is in flash. + +From serial, recovery must be written at **`0x10000`**, not at `0x1000` (that is the bootloader; the recovery `.bin` overlaps the partition table). See `scripts/do_fresh_install.sh`. + +The recovery app itself **refuses** to install a recovery image. It only writes EMS-ESP into `app0`/`app1`. + +## How EMS-ESP boots into recovery + +Only 16MB builds with `-DEMSESP_HAS_RECOVERY` and a valid recovery image in `boot`. + +| How | What happens | +| --- | --- | +| **Settings → Version** | Developer mode shows a Recovery row. If a version is installed, **Restart** confirms, then `setPartition("boot")`. | +| **Console** (serial or telnet, admin) | `restart boot` → `system_restart("boot")`. | +| **Button** | Long press ~3 s (not the ~10 s factory reset). | +| **Crash loop** | 5 panics or watchdog resets in a row (RTC counter; survives software restart, not a power cycle). | + +The first three record NVS `rec_reason=request`. The crash path records `crash`. Recovery reads that once and clears it, so a user-requested entry does not show the red crash banner. + +These do **not** enter recovery: + +- Uploading the recovery `.bin` (install only). +- `/api/system/restart` or MQTT `system/restart` (ignore any partition argument; reboot the current app). +- Bootloader rollback of a pending OTA (goes to the previous **OTA** slot). + +## What recovery does + +- Access point `ems-esp-recovery` (AP SSID plus `-recovery`), plus the saved LAN (Ethernet preferred, else WiFi, including a static IP if one is set). +- Sign-in with an EMS-ESP admin account (settings are read from LittleFS, never written). +- List `boot` / `app0` / `app1`, show which is running, which will boot next, and rollback state. +- Upload an EMS-ESP `.bin` into an OTA slot (empty slot, or the older of the two if both are filled) and boot it. +- **Start** a chosen OTA slot without uploading. + +After a WebUI/OTA install from recovery, the new EMS-ESP image is pending verification. After ~2 minutes of uptime it is confirmed so the bootloader will not roll it back. + +## Rough boot flow + +``` +reset + → ESP-IDF bootloader + otadata blank? → factory/boot (recovery) + otadata → app0/1 → that slot if the image is valid + selected image bad → try factory, then the other OTA slot + → recovery or EMS-ESP + +EMS-ESP, if it starts: + crash counter (RTC) + 5 crashes in a row + recovery present → set boot=factory, restart + healthy for 2 minutes → confirm OTA image, clear crash counter +``` + +## Builds + +| Env | Image | +| --- | --- | +| `recovery_s_16M` / `recovery_s_16M_P` / `recovery_s3_16M_P` | Recovery, flashed into `boot` at `0x10000` | +| `s_16M` / `s_16M_P` / `s3_16M_P` | EMS-ESP (`app0` on serial upload) | + +Firmware files land in `build/firmware/` as `EMS-ESP-Recovery---16MB[+].bin`. diff --git a/interface/package.json b/interface/package.json index ce0e5cd1d..475b26bc0 100644 --- a/interface/package.json +++ b/interface/package.json @@ -57,7 +57,7 @@ "terser": "^5.51.2", "typescript": "^6.0.3", "typescript-eslint": "^8.71.1", - "vite": "^8.3.2", + "vite": "^8.3.3", "vite-plugin-imagemin": "^0.6.1" }, "packageManager": "pnpm@12.9.1" diff --git a/interface/pnpm-lock.yaml b/interface/pnpm-lock.yaml index eab249366..94b15bac3 100644 --- a/interface/pnpm-lock.yaml +++ b/interface/pnpm-lock.yaml @@ -214,7 +214,7 @@ importers: version: 10.0.1(eslint@10.12.0(supports-color@10.2.2)) '@preact/preset-vite': specifier: ^2.10.6 - version: 2.10.6(@babel/core@7.29.7(supports-color@10.2.2))(preact@11.0.0)(supports-color@10.2.2)(vite@8.3.2(@types/node@26.6.4)(terser@5.51.2)) + version: 2.10.6(@babel/core@7.29.7(supports-color@10.2.2))(preact@11.0.0)(supports-color@10.2.2)(vite@8.3.3(@types/node@26.6.4)(terser@5.51.2)) '@trivago/prettier-plugin-sort-imports': specifier: ^6.0.2 version: 6.0.2(prettier@3.9.9)(supports-color@10.2.2) @@ -258,11 +258,11 @@ importers: specifier: ^8.71.1 version: 8.71.1(eslint@10.12.0(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) vite: - specifier: ^8.3.2 - version: 8.3.2(@types/node@26.6.4)(terser@5.51.2) + specifier: ^8.3.3 + version: 8.3.3(@types/node@26.6.4)(terser@5.51.2) vite-plugin-imagemin: specifier: ^0.6.1 - version: 0.6.1(supports-color@10.2.2)(vite@8.3.2(@types/node@26.6.4)(terser@5.51.2)) + version: 0.6.1(supports-color@10.2.2)(vite@8.3.3(@types/node@26.6.4)(terser@5.51.2)) packages: @@ -1338,8 +1338,8 @@ packages: duplexer3@0.1.5: resolution: {integrity: sha512-1A8za6ws41LQgv9HrE/66jyC5yuSjQ3L/KOpFtoBilsAK2iA2wuS5rTt1OCzIvtS2V7nVmedsUU+DGRcjBmOYA==} - electron-to-chromium@1.5.444: - resolution: {integrity: sha512-5ss/uJfoDYDHT0lfJzT6FbcskIzROIOPf0BbbFkGcvDzoJU7i//9GDrwwIHQVmIsrAGiF3ihpADBRIsrEFt1rQ==} + electron-to-chromium@1.5.445: + resolution: {integrity: sha512-iX7o1nF+qNb3Bv5mLYgqfgF9Znh9e/8MKXdmbb8Gv2Q47OKIzTwEPD1oda1HrgEwYatTLJj0wQjHvIFt8Db53Q==} emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -3057,8 +3057,8 @@ packages: peerDependencies: vite: 5.x || 6.x || 7.x || 8.x - vite@8.3.2: - resolution: {integrity: sha512-SQr1x6W5vVSbROg7vsyXIaxK9b0G7zsT68acdWWRmnBUsgDieLCRG+Rep9WdZgcposvv/GSnr4GUUBqB3vXq6w==} + vite@8.3.3: + resolution: {integrity: sha512-cTAldKPImjg6c+gk48U19POPn3GCBzZwpdsN8ZMEEcbpes+6/wvfqUd0C2y3qYY4wsj8PwgFwrZ/1jBPVttMSg==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -3591,19 +3591,19 @@ snapshots: '@popperjs/core@2.11.8': {} - '@preact/preset-vite@2.10.6(@babel/core@7.29.7(supports-color@10.2.2))(preact@11.0.0)(supports-color@10.2.2)(vite@8.3.2(@types/node@26.6.4)(terser@5.51.2))': + '@preact/preset-vite@2.10.6(@babel/core@7.29.7(supports-color@10.2.2))(preact@11.0.0)(supports-color@10.2.2)(vite@8.3.3(@types/node@26.6.4)(terser@5.51.2))': dependencies: '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/plugin-transform-react-jsx': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) '@babel/plugin-transform-react-jsx-development': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) - '@prefresh/vite': 2.4.12(preact@11.0.0)(supports-color@10.2.2)(vite@8.3.2(@types/node@26.6.4)(terser@5.51.2)) + '@prefresh/vite': 2.4.12(preact@11.0.0)(supports-color@10.2.2)(vite@8.3.3(@types/node@26.6.4)(terser@5.51.2)) '@rollup/pluginutils': 5.4.0 babel-plugin-transform-hook-names: 1.0.2(@babel/core@7.29.7(supports-color@10.2.2)) debug: 4.4.3(supports-color@10.2.2) magic-string: 0.30.21 picocolors: 1.1.1 - vite: 8.3.2(@types/node@26.6.4)(terser@5.51.2) - vite-prerender-plugin: 0.5.14(vite@8.3.2(@types/node@26.6.4)(terser@5.51.2)) + vite: 8.3.3(@types/node@26.6.4)(terser@5.51.2) + vite-prerender-plugin: 0.5.14(vite@8.3.3(@types/node@26.6.4)(terser@5.51.2)) zimmerframe: 1.1.5 transitivePeerDependencies: - preact @@ -3620,7 +3620,7 @@ snapshots: '@prefresh/utils@1.2.1': {} - '@prefresh/vite@2.4.12(preact@11.0.0)(supports-color@10.2.2)(vite@8.3.2(@types/node@26.6.4)(terser@5.51.2))': + '@prefresh/vite@2.4.12(preact@11.0.0)(supports-color@10.2.2)(vite@8.3.3(@types/node@26.6.4)(terser@5.51.2))': dependencies: '@babel/core': 7.29.7(supports-color@10.2.2) '@prefresh/babel-plugin': 0.5.4(@babel/core@7.29.7(supports-color@10.2.2)) @@ -3628,7 +3628,7 @@ snapshots: '@prefresh/utils': 1.2.1 '@rollup/pluginutils': 4.2.1 preact: 11.0.0 - vite: 8.3.2(@types/node@26.6.4)(terser@5.51.2) + vite: 8.3.3(@types/node@26.6.4)(terser@5.51.2) transitivePeerDependencies: - supports-color @@ -4005,7 +4005,7 @@ snapshots: dependencies: baseline-browser-mapping: 2.11.27 caniuse-lite: 1.0.30001814 - electron-to-chromium: 1.5.444 + electron-to-chromium: 1.5.445 node-releases: 2.0.57 update-browserslist-db: 1.3.3(browserslist@4.29.3) @@ -4370,7 +4370,7 @@ snapshots: duplexer3@0.1.5: {} - electron-to-chromium@1.5.444: {} + electron-to-chromium@1.5.445: {} emoji-regex@10.6.0: {} @@ -5982,7 +5982,7 @@ snapshots: spdx-correct: 3.2.0 spdx-expression-parse: 3.0.1 - vite-plugin-imagemin@0.6.1(supports-color@10.2.2)(vite@8.3.2(@types/node@26.6.4)(terser@5.51.2)): + vite-plugin-imagemin@0.6.1(supports-color@10.2.2)(vite@8.3.3(@types/node@26.6.4)(terser@5.51.2)): dependencies: '@types/imagemin': 7.0.1 '@types/imagemin-gifsicle': 7.0.4 @@ -6007,11 +6007,11 @@ snapshots: imagemin-webp: 6.1.0 jpegtran-bin: 6.0.1 pathe: 0.2.0 - vite: 8.3.2(@types/node@26.6.4)(terser@5.51.2) + vite: 8.3.3(@types/node@26.6.4)(terser@5.51.2) transitivePeerDependencies: - supports-color - vite-prerender-plugin@0.5.14(vite@8.3.2(@types/node@26.6.4)(terser@5.51.2)): + vite-prerender-plugin@0.5.14(vite@8.3.3(@types/node@26.6.4)(terser@5.51.2)): dependencies: kolorist: 1.8.0 magic-string: 0.30.21 @@ -6019,9 +6019,9 @@ snapshots: simple-code-frame: 1.3.0 source-map: 0.7.6 stack-trace: 1.0.0 - vite: 8.3.2(@types/node@26.6.4)(terser@5.51.2) + vite: 8.3.3(@types/node@26.6.4)(terser@5.51.2) - vite@8.3.2(@types/node@26.6.4)(terser@5.51.2): + vite@8.3.3(@types/node@26.6.4)(terser@5.51.2): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 diff --git a/interface/src/app/main/UserProfile.tsx b/interface/src/app/main/UserProfile.tsx index ebcae0be3..bad7cbab8 100644 --- a/interface/src/app/main/UserProfile.tsx +++ b/interface/src/app/main/UserProfile.tsx @@ -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 = () => { @@ -665,77 +670,69 @@ const Version = () => { {LL.THIS_VERSION()} - - - {LL.VERSION()} - - - + {LL.VERSION()} + + {data.emsesp_version} {data.build_flags && ( - -   ({data.build_flags}) + +  ({data.build_flags}) )} - setPartitionVersionInfo(data.partition)} - aria-label={LL.FIRMWARE_VERSION_INFO()} - > - - - + setPartitionVersionInfo(data.partition)} + aria-label={LL.FIRMWARE_VERSION_INFO()} + sx={{ p: 0.5 }} + > + + + - - {LL.PLATFORM()} - - - - {platform} - -   ( - {data.psram ? ( - - ) : ( - - )} - PSRAM) - + {LL.PLATFORM()} + + {platform} + + ( + {data.psram ? ( + + ) : ( + + )} + PSRAM) - - - {data.recovery_version !== undefined && ( - <> - - {LL.RECOVERY()} - - - - {data.recovery_version || LL.RECOVERY_NOT_INSTALLED()} - - - - )} - + + {internetLive ? ( <> @@ -743,83 +740,147 @@ const Version = () => { {LL.AVAILABLE_VERSION()} - - {otherPartitions.length > 0 && data.developer_mode && ( + {data.developer_mode && ( <> - - {LL.STORED_VERSIONS()} - - - {otherPartitions.map((partition) => ( - - {partition.version} - - setPartitionVersionInfo(partition.partition) - } - aria-label={LL.FIRMWARE_VERSION_INFO()} - > - - - + {otherPartitions.length > 0 && ( + <> + + {LL.STORED_VERSIONS()} - ))} - + + {otherPartitions.map((partition) => ( + + + {partition.version} + + + setPartitionVersionInfo(partition.partition) + } + aria-label={LL.FIRMWARE_VERSION_INFO()} + sx={{ p: 0.5 }} + > + + + + + ))} + + + )} + {data.recovery_version !== undefined && ( + <> + {LL.RECOVERY()} + + + {data.recovery_version || LL.RECOVERY_NOT_INSTALLED()} + + {data.recovery_version && me.admin && ( + + )} + + + + )} )} - - {LL.STABLE()} - - - - {latestVersion?.version} - setShowVersionInfo(1)} - aria-label={LL.FIRMWARE_VERSION_INFO()} - > - - - {showButtons(false)} - - + {LL.STABLE()} + + {latestVersion?.version} + setShowVersionInfo(1)} + aria-label={LL.FIRMWARE_VERSION_INFO()} + sx={{ p: 0.5 }} + > + + + {showButtons(false)} + - - {LL.DEVELOPMENT()} - - - - {latestDevVersion?.version} - setShowVersionInfo(2)} - aria-label={LL.FIRMWARE_VERSION_INFO()} - > - - - {showButtons(true)} - - - + {LL.DEVELOPMENT()} + + {latestDevVersion?.version} + setShowVersionInfo(2)} + aria-label={LL.FIRMWARE_VERSION_INFO()} + sx={{ p: 0.5 }} + > + + + {showButtons(true)} + + ) : ( diff --git a/interface/src/types/system.ts b/interface/src/types/system.ts index 94e8d62f2..6aef9ed44 100644 --- a/interface/src/types/system.ts +++ b/interface/src/types/system.ts @@ -51,7 +51,6 @@ export interface SystemStatus { free_caps: number; model: string; board: string; - has_loader: boolean; recovery_version?: string; has_partition: boolean; partitions: { diff --git a/lib/OtaUpdater/OtaUpdater.cpp b/lib/OtaUpdater/OtaUpdater.cpp index c94f07084..df2c88b64 100644 --- a/lib/OtaUpdater/OtaUpdater.cpp +++ b/lib/OtaUpdater/OtaUpdater.cpp @@ -3,6 +3,10 @@ #include #include #include +#ifdef OTA_UPDATER_ANY_PARTITION +#include +#include +#endif #include @@ -19,8 +23,14 @@ OtaUpdater::FileType OtaUpdater::classify(const String & filename, size_t filesi if (extension == "bin" && filename.endsWith("littlefs.bin")) { return FileType::FILESYSTEM; } - if (extension == "bin" && filesize >= min_firmware_size) { - return FileType::FIRMWARE; + 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; @@ -114,6 +124,19 @@ 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(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 = ""; @@ -126,13 +149,14 @@ bool OtaUpdater::beginFirmware(size_t filesize, const esp_partition_t * target, _error = "Firmware does not fit in the partition"; return false; } - const esp_err_t err = esp_ota_begin(target, OTA_WITH_SEQUENTIAL_WRITES, &_handle); - if (err != ESP_OK) { - _error = esp_err_to_name(err); + _dram = static_cast(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(); @@ -166,15 +190,39 @@ bool OtaUpdater::beginFilesystem() { bool OtaUpdater::write(const uint8_t * data, size_t len) { #ifdef OTA_UPDATER_ANY_PARTITION if (_target != nullptr) { - const esp_err_t err = esp_ota_write(_handle, data, len); - if (err != ESP_OK) { - _error = esp_err_to_name(err); - return false; + 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; } - if (_md5_applied) { - _md5Builder.add(data, len); - } - _written += len; return true; } #endif @@ -184,27 +232,32 @@ bool OtaUpdater::write(const uint8_t * data, size_t len) { bool OtaUpdater::end() { #ifdef OTA_UPDATER_ANY_PARTITION if (_target != nullptr) { - const esp_partition_t * target = _target; - _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) { - esp_ota_abort(_handle); _error = "MD5 check failed"; return false; } } - // esp_ota_end() validates the image - esp_err_t err = esp_ota_end(_handle); - if (err == ESP_OK && _set_boot) { - err = esp_ota_set_boot_partition(target); - } + 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) { - _error = esp_err_to_name(err); + 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 @@ -214,12 +267,13 @@ bool OtaUpdater::end() { void OtaUpdater::abort() { #ifdef OTA_UPDATER_ANY_PARTITION if (_target != nullptr) { - esp_ota_abort(_handle); - _target = nullptr; + releaseTarget(); return; } #endif - Update.abort(); + if (Update.isRunning()) { + Update.abort(); + } } size_t OtaUpdater::progress() const { diff --git a/lib/OtaUpdater/OtaUpdater.h b/lib/OtaUpdater/OtaUpdater.h index e35c512c2..d13f4bb3f 100644 --- a/lib/OtaUpdater/OtaUpdater.h +++ b/lib/OtaUpdater/OtaUpdater.h @@ -16,11 +16,16 @@ 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); @@ -64,12 +69,21 @@ class OtaUpdater { const char * _error = ""; #ifdef OTA_UPDATER_ANY_PARTITION - // set when writing to an explicit target partition with the IDF OTA API instead of Update + // 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; - esp_ota_handle_t _handle = 0; 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 }; diff --git a/mock-api/restServer.ts b/mock-api/restServer.ts index ea1fd57c0..633695866 100644 --- a/mock-api/restServer.ts +++ b/mock-api/restServer.ts @@ -116,8 +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: '', // upload an EMS-ESP-Recovery-*.bin to set it + recovery_version: '1.0.0', has_partition: true, partitions: [ { @@ -152,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 @@ -344,10 +344,10 @@ function updateMask(entity: any, de: any, dd: any) { const old_custom_name = dd.nodes[dd_objIndex].cn; console.log( 'comparing names, old (' + - old_custom_name + - ') with new (' + - new_custom_name + - ')' + old_custom_name + + ') with new (' + + new_custom_name + + ')' ); if (old_custom_name !== new_custom_name) { changed = true; @@ -467,9 +467,9 @@ function upgradeImportantMessages(version: string) { console.log( 'upgradeImportantMessageType: version=' + - version + - ' type=' + - upgradeImportantMessageType_n + version + + ' type=' + + upgradeImportantMessageType_n ); return { upgradeImportantMessageType: upgradeImportantMessageType_n }; } @@ -520,17 +520,17 @@ function get_versions() { console.log( 'getVersions: current=' + - THIS_VERSION + - ' stable=' + - LATEST_STABLE_VERSION + - ' (upgradeable=' + - (STABLE_VERSION_IS_UPGRADEABLE ? 'YES' : 'NO') + - ') dev=' + - LATEST_DEV_VERSION + - ' (upgradeable=' + - (DEV_VERSION_IS_UPGRADEABLE ? 'YES' : 'NO') + - ')' + - (MOCK_OFFLINE ? ' [offline]' : '') + THIS_VERSION + + ' stable=' + + LATEST_STABLE_VERSION + + ' (upgradeable=' + + (STABLE_VERSION_IS_UPGRADEABLE ? 'YES' : 'NO') + + ') dev=' + + LATEST_DEV_VERSION + + ' (upgradeable=' + + (DEV_VERSION_IS_UPGRADEABLE ? 'YES' : 'NO') + + ')' + + (MOCK_OFFLINE ? ' [offline]' : '') ); return data; } diff --git a/platformio.ini b/platformio.ini index 8a428a492..4206ec789 100644 --- a/platformio.ini +++ b/platformio.ini @@ -177,8 +177,8 @@ build_flags = ; 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_s3_16M_P] -extends = env:s3_16M_P +[env:crashtest_s_16M_P] +extends = env:s_16M_P build_flags = ${espressif32_base_16M.build_flags} -D EMSESP_CRASH_TEST=1 diff --git a/scripts/do_fresh_install.sh b/scripts/do_fresh_install.sh index 790ced624..35b27cbc0 100755 --- a/scripts/do_fresh_install.sh +++ b/scripts/do_fresh_install.sh @@ -1,26 +1,56 @@ +#!/usr/bin/env bash +# Full fresh install of EMS-ESP on an ESP32 16MB board (s_16M_P). +# +# Run from the scripts folder: +# ./do_fresh_install.sh +# +# 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 ESPTOOL=~/.platformio/penv/bin/esptool PORT=/dev/ttyUSB0 -BUILD=../.pio/build/s_16M_P +BAUD=${BAUD:-2000000} + +BUILD_BIN=../.pio/build/s_16M_P +RECOVERY_BIN=../.pio/build/recovery_s_16M_P BOOT_APP0=~/.platformio/packages/framework-arduinoespressif32/tools/partitions/boot_app0.bin -# erase +for f in \ + "$BUILD_BIN/bootloader.bin" \ + "$BUILD_BIN/partitions.bin" \ + "$BUILD_BIN/firmware.bin" \ + "$RECOVERY_BIN/firmware.bin" \ + "$BOOT_APP0"; do + if [[ ! -f $f ]]; then + echo "Missing $f — build s_16M_P and recovery_s_16M_P first" >&2 + exit 1 + fi +done + +# full erase, stay in download mode $ESPTOOL --chip esp32 --port $PORT --after no-reset erase-flash -# create the filesystem -~/.platformio/penv/bin/python build_littlefs.py data $BUILD/littlefs.bin +# create the filesystem, in case it's needed +~/.platformio/penv/bin/python build_littlefs.py data $BUILD_BIN/littlefs.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 - -# flash bootloader + partition table + app (app0 @ 0x490000), then boot -$ESPTOOL --chip esp32 --port $PORT --baud 460800 \ +# bootloader, partitions, otadata, recovery, app, filesystem; then boot +$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 \ + 0x1000 $BUILD_BIN/bootloader.bin \ + 0x8000 $BUILD_BIN/partitions.bin \ 0xe000 $BOOT_APP0 \ - 0x490000 $BUILD/firmware.bin + 0x10000 $RECOVERY_BIN/firmware.bin \ + 0x490000 $BUILD_BIN/firmware.bin \ + 0xdf0000 $BUILD_BIN/littlefs.bin diff --git a/src/ESP32React/UploadFileService.cpp b/src/ESP32React/UploadFileService.cpp index 1abc063f3..801a67ffe 100644 --- a/src/ESP32React/UploadFileService.cpp +++ b/src/ESP32React/UploadFileService.cpp @@ -8,7 +8,8 @@ UploadFileService::UploadFileService(AsyncWebServer * server, SecurityManager * : _securityManager(securityManager) , _is_firmware(false) , _is_filesystem(false) - , _is_recovery(false) { + , _is_recovery(false) + , _response_sent(false) { server->on( UPLOAD_FILE_PATH, HTTP_POST, @@ -31,9 +32,10 @@ void UploadFileService::handleUpload(AsyncWebServerRequest * request, const Stri // check details of the file, to see if its a valid bin or json file const std::size_t filesize = request->contentLength(); - _is_firmware = false; - _is_filesystem = false; - _is_recovery = false; + _is_firmware = false; + _is_filesystem = false; + _is_recovery = false; + _response_sent = false; switch (OtaUpdater::classify(filename, filesize, emsesp::System::MIN_FIRMWARE_SIZE)) { case OtaUpdater::FileType::FILESYSTEM: @@ -96,6 +98,7 @@ void UploadFileService::handleUpload(AsyncWebServerRequest * request, const Stri } request->onDisconnect([this] { handleDisconnect(); }); // success, let's make sure we end the update if the client hangs up } else { + emsesp::EMSESP::logger().err("OTA begin failed: %s", _ota.errorString()); handleError(request, 507); // failed to begin, send an error response Insufficient Storage return; } @@ -147,6 +150,19 @@ void UploadFileService::handleUpload(AsyncWebServerRequest * request, const Stri } void UploadFileService::uploadComplete(AsyncWebServerRequest * request) { + if (request->_tempObject) { + const int code = static_cast(reinterpret_cast(request->_tempObject)); + if (!_response_sent && code >= 400) { + request->send(request->beginResponse(code)); + _response_sent = true; + } + _is_firmware = false; + _is_filesystem = false; + _is_recovery = false; + emsesp::EMSESP::system_.systemStatus(emsesp::SYSTEM_STATUS::SYSTEM_STATUS_NORMAL); + return; + } + emsesp::EMSESP::logger().info("Upload successful"); // did we just complete uploading a json file? @@ -217,26 +233,25 @@ void UploadFileService::uploadComplete(AsyncWebServerRequest * request) { } void UploadFileService::handleError(AsyncWebServerRequest * request, int code) { - emsesp::EMSESP::logger().info("Upload error: %d", code); - emsesp::EMSESP::system_.uart_init(); // re-enable UART - // if we have had an error already, do nothing if (request->_tempObject) { return; } + request->_tempObject = reinterpret_cast(static_cast(code)); // stop later chunks from retrying the write - // send the error code to the client and record the error code in the temp object - AsyncWebServerResponse * response = request->beginResponse(code); - request->send(response); + emsesp::EMSESP::logger().info("Upload error: %d", code); + emsesp::EMSESP::system_.uart_init(); // re-enable UART + _ota.abort(); - // check for invalid extension and immediately kill the connection, which will throw an error - // that is caught by the web code. Unfortunately the http error code is not sent to the client on fast network connections + // 406: the client sent a file we won't accept. Close now so the browser fails fast. + // Other codes wait for uploadComplete() to send, so we don't destroy the request mid-stream. if (code == 406) { + request->send(request->beginResponse(code)); + _response_sent = true; request->client()->close(); _is_firmware = false; _is_filesystem = false; _is_recovery = false; - _ota.abort(); } // if we aborted a filesystem upload, remount LittleFS so the device keeps working @@ -249,13 +264,10 @@ void UploadFileService::handleDisconnect() { emsesp::EMSESP::logger().info("Upload finished"); emsesp::EMSESP::system_.uart_init(); // re-enable UART -#ifdef EMSESP_HAS_RECOVERY - if (_is_recovery) { - // the client hung up before the upload completed - _ota.abort(); + _ota.abort(); + if (_is_recovery || _is_firmware || _is_filesystem) { emsesp::EMSESP::system_.systemStatus(emsesp::SYSTEM_STATUS::SYSTEM_STATUS_NORMAL); } -#endif _is_firmware = false; _is_filesystem = false; diff --git a/src/ESP32React/UploadFileService.h b/src/ESP32React/UploadFileService.h index c3249b3c2..75890dd08 100644 --- a/src/ESP32React/UploadFileService.h +++ b/src/ESP32React/UploadFileService.h @@ -22,6 +22,7 @@ class UploadFileService { bool _is_firmware; bool _is_filesystem; bool _is_recovery; // firmware for the factory partition, which doesn't change the boot partition + bool _response_sent; OtaUpdater _ota; void handleUpload(AsyncWebServerRequest * request, const String & filename, size_t index, uint8_t * data, size_t len, bool final); diff --git a/src/core/system.cpp b/src/core/system.cpp index 28e1fb112..b7ef78b9e 100644 --- a/src/core/system.cpp +++ b/src/core/system.cpp @@ -573,8 +573,12 @@ void System::recovery_installed() { if (factory != nullptr) { char c[20]; snprintf(c, sizeof(c), "d_%s", factory->label); - EMSESP::nvs_.remove(factory->label); - EMSESP::nvs_.remove(c); + if (EMSESP::nvs_.isKey(factory->label)) { + EMSESP::nvs_.remove(factory->label); + } + if (EMSESP::nvs_.isKey(c)) { + EMSESP::nvs_.remove(c); + } partition_info_.erase(factory->label); // on the AsyncTCP task, same as the web handlers reading it } LOG_INFO("Recovery firmware v%s installed", recovery_version().c_str()); @@ -639,6 +643,12 @@ bool System::set_partition([[maybe_unused]] const char * partitionname) { return false; } +#ifdef EMSESP_HAS_RECOVERY + if (partition->subtype == ESP_PARTITION_SUBTYPE_APP_FACTORY) { + EMSESP::nvs_.putString(EMSESP_NVS_RECOVERY_REASON, "request"); + } +#endif + // initiate the restart EMSESP::system_.systemStatus(SYSTEM_STATUS::SYSTEM_STATUS_RESTART_REQUESTED); return true; @@ -1323,6 +1333,16 @@ void System::show_system(uuid::console::Shell & shell) { shell.printfln(" [total %d]", available.size()); // List all partitions and their version info shell.println(" Partitions:"); +#ifndef EMSESP_STANDALONE + const esp_partition_t * running = esp_ota_get_running_partition(); + const esp_partition_t * boot = esp_ota_get_boot_partition(); + const char * running_label = running ? running->label : ""; + const char * boot_label = boot ? boot->label : ""; +#else + const char * running_label = ""; + const char * boot_label = ""; +#endif + bool boot_listed = false; for (const auto & partition : partition_info_) { if (partition.second.version.empty()) { continue; // no version, empty string @@ -1334,13 +1354,42 @@ void System::show_system(uuid::console::Shell & shell) { strftime(time_string, sizeof(time_string), "%FT%T", localtime(&d)); installed = std::string(", installed on ") + time_string; } - shell.printfln(" %s: v%s (%d KB%s) %s", - partition.first.c_str(), - partition.second.version.c_str(), - partition.second.size, - installed.c_str(), - (strcmp(esp_ota_get_running_partition()->label, partition.first.c_str()) == 0) ? "** active **" : ""); + const bool is_running = strcmp(running_label, partition.first.c_str()) == 0; + const bool is_boot = strcmp(boot_label, partition.first.c_str()) == 0; + if (is_boot) { + boot_listed = true; + } + const char * mark = is_running && is_boot ? "** active, boot **" : is_running ? "** active **" : is_boot ? "** boot **" : ""; + shell.printfln(" %s: v%s (%d KB%s) %s", partition.first.c_str(), partition.second.version.c_str(), partition.second.size, installed.c_str(), mark); } +#ifndef EMSESP_STANDALONE +#ifdef EMSESP_HAS_RECOVERY + // 16MB boards: factory ("boot") holds the recovery app and is omitted from partition_info_ + { + const esp_partition_t * factory = esp_partition_find_first(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_APP_FACTORY, nullptr); + esp_app_desc_t desc; + if (OtaUpdater::isRecoveryPartition(factory, &desc)) { + const bool is_running = running && factory == running; + const bool is_boot = boot && factory == boot; + if (is_boot) { + boot_listed = true; + } + int size_kb = 0; + esp_image_metadata_t meta = {}; + esp_partition_pos_t part_pos = {.offset = factory->address, .size = factory->size}; + if (esp_image_verify(ESP_IMAGE_VERIFY_SILENT, &part_pos, &meta) == ESP_OK) { + size_kb = static_cast(meta.image_len / 1024); + } + const char * mark = is_running && is_boot ? "** active, boot **" : is_running ? "** active **" : is_boot ? "** boot **" : ""; + shell.printfln(" %s: Recovery v%s (%d KB) %s", factory->label, desc.version, size_kb, mark); + } + } +#endif + // recovery/factory is omitted from partition_info_, still show it if it's the boot target + if (!boot_listed && boot_label[0] != '\0') { + shell.printfln(" %s: ** boot **", boot_label); + } +#endif // List all NVS values #ifndef EMSESP_STANDALONE shell.println(" NVS values:"); diff --git a/src/core/system.h b/src/core/system.h index 0793ba7cb..7eb43a802 100644 --- a/src/core/system.h +++ b/src/core/system.h @@ -375,7 +375,8 @@ class System { static constexpr uint8_t HEALTHCHECK_NO_NETWORK = (1 << 1); // 2 static constexpr uint8_t HEALTHCHECK_RESET = (1 << 7); // 128 - // smallest .bin accepted as a firmware image, for both URL and web uploads - 1.6 MB + // smallest .bin accepted as a main firmware image, for both URL and web uploads - 1.6 MB + // recovery firmware uses OtaUpdater::MIN_RECOVERY_FIRMWARE_SIZE when the filename contains "recovery" static constexpr size_t MIN_FIRMWARE_SIZE = 1677721; private: diff --git a/src/emsesp_version.h b/src/emsesp_version.h index fc1c2c9d9..845c6c1ad 100644 --- a/src/emsesp_version.h +++ b/src/emsesp_version.h @@ -1 +1 @@ -#define EMSESP_APP_VERSION "3.9.0-dev.16" +#define EMSESP_APP_VERSION "3.9.0-dev.17" diff --git a/src/web/WebStatusService.cpp b/src/web/WebStatusService.cpp index fbc9f4874..107cca941 100644 --- a/src/web/WebStatusService.cpp +++ b/src/web/WebStatusService.cpp @@ -134,13 +134,12 @@ void WebStatusService::systemStatus(AsyncWebServerRequest * request) { root["temperature"] = (int)Helpers::transformNumFloat(EMSESP::system_.temperature(), 0, EMSESP::system_.fahrenheit() ? 2 : 0); // only 2 decimal places #endif - // check for a factory partition first + // factory partition is only on 16MB boards; recovery_version is empty until a recovery .bin is installed const esp_partition_t * partition = esp_partition_find_first(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_APP_FACTORY, nullptr); - root["has_loader"] = partition != NULL && partition != esp_ota_get_running_partition(); if (partition != NULL) { - root["recovery_version"] = EMSESP::system_.recovery_version(); // empty if not installed + root["recovery_version"] = EMSESP::system_.recovery_version(); } - partition = esp_ota_get_next_update_partition(nullptr); + partition = esp_ota_get_next_update_partition(nullptr); if (partition) { uint64_t buffer; esp_partition_read(partition, 0, &buffer, 8); From 92e2a4b6c344defe64cff52c4ca43dfec9fa879b Mon Sep 17 00:00:00 2001 From: proddy Date: Thu, 8 Oct 2026 00:01:02 +0200 Subject: [PATCH 03/13] build recovery --- platformio.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platformio.ini b/platformio.ini index 4206ec789..e07f18028 100644 --- a/platformio.ini +++ b/platformio.ini @@ -9,7 +9,7 @@ [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 From 7851ae86c7bcba396950f001c9df711d5bee3a7a Mon Sep 17 00:00:00 2001 From: proddy Date: Thu, 8 Oct 2026 13:37:56 +0200 Subject: [PATCH 04/13] pkg update --- interface/package.json | 2 +- interface/pnpm-lock.yaml | 204 +++++++++++++++++++-------------------- mock-api/package.json | 2 +- mock-api/pnpm-lock.yaml | 130 ++++++++++++------------- mock-api/restServer.ts | 36 +++---- project-words.txt | 1 + 6 files changed, 188 insertions(+), 187 deletions(-) diff --git a/interface/package.json b/interface/package.json index 475b26bc0..58d8d8f4c 100644 --- a/interface/package.json +++ b/interface/package.json @@ -60,5 +60,5 @@ "vite": "^8.3.3", "vite-plugin-imagemin": "^0.6.1" }, - "packageManager": "pnpm@12.9.1" + "packageManager": "pnpm@12.10.1" } diff --git a/interface/pnpm-lock.yaml b/interface/pnpm-lock.yaml index 94b15bac3..363cf360f 100644 --- a/interface/pnpm-lock.yaml +++ b/interface/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.9.1 - version: 12.9.1 + specifier: 12.10.1 + version: 12.10.1 packages: - '@pnpm/exe.android-arm64@12.9.1': - resolution: {integrity: sha512-Hgm1XdqMTfBC8PdGLL+NjZWv4xyi7D6XNrN9fb24XGYlyTBmUtdCefKnCLmHujShRD8BEO1zqM30SNcKBDLysw==} + '@pnpm/exe.android-arm64@12.10.1': + resolution: {integrity: sha512-CVTlHPwNvadQCJD9bGSMzWIhFX8QpjtqC2c27dhdnn/9iKzEjZ3qL62o3gHpKbIuo6qn4WxTKRoaokgNdReBig==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.9.1': - resolution: {integrity: sha512-WDGCe6jZRVZP3RC/q6hOkZylkPkIQIi1/GcrR/d/YzbihhAHXCczrk+UYnV5TgbsEnAYO7Cx+2rX3nfhHvy24w==} + '@pnpm/exe.android-x64@12.10.1': + resolution: {integrity: sha512-NFiYLIaodY7da0XIebwcU3JoP7IK/lzF7zgKSSP+9H+QcFnQ1GDfBkuBKCYJzZ+Xu0WH54neIPLBBCaOjaaauA==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.9.1': - resolution: {integrity: sha512-+7iiEEBgYN9szPWUnAuN6eOVMTI6Hrki5bcuC9n9xl9tTnT53Tpxpo45Hml+W4IrAU0d+Tmd0sPnVIbX59PD6w==} + '@pnpm/exe.darwin-arm64@12.10.1': + resolution: {integrity: sha512-0O8tdQ8ijlPJudXspLMTcitMkdBEz6uUflazL5uQE0WMs85oQIp0U+5hfEktMFB64QqEhjqlvvujTwbzdr9oMg==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.9.1': - resolution: {integrity: sha512-SK7TxJiR12GbJZGffRFHItkc+Mx+99v6xMMJ9/X//8NwWV1wo17a2bwfGI8qZpl17YwsrmPEFOOqhzE1GIlrtQ==} + '@pnpm/exe.darwin-x64@12.10.1': + resolution: {integrity: sha512-ud+1/uZOKEo8P/AsPynOJc/LNwfI0j6WwCElibj71rU9l4nIBnsSn+wWTIPZPkZ4BBSH5+82azSnAARMyJ1lNA==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.9.1': - resolution: {integrity: sha512-4AjLk6uvTs2iO6wMit2hcxJ2M32xb7OV5SNFk+w6G8/NzcjcMB7ENEpyeXyLiP0kcnp8uaCGUct2NfrLCJ35eQ==} + '@pnpm/exe.freebsd-x64@12.10.1': + resolution: {integrity: sha512-mj9PE5rf8uAfQQ2K66HSATMSut+lNiB4eSqFd5+azFVCMnxNSy/32xpZQJT21PhnN3Y4qbK+iKHe5nWAxSuRhw==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.9.1': - resolution: {integrity: sha512-dZL2ES1ckDWM9v+EtxoOklZ0WNdtayooi9iLiDUubopudX3zXPNR2mlrxBRgjbDgECOndQen2SmyxL5eZiBuKw==} + '@pnpm/exe.linux-arm64-musl@12.10.1': + resolution: {integrity: sha512-xba+00wZJ8Um6rBJJjWl88uJj0tydB8dtomvjlCLvi0uL32vpa9CRP8Vv26FIXvbiZ1bo2RCzYt/eChxJVV52w==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.9.1': - resolution: {integrity: sha512-cx+uF6xqwEtm7B8NV+1ve0xBg9gcuzQZ5R+0WgOWvKG/h978UljS6BgWs1ipIGuGgUdeVhm3PKmBltXIcczLXA==} + '@pnpm/exe.linux-arm64@12.10.1': + resolution: {integrity: sha512-oBdMp69ScC9UnniG4qZAQVr2fBDSXjm4yk/72vkEVyrlwS23yMxcRQl7Qdo3sgXPAygY6TWnPRKm4rXzjoyW7w==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.9.1': - resolution: {integrity: sha512-MpjPsy4XR17zAtLziQZfsN+wveWa/bUJggZtwfo3+SuyRmXnSvVkFD4+v8/3RTWyYaM6mwLUTjZDJ+g3urL+ew==} + '@pnpm/exe.linux-ppc64@12.10.1': + resolution: {integrity: sha512-Yyb3wu09h3c7mLme270uHFiVaAIUHC411Ru9LIU2BKUIstrGlXa9OqqVWoXaRgYRdFdOFknkwG7GNn6ygwLbnw==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.9.1': - resolution: {integrity: sha512-Oo67TKqqH+Bno+L6X7Xyorwp1QG82zKIkL0vbo1x2kcPTDiyPbdM+d6lIs4+hzeB1KHT5FDoGzZj2dad7Ft+eg==} + '@pnpm/exe.linux-riscv64@12.10.1': + resolution: {integrity: sha512-PdkeUFFkybknnGkWP0H22eMpIiS4OZXL5BcQzF4lh+vmrW3IaC2HOkbANKgot/PKO0Kl77pzA/6PKFxgbgfXIQ==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.9.1': - resolution: {integrity: sha512-QAD5FRCdW36HfD58BV7DoDm6z8yv+GgSqcUV1+ERwYjpVQuG6ZTpf5o0x3KDas8eTLIW7MLYVdDdlAurWeArEg==} + '@pnpm/exe.linux-s390x@12.10.1': + resolution: {integrity: sha512-1okKuHKJwJPAbofDxfhZWAUxEWtee4ZSf7Pah8kcvocaJkf2WxFJ5KVTiNIvDml5GLidDQsgf7Pcf/WHbCak7Q==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.9.1': - resolution: {integrity: sha512-mQDhBsGqcTkrLQM4q5igmRFcr6EAl4UM/FGawY+0CWGcqPpUmYUvDy9LHy80xxkHIWVJ886gvvso2XOjfav1Jw==} + '@pnpm/exe.linux-x64-musl@12.10.1': + resolution: {integrity: sha512-e6IqzVNkPa7srFKJKnY23uWJ+qz0LaRWiTUo/sHfFXRRMRzB5uNOr6qWWip/jqhdGwU91n9J7LrEYZ6e0tRczw==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.9.1': - resolution: {integrity: sha512-+M5N74hM4fc87AAckxx/UIW+dUnWf2eko+HchJs/NZUxehLhekNyMI/GNaNXlvd+kWR+97XXBD51XRkkNiUtrA==} + '@pnpm/exe.linux-x64@12.10.1': + resolution: {integrity: sha512-ZByV0DRgWzGaDSjT5ddRBASdwz+r/qPMYh7o6RGmPQjtRGNFbSl94xY2DcAM8mqCOZJUUDtAXQ+wpBXcdpKlkg==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.9.1': - resolution: {integrity: sha512-mI28C2wxobrDi6x1OYhBRyG10hnDq/4sEIyy5RzoX6LB4kLuv6K/d25HOpMwlW20cuM4M72k9NZpOdcmUxMbEA==} + '@pnpm/exe.win32-arm64@12.10.1': + resolution: {integrity: sha512-I7QZHcjioG7orFeCRUFUHByJ1G0zFPnAlaGGyrUvb6MGkv5oeNwNPl7t+Gp+7zHfnMcgLGJ1a+Wfim0IDw3lOw==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.9.1': - resolution: {integrity: sha512-SWoDMT0oD7NrDSIoPpkKD09D9fAQKNjwFZ3lezlCUhAfXxMv70zsfrNT2/P+GoCK2kefXF9OysUZkH1l5vvvfA==} + '@pnpm/exe.win32-x64@12.10.1': + resolution: {integrity: sha512-4WJFdp58EKlCj0zLobrXU0vpszSZIr4X/3y/uNbAQqGW0zE+VdruZB0gFc3L2kzsUzVwTYbaCn+XEhnCYSAlTg==} cpu: [x64] os: [win32] - pnpm@12.9.1: - resolution: {integrity: sha512-BrBV//XNINwSeB3hc87TMQzglRvy7GICEaFgRdVETVyTpmMhB2TvKaZTphBFm6A2jw50+E2AxUcjz5Wq57wNbQ==} + pnpm@12.10.1: + resolution: {integrity: sha512-ukCjfrGjcNYP6oxM8dNkwTvMzvUY6YrSvO6VT1OdONo3RY5YJho/pV4GUjRQ6g//+fSXsTW+QfLBXBZCusrObA==} engines: {node: '>=18.*'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.9.1': + '@pnpm/exe.android-arm64@12.10.1': optional: true - '@pnpm/exe.android-x64@12.9.1': + '@pnpm/exe.android-x64@12.10.1': optional: true - '@pnpm/exe.darwin-arm64@12.9.1': + '@pnpm/exe.darwin-arm64@12.10.1': optional: true - '@pnpm/exe.darwin-x64@12.9.1': + '@pnpm/exe.darwin-x64@12.10.1': optional: true - '@pnpm/exe.freebsd-x64@12.9.1': + '@pnpm/exe.freebsd-x64@12.10.1': optional: true - '@pnpm/exe.linux-arm64-musl@12.9.1': + '@pnpm/exe.linux-arm64-musl@12.10.1': optional: true - '@pnpm/exe.linux-arm64@12.9.1': + '@pnpm/exe.linux-arm64@12.10.1': optional: true - '@pnpm/exe.linux-ppc64@12.9.1': + '@pnpm/exe.linux-ppc64@12.10.1': optional: true - '@pnpm/exe.linux-riscv64@12.9.1': + '@pnpm/exe.linux-riscv64@12.10.1': optional: true - '@pnpm/exe.linux-s390x@12.9.1': + '@pnpm/exe.linux-s390x@12.10.1': optional: true - '@pnpm/exe.linux-x64-musl@12.9.1': + '@pnpm/exe.linux-x64-musl@12.10.1': optional: true - '@pnpm/exe.linux-x64@12.9.1': + '@pnpm/exe.linux-x64@12.10.1': optional: true - '@pnpm/exe.win32-arm64@12.9.1': + '@pnpm/exe.win32-arm64@12.10.1': optional: true - '@pnpm/exe.win32-x64@12.9.1': + '@pnpm/exe.win32-x64@12.10.1': optional: true - pnpm@12.9.1: + pnpm@12.10.1: optionalDependencies: - '@pnpm/exe.android-arm64': 12.9.1 - '@pnpm/exe.android-x64': 12.9.1 - '@pnpm/exe.darwin-arm64': 12.9.1 - '@pnpm/exe.darwin-x64': 12.9.1 - '@pnpm/exe.freebsd-x64': 12.9.1 - '@pnpm/exe.linux-arm64': 12.9.1 - '@pnpm/exe.linux-arm64-musl': 12.9.1 - '@pnpm/exe.linux-ppc64': 12.9.1 - '@pnpm/exe.linux-riscv64': 12.9.1 - '@pnpm/exe.linux-s390x': 12.9.1 - '@pnpm/exe.linux-x64': 12.9.1 - '@pnpm/exe.linux-x64-musl': 12.9.1 - '@pnpm/exe.win32-arm64': 12.9.1 - '@pnpm/exe.win32-x64': 12.9.1 + '@pnpm/exe.android-arm64': 12.10.1 + '@pnpm/exe.android-x64': 12.10.1 + '@pnpm/exe.darwin-arm64': 12.10.1 + '@pnpm/exe.darwin-x64': 12.10.1 + '@pnpm/exe.freebsd-x64': 12.10.1 + '@pnpm/exe.linux-arm64': 12.10.1 + '@pnpm/exe.linux-arm64-musl': 12.10.1 + '@pnpm/exe.linux-ppc64': 12.10.1 + '@pnpm/exe.linux-riscv64': 12.10.1 + '@pnpm/exe.linux-s390x': 12.10.1 + '@pnpm/exe.linux-x64': 12.10.1 + '@pnpm/exe.linux-x64-musl': 12.10.1 + '@pnpm/exe.win32-arm64': 12.10.1 + '@pnpm/exe.win32-x64': 12.10.1 --- lockfileVersion: '9.0' @@ -328,8 +328,8 @@ packages: resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} engines: {node: '>=6.9.0'} - '@babel/helpers@7.29.7': - resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + '@babel/helpers@7.29.10': + resolution: {integrity: sha512-EMCx3AGZ3WfAl6ht7AyQoPm2Lh+BGYEMdc4Q6Hf7uSCDOZFuLq44qNwL+iaim8VlyYhRAo4X43+weadK8ZB4Yg==} engines: {node: '>=6.9.0'} '@babel/parser@7.29.9': @@ -355,16 +355,16 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/runtime@7.29.7': - resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + '@babel/runtime@7.29.10': + resolution: {integrity: sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==} engines: {node: '>=6.9.0'} '@babel/template@7.29.7': 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': @@ -1106,8 +1106,8 @@ packages: resolution: {integrity: sha512-DLIsRzJVBQu72meAKPkWQOLcujdXT32hwdfnkI1frSiSRMK1MofjKHf+MEx0SB6fjEFXL8fBDv1dKymBlOp4Qw==} engines: {node: '>=0.10.0'} - caniuse-lite@1.0.30001814: - resolution: {integrity: sha512-/Uaf1lAzr59XcMpW0o96WoEfr+VXK2OX4U9AgFoiSHsVJ4HppnIFUjtYzsyDH2+tgANaQb2/oxYGwCPapN1FpA==} + caniuse-lite@1.0.30001815: + resolution: {integrity: sha512-uDybUEyfgbyJJ93J1vikTylBX31AkdVYeVPeffMQpf8XN7hSfr0oZOn4VFOIMNvtU5+rHvC5Udg2TgtJKHm23Q==} caw@2.0.1: resolution: {integrity: sha512-Cg8/ZSBEa8ZVY9HspcGUYaK63d/bN7rqS3CYCzEGUxuYv6UlmcjzDUz2fCFFHyTvUW5Pk0I+3hkA3iXlIj6guA==} @@ -1338,8 +1338,8 @@ packages: duplexer3@0.1.5: resolution: {integrity: sha512-1A8za6ws41LQgv9HrE/66jyC5yuSjQ3L/KOpFtoBilsAK2iA2wuS5rTt1OCzIvtS2V7nVmedsUU+DGRcjBmOYA==} - electron-to-chromium@1.5.445: - resolution: {integrity: sha512-iX7o1nF+qNb3Bv5mLYgqfgF9Znh9e/8MKXdmbb8Gv2Q47OKIzTwEPD1oda1HrgEwYatTLJj0wQjHvIFt8Db53Q==} + electron-to-chromium@1.5.449: + resolution: {integrity: sha512-dKeYk/3SFQz+nXi5whp3OuJzeWABEG4zCtkWJ9+Vby1E1iFM2y0gElRtQvNXRPJTsj8WAF4HMP7hvP790QtJ9g==} emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -2665,8 +2665,8 @@ packages: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} - resolve@1.22.12: - resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} + resolve@1.22.13: + resolution: {integrity: sha512-Dj/cW9zBV2aRj6XzCxuGIs3ESZKv02jixne3q6jFZYZ3/Tg09gHHviSMDL4kObh9pqSahdRHEnDoaP2gn8YLhA==} engines: {node: '>= 0.4'} hasBin: true @@ -3191,10 +3191,10 @@ snapshots: '@babel/generator': 7.29.8 '@babel/helper-compilation-targets': 7.29.7 '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) - '@babel/helpers': 7.29.7 + '@babel/helpers': 7.29.10 '@babel/parser': 7.29.9 '@babel/template': 7.29.7 - '@babel/traverse': 7.29.8(supports-color@10.2.2) + '@babel/traverse': 7.29.10(supports-color@10.2.2) '@babel/types': 7.29.8 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 @@ -3229,7 +3229,7 @@ snapshots: '@babel/helper-module-imports@7.29.7(supports-color@10.2.2)': dependencies: - '@babel/traverse': 7.29.8(supports-color@10.2.2) + '@babel/traverse': 7.29.10(supports-color@10.2.2) '@babel/types': 7.29.8 transitivePeerDependencies: - supports-color @@ -3239,7 +3239,7 @@ snapshots: '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-module-imports': 7.29.7(supports-color@10.2.2) '@babel/helper-validator-identifier': 7.29.7 - '@babel/traverse': 7.29.8(supports-color@10.2.2) + '@babel/traverse': 7.29.10(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -3251,7 +3251,7 @@ snapshots: '@babel/helper-validator-option@7.29.7': {} - '@babel/helpers@7.29.7': + '@babel/helpers@7.29.10': dependencies: '@babel/template': 7.29.7 '@babel/types': 7.29.8 @@ -3283,7 +3283,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/runtime@7.29.7': {} + '@babel/runtime@7.29.10': {} '@babel/template@7.29.7': dependencies: @@ -3291,7 +3291,7 @@ snapshots: '@babel/parser': 7.29.9 '@babel/types': 7.29.8 - '@babel/traverse@7.29.8(supports-color@10.2.2)': + '@babel/traverse@7.29.10(supports-color@10.2.2)': dependencies: '@babel/code-frame': 7.29.7 '@babel/generator': 7.29.8 @@ -3323,7 +3323,7 @@ snapshots: '@emotion/babel-plugin@11.13.5(supports-color@10.2.2)': dependencies: '@babel/helper-module-imports': 7.29.7(supports-color@10.2.2) - '@babel/runtime': 7.29.7 + '@babel/runtime': 7.29.10 '@emotion/hash': 0.9.2 '@emotion/memoize': 0.9.0 '@emotion/serialize': 1.3.3 @@ -3354,7 +3354,7 @@ snapshots: '@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2)': dependencies: - '@babel/runtime': 7.29.7 + '@babel/runtime': 7.29.10 '@emotion/babel-plugin': 11.13.5(supports-color@10.2.2) '@emotion/cache': 11.14.0 '@emotion/serialize': 1.3.3 @@ -3380,7 +3380,7 @@ snapshots: '@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2)': dependencies: - '@babel/runtime': 7.29.7 + '@babel/runtime': 7.29.10 '@emotion/babel-plugin': 11.13.5(supports-color@10.2.2) '@emotion/is-prop-valid': 1.4.0 '@emotion/react': 11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2) @@ -3492,7 +3492,7 @@ snapshots: '@mui/icons-material@9.4.0(@mui/material@9.4.0(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(@types/react@19.3.0)(react@19.3.0)': dependencies: - '@babel/runtime': 7.29.7 + '@babel/runtime': 7.29.10 '@mui/material': 9.4.0(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) react: 19.3.0 optionalDependencies: @@ -3500,7 +3500,7 @@ snapshots: '@mui/material@9.4.0(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: - '@babel/runtime': 7.29.7 + '@babel/runtime': 7.29.10 '@mui/core-downloads-tracker': 9.4.0 '@mui/system': 9.4.0(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@types/react@19.3.0)(react@19.3.0) '@mui/types': 9.4.0(@types/react@19.3.0) @@ -3521,7 +3521,7 @@ snapshots: '@mui/private-theming@9.4.0(@types/react@19.3.0)(react@19.3.0)': dependencies: - '@babel/runtime': 7.29.7 + '@babel/runtime': 7.29.10 '@mui/utils': 9.4.0(@types/react@19.3.0)(react@19.3.0) prop-types: 15.8.1 react: 19.3.0 @@ -3530,7 +3530,7 @@ snapshots: '@mui/styled-engine@9.4.0(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(react@19.3.0)': dependencies: - '@babel/runtime': 7.29.7 + '@babel/runtime': 7.29.10 '@emotion/cache': 11.14.0 '@emotion/serialize': 1.3.3 '@emotion/sheet': 1.4.0 @@ -3543,7 +3543,7 @@ snapshots: '@mui/system@9.4.0(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@types/react@19.3.0)(react@19.3.0)': dependencies: - '@babel/runtime': 7.29.7 + '@babel/runtime': 7.29.10 '@mui/private-theming': 9.4.0(@types/react@19.3.0)(react@19.3.0) '@mui/styled-engine': 9.4.0(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(react@19.3.0) '@mui/types': 9.4.0(@types/react@19.3.0) @@ -3559,13 +3559,13 @@ snapshots: '@mui/types@9.4.0(@types/react@19.3.0)': dependencies: - '@babel/runtime': 7.29.7 + '@babel/runtime': 7.29.10 optionalDependencies: '@types/react': 19.3.0 '@mui/utils@9.4.0(@types/react@19.3.0)(react@19.3.0)': dependencies: - '@babel/runtime': 7.29.7 + '@babel/runtime': 7.29.10 '@mui/types': 9.4.0(@types/react@19.3.0) '@types/prop-types': 15.7.15 clsx: 2.1.1 @@ -3707,7 +3707,7 @@ snapshots: dependencies: '@babel/generator': 7.29.8 '@babel/parser': 7.29.9 - '@babel/traverse': 7.29.8(supports-color@10.2.2) + '@babel/traverse': 7.29.10(supports-color@10.2.2) '@babel/types': 7.29.8 javascript-natural-sort: 0.7.1 lodash-es: 4.18.1 @@ -3928,9 +3928,9 @@ snapshots: babel-plugin-macros@3.1.0: dependencies: - '@babel/runtime': 7.29.7 + '@babel/runtime': 7.29.10 cosmiconfig: 7.1.0 - resolve: 1.22.12 + resolve: 1.22.13 babel-plugin-transform-hook-names@1.0.2(@babel/core@7.29.7(supports-color@10.2.2)): dependencies: @@ -4004,8 +4004,8 @@ snapshots: browserslist@4.29.3: dependencies: baseline-browser-mapping: 2.11.27 - caniuse-lite: 1.0.30001814 - electron-to-chromium: 1.5.445 + caniuse-lite: 1.0.30001815 + electron-to-chromium: 1.5.449 node-releases: 2.0.57 update-browserslist-db: 1.3.3(browserslist@4.29.3) @@ -4075,7 +4075,7 @@ snapshots: camelcase@2.1.1: {} - caniuse-lite@1.0.30001814: {} + caniuse-lite@1.0.30001815: {} caw@2.0.1: dependencies: @@ -4296,7 +4296,7 @@ snapshots: dom-helpers@5.2.1: dependencies: - '@babel/runtime': 7.29.7 + '@babel/runtime': 7.29.10 csstype: 3.2.3 dom-serializer@1.4.1: @@ -4370,7 +4370,7 @@ snapshots: duplexer3@0.1.5: {} - electron-to-chromium@1.5.445: {} + electron-to-chromium@1.5.449: {} emoji-regex@10.6.0: {} @@ -5312,7 +5312,7 @@ snapshots: normalize-package-data@2.5.0: dependencies: hosted-git-info: 2.8.9 - resolve: 1.22.12 + resolve: 1.22.13 semver: 5.7.2 validate-npm-package-license: 3.0.4 @@ -5567,7 +5567,7 @@ snapshots: react-transition-group@4.4.5(react-dom@19.3.0(react@19.3.0))(react@19.3.0): dependencies: - '@babel/runtime': 7.29.7 + '@babel/runtime': 7.29.10 dom-helpers: 5.2.1 loose-envify: 1.4.0 prop-types: 15.8.1 @@ -5581,7 +5581,7 @@ snapshots: react-window@1.8.11(react-dom@19.3.0(react@19.3.0))(react@19.3.0): dependencies: - '@babel/runtime': 7.29.7 + '@babel/runtime': 7.29.10 memoize-one: 5.2.1 react: 19.3.0 react-dom: 19.3.0(react@19.3.0) @@ -5622,7 +5622,7 @@ snapshots: resolve-from@4.0.0: {} - resolve@1.22.12: + resolve@1.22.13: dependencies: es-errors: 1.3.0 is-core-module: 2.17.0 diff --git a/mock-api/package.json b/mock-api/package.json index 799c6f2d1..883a6c0ca 100644 --- a/mock-api/package.json +++ b/mock-api/package.json @@ -17,5 +17,5 @@ "@trivago/prettier-plugin-sort-imports": "^6.0.2", "prettier": "^3.9.9" }, - "packageManager": "pnpm@12.9.1" + "packageManager": "pnpm@12.10.1" } diff --git a/mock-api/pnpm-lock.yaml b/mock-api/pnpm-lock.yaml index b431bd0a6..acb1928ed 100644 --- a/mock-api/pnpm-lock.yaml +++ b/mock-api/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.9.1 - version: 12.9.1 + specifier: 12.10.1 + version: 12.10.1 packages: - '@pnpm/exe.android-arm64@12.9.1': - resolution: {integrity: sha512-Hgm1XdqMTfBC8PdGLL+NjZWv4xyi7D6XNrN9fb24XGYlyTBmUtdCefKnCLmHujShRD8BEO1zqM30SNcKBDLysw==} + '@pnpm/exe.android-arm64@12.10.1': + resolution: {integrity: sha512-CVTlHPwNvadQCJD9bGSMzWIhFX8QpjtqC2c27dhdnn/9iKzEjZ3qL62o3gHpKbIuo6qn4WxTKRoaokgNdReBig==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.9.1': - resolution: {integrity: sha512-WDGCe6jZRVZP3RC/q6hOkZylkPkIQIi1/GcrR/d/YzbihhAHXCczrk+UYnV5TgbsEnAYO7Cx+2rX3nfhHvy24w==} + '@pnpm/exe.android-x64@12.10.1': + resolution: {integrity: sha512-NFiYLIaodY7da0XIebwcU3JoP7IK/lzF7zgKSSP+9H+QcFnQ1GDfBkuBKCYJzZ+Xu0WH54neIPLBBCaOjaaauA==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.9.1': - resolution: {integrity: sha512-+7iiEEBgYN9szPWUnAuN6eOVMTI6Hrki5bcuC9n9xl9tTnT53Tpxpo45Hml+W4IrAU0d+Tmd0sPnVIbX59PD6w==} + '@pnpm/exe.darwin-arm64@12.10.1': + resolution: {integrity: sha512-0O8tdQ8ijlPJudXspLMTcitMkdBEz6uUflazL5uQE0WMs85oQIp0U+5hfEktMFB64QqEhjqlvvujTwbzdr9oMg==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.9.1': - resolution: {integrity: sha512-SK7TxJiR12GbJZGffRFHItkc+Mx+99v6xMMJ9/X//8NwWV1wo17a2bwfGI8qZpl17YwsrmPEFOOqhzE1GIlrtQ==} + '@pnpm/exe.darwin-x64@12.10.1': + resolution: {integrity: sha512-ud+1/uZOKEo8P/AsPynOJc/LNwfI0j6WwCElibj71rU9l4nIBnsSn+wWTIPZPkZ4BBSH5+82azSnAARMyJ1lNA==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.9.1': - resolution: {integrity: sha512-4AjLk6uvTs2iO6wMit2hcxJ2M32xb7OV5SNFk+w6G8/NzcjcMB7ENEpyeXyLiP0kcnp8uaCGUct2NfrLCJ35eQ==} + '@pnpm/exe.freebsd-x64@12.10.1': + resolution: {integrity: sha512-mj9PE5rf8uAfQQ2K66HSATMSut+lNiB4eSqFd5+azFVCMnxNSy/32xpZQJT21PhnN3Y4qbK+iKHe5nWAxSuRhw==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.9.1': - resolution: {integrity: sha512-dZL2ES1ckDWM9v+EtxoOklZ0WNdtayooi9iLiDUubopudX3zXPNR2mlrxBRgjbDgECOndQen2SmyxL5eZiBuKw==} + '@pnpm/exe.linux-arm64-musl@12.10.1': + resolution: {integrity: sha512-xba+00wZJ8Um6rBJJjWl88uJj0tydB8dtomvjlCLvi0uL32vpa9CRP8Vv26FIXvbiZ1bo2RCzYt/eChxJVV52w==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.9.1': - resolution: {integrity: sha512-cx+uF6xqwEtm7B8NV+1ve0xBg9gcuzQZ5R+0WgOWvKG/h978UljS6BgWs1ipIGuGgUdeVhm3PKmBltXIcczLXA==} + '@pnpm/exe.linux-arm64@12.10.1': + resolution: {integrity: sha512-oBdMp69ScC9UnniG4qZAQVr2fBDSXjm4yk/72vkEVyrlwS23yMxcRQl7Qdo3sgXPAygY6TWnPRKm4rXzjoyW7w==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.9.1': - resolution: {integrity: sha512-MpjPsy4XR17zAtLziQZfsN+wveWa/bUJggZtwfo3+SuyRmXnSvVkFD4+v8/3RTWyYaM6mwLUTjZDJ+g3urL+ew==} + '@pnpm/exe.linux-ppc64@12.10.1': + resolution: {integrity: sha512-Yyb3wu09h3c7mLme270uHFiVaAIUHC411Ru9LIU2BKUIstrGlXa9OqqVWoXaRgYRdFdOFknkwG7GNn6ygwLbnw==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.9.1': - resolution: {integrity: sha512-Oo67TKqqH+Bno+L6X7Xyorwp1QG82zKIkL0vbo1x2kcPTDiyPbdM+d6lIs4+hzeB1KHT5FDoGzZj2dad7Ft+eg==} + '@pnpm/exe.linux-riscv64@12.10.1': + resolution: {integrity: sha512-PdkeUFFkybknnGkWP0H22eMpIiS4OZXL5BcQzF4lh+vmrW3IaC2HOkbANKgot/PKO0Kl77pzA/6PKFxgbgfXIQ==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.9.1': - resolution: {integrity: sha512-QAD5FRCdW36HfD58BV7DoDm6z8yv+GgSqcUV1+ERwYjpVQuG6ZTpf5o0x3KDas8eTLIW7MLYVdDdlAurWeArEg==} + '@pnpm/exe.linux-s390x@12.10.1': + resolution: {integrity: sha512-1okKuHKJwJPAbofDxfhZWAUxEWtee4ZSf7Pah8kcvocaJkf2WxFJ5KVTiNIvDml5GLidDQsgf7Pcf/WHbCak7Q==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.9.1': - resolution: {integrity: sha512-mQDhBsGqcTkrLQM4q5igmRFcr6EAl4UM/FGawY+0CWGcqPpUmYUvDy9LHy80xxkHIWVJ886gvvso2XOjfav1Jw==} + '@pnpm/exe.linux-x64-musl@12.10.1': + resolution: {integrity: sha512-e6IqzVNkPa7srFKJKnY23uWJ+qz0LaRWiTUo/sHfFXRRMRzB5uNOr6qWWip/jqhdGwU91n9J7LrEYZ6e0tRczw==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.9.1': - resolution: {integrity: sha512-+M5N74hM4fc87AAckxx/UIW+dUnWf2eko+HchJs/NZUxehLhekNyMI/GNaNXlvd+kWR+97XXBD51XRkkNiUtrA==} + '@pnpm/exe.linux-x64@12.10.1': + resolution: {integrity: sha512-ZByV0DRgWzGaDSjT5ddRBASdwz+r/qPMYh7o6RGmPQjtRGNFbSl94xY2DcAM8mqCOZJUUDtAXQ+wpBXcdpKlkg==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.9.1': - resolution: {integrity: sha512-mI28C2wxobrDi6x1OYhBRyG10hnDq/4sEIyy5RzoX6LB4kLuv6K/d25HOpMwlW20cuM4M72k9NZpOdcmUxMbEA==} + '@pnpm/exe.win32-arm64@12.10.1': + resolution: {integrity: sha512-I7QZHcjioG7orFeCRUFUHByJ1G0zFPnAlaGGyrUvb6MGkv5oeNwNPl7t+Gp+7zHfnMcgLGJ1a+Wfim0IDw3lOw==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.9.1': - resolution: {integrity: sha512-SWoDMT0oD7NrDSIoPpkKD09D9fAQKNjwFZ3lezlCUhAfXxMv70zsfrNT2/P+GoCK2kefXF9OysUZkH1l5vvvfA==} + '@pnpm/exe.win32-x64@12.10.1': + resolution: {integrity: sha512-4WJFdp58EKlCj0zLobrXU0vpszSZIr4X/3y/uNbAQqGW0zE+VdruZB0gFc3L2kzsUzVwTYbaCn+XEhnCYSAlTg==} cpu: [x64] os: [win32] - pnpm@12.9.1: - resolution: {integrity: sha512-BrBV//XNINwSeB3hc87TMQzglRvy7GICEaFgRdVETVyTpmMhB2TvKaZTphBFm6A2jw50+E2AxUcjz5Wq57wNbQ==} + pnpm@12.10.1: + resolution: {integrity: sha512-ukCjfrGjcNYP6oxM8dNkwTvMzvUY6YrSvO6VT1OdONo3RY5YJho/pV4GUjRQ6g//+fSXsTW+QfLBXBZCusrObA==} engines: {node: '>=18.*'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.9.1': + '@pnpm/exe.android-arm64@12.10.1': optional: true - '@pnpm/exe.android-x64@12.9.1': + '@pnpm/exe.android-x64@12.10.1': optional: true - '@pnpm/exe.darwin-arm64@12.9.1': + '@pnpm/exe.darwin-arm64@12.10.1': optional: true - '@pnpm/exe.darwin-x64@12.9.1': + '@pnpm/exe.darwin-x64@12.10.1': optional: true - '@pnpm/exe.freebsd-x64@12.9.1': + '@pnpm/exe.freebsd-x64@12.10.1': optional: true - '@pnpm/exe.linux-arm64-musl@12.9.1': + '@pnpm/exe.linux-arm64-musl@12.10.1': optional: true - '@pnpm/exe.linux-arm64@12.9.1': + '@pnpm/exe.linux-arm64@12.10.1': optional: true - '@pnpm/exe.linux-ppc64@12.9.1': + '@pnpm/exe.linux-ppc64@12.10.1': optional: true - '@pnpm/exe.linux-riscv64@12.9.1': + '@pnpm/exe.linux-riscv64@12.10.1': optional: true - '@pnpm/exe.linux-s390x@12.9.1': + '@pnpm/exe.linux-s390x@12.10.1': optional: true - '@pnpm/exe.linux-x64-musl@12.9.1': + '@pnpm/exe.linux-x64-musl@12.10.1': optional: true - '@pnpm/exe.linux-x64@12.9.1': + '@pnpm/exe.linux-x64@12.10.1': optional: true - '@pnpm/exe.win32-arm64@12.9.1': + '@pnpm/exe.win32-arm64@12.10.1': optional: true - '@pnpm/exe.win32-x64@12.9.1': + '@pnpm/exe.win32-x64@12.10.1': optional: true - pnpm@12.9.1: + pnpm@12.10.1: optionalDependencies: - '@pnpm/exe.android-arm64': 12.9.1 - '@pnpm/exe.android-x64': 12.9.1 - '@pnpm/exe.darwin-arm64': 12.9.1 - '@pnpm/exe.darwin-x64': 12.9.1 - '@pnpm/exe.freebsd-x64': 12.9.1 - '@pnpm/exe.linux-arm64': 12.9.1 - '@pnpm/exe.linux-arm64-musl': 12.9.1 - '@pnpm/exe.linux-ppc64': 12.9.1 - '@pnpm/exe.linux-riscv64': 12.9.1 - '@pnpm/exe.linux-s390x': 12.9.1 - '@pnpm/exe.linux-x64': 12.9.1 - '@pnpm/exe.linux-x64-musl': 12.9.1 - '@pnpm/exe.win32-arm64': 12.9.1 - '@pnpm/exe.win32-x64': 12.9.1 + '@pnpm/exe.android-arm64': 12.10.1 + '@pnpm/exe.android-x64': 12.10.1 + '@pnpm/exe.darwin-arm64': 12.10.1 + '@pnpm/exe.darwin-x64': 12.10.1 + '@pnpm/exe.freebsd-x64': 12.10.1 + '@pnpm/exe.linux-arm64': 12.10.1 + '@pnpm/exe.linux-arm64-musl': 12.10.1 + '@pnpm/exe.linux-ppc64': 12.10.1 + '@pnpm/exe.linux-riscv64': 12.10.1 + '@pnpm/exe.linux-s390x': 12.10.1 + '@pnpm/exe.linux-x64': 12.10.1 + '@pnpm/exe.linux-x64-musl': 12.10.1 + '@pnpm/exe.win32-arm64': 12.10.1 + '@pnpm/exe.win32-x64': 12.10.1 --- lockfileVersion: '9.0' @@ -211,8 +211,8 @@ 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': @@ -340,7 +340,7 @@ snapshots: '@babel/parser': 7.29.9 '@babel/types': 7.29.8 - '@babel/traverse@7.29.8': + '@babel/traverse@7.29.10': dependencies: '@babel/code-frame': 7.29.7 '@babel/generator': 7.29.8 @@ -377,7 +377,7 @@ snapshots: dependencies: '@babel/generator': 7.29.8 '@babel/parser': 7.29.9 - '@babel/traverse': 7.29.8 + '@babel/traverse': 7.29.10 '@babel/types': 7.29.8 javascript-natural-sort: 0.7.1 lodash-es: 4.18.1 diff --git a/mock-api/restServer.ts b/mock-api/restServer.ts index 633695866..f46d3433c 100644 --- a/mock-api/restServer.ts +++ b/mock-api/restServer.ts @@ -344,10 +344,10 @@ function updateMask(entity: any, de: any, dd: any) { const old_custom_name = dd.nodes[dd_objIndex].cn; console.log( 'comparing names, old (' + - old_custom_name + - ') with new (' + - new_custom_name + - ')' + old_custom_name + + ') with new (' + + new_custom_name + + ')' ); if (old_custom_name !== new_custom_name) { changed = true; @@ -467,9 +467,9 @@ function upgradeImportantMessages(version: string) { console.log( 'upgradeImportantMessageType: version=' + - version + - ' type=' + - upgradeImportantMessageType_n + version + + ' type=' + + upgradeImportantMessageType_n ); return { upgradeImportantMessageType: upgradeImportantMessageType_n }; } @@ -520,17 +520,17 @@ function get_versions() { console.log( 'getVersions: current=' + - THIS_VERSION + - ' stable=' + - LATEST_STABLE_VERSION + - ' (upgradeable=' + - (STABLE_VERSION_IS_UPGRADEABLE ? 'YES' : 'NO') + - ') dev=' + - LATEST_DEV_VERSION + - ' (upgradeable=' + - (DEV_VERSION_IS_UPGRADEABLE ? 'YES' : 'NO') + - ')' + - (MOCK_OFFLINE ? ' [offline]' : '') + THIS_VERSION + + ' stable=' + + LATEST_STABLE_VERSION + + ' (upgradeable=' + + (STABLE_VERSION_IS_UPGRADEABLE ? 'YES' : 'NO') + + ') dev=' + + LATEST_DEV_VERSION + + ' (upgradeable=' + + (DEV_VERSION_IS_UPGRADEABLE ? 'YES' : 'NO') + + ')' + + (MOCK_OFFLINE ? ' [offline]' : '') ); return data; } diff --git a/project-words.txt b/project-words.txt index cdc228250..562011343 100644 --- a/project-words.txt +++ b/project-words.txt @@ -1356,3 +1356,4 @@ firmwares recognises POWERON crashtest +otadata \ No newline at end of file From 7e96c1ea0a9da3d99de0e8111c03e677808e0e98 Mon Sep 17 00:00:00 2001 From: proddy Date: Thu, 8 Oct 2026 13:40:01 +0200 Subject: [PATCH 05/13] update --- docs/dump_telegrams.csv | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/dump_telegrams.csv b/docs/dump_telegrams.csv index 8686d8ef6..dc630de2a 100644 --- a/docs/dump_telegrams.csv +++ b/docs/dump_telegrams.csv @@ -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, @@ -112,9 +112,9 @@ telegram_type_id,name,is_fetched 0x02A0,RC300Curves, 0x02A1,RC300Curves, 0x02A2,RC300Curves, -0x02A5,RC300Monitor, -0x02A6,CRFMonitor, -0x02A7,CRFMonitor, +0x02A5,CRFMonitor, +0x02A6,RC300Monitor, +0x02A7,RC300Monitor, 0x02A8,RC300Monitor, 0x02A9,RC300Monitor, 0x02AA,RC300Monitor, From ec6184333e78687c83b73500ac7ad0b8ae0e6dfc Mon Sep 17 00:00:00 2001 From: proddy Date: Thu, 8 Oct 2026 17:34:14 +0200 Subject: [PATCH 06/13] update flash script --- scripts/do_fresh_install.sh | 125 +++++++++++++++++++++++++++++------- 1 file changed, 101 insertions(+), 24 deletions(-) diff --git a/scripts/do_fresh_install.sh b/scripts/do_fresh_install.sh index 35b27cbc0..27e053600 100755 --- a/scripts/do_fresh_install.sh +++ b/scripts/do_fresh_install.sh @@ -1,8 +1,9 @@ #!/usr/bin/env bash -# Full fresh install of EMS-ESP on an ESP32 16MB board (s_16M_P). + +# Run like: +# ./scripts/do_fresh_install.sh # -# Run from the scripts folder: -# ./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 @@ -13,44 +14,120 @@ # 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 +# 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 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} -BUILD_BIN=../.pio/build/s_16M_P -RECOVERY_BIN=../.pio/build/recovery_s_16M_P -BOOT_APP0=~/.platformio/packages/framework-arduinoespressif32/tools/partitions/boot_app0.bin +BUILD_DIR=../.pio/build/s_16M_P +RECOVERY_DIR=../.pio/build/recovery_s_16M_P +FIRMWARE_DIR=../build/firmware +# 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 + +# welcome message +step 33 "Welcome to the EMS-ESP fresh install" +note "This will install the latest EMS-ESP firmware and LittleFS" + +# 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 \ - "$BUILD_BIN/bootloader.bin" \ - "$BUILD_BIN/partitions.bin" \ - "$BUILD_BIN/firmware.bin" \ - "$RECOVERY_BIN/firmware.bin" \ - "$BOOT_APP0"; do + "$BOOTLOADER_BIN" \ + "$PARTITIONS_BIN" \ + "$FIRMWARE_BIN" \ + "$RECOVERY_BIN" \ + "$BOOT_APP0_BIN" \ + "$LITTLEFS_BIN"; do if [[ ! -f $f ]]; then - echo "Missing $f — build s_16M_P and recovery_s_16M_P first" >&2 + 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 -# full erase, stay in download mode -$ESPTOOL --chip esp32 --port $PORT --after no-reset erase-flash +# show port, erase and baud rate +step 33 "Port: $PORT, Erase: $ERASE, Baud: $BAUD" -# create the filesystem, in case it's needed -~/.platformio/penv/bin/python build_littlefs.py data $BUILD_BIN/littlefs.bin +# 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 -# bootloader, partitions, otadata, recovery, app, filesystem; then boot +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_BIN/bootloader.bin \ - 0x8000 $BUILD_BIN/partitions.bin \ - 0xe000 $BOOT_APP0 \ - 0x10000 $RECOVERY_BIN/firmware.bin \ - 0x490000 $BUILD_BIN/firmware.bin \ - 0xdf0000 $BUILD_BIN/littlefs.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" From 988ad0006b669740d0f57c6a7f6c4ed13db8783a Mon Sep 17 00:00:00 2001 From: proddy Date: Thu, 8 Oct 2026 17:48:51 +0200 Subject: [PATCH 07/13] remove test docs --- docs/Recovery-Testing.md | 155 --------------------------------------- docs/Recovery.md | 104 -------------------------- 2 files changed, 259 deletions(-) delete mode 100644 docs/Recovery-Testing.md delete mode 100644 docs/Recovery.md diff --git a/docs/Recovery-Testing.md b/docs/Recovery-Testing.md deleted file mode 100644 index 7400c0686..000000000 --- a/docs/Recovery-Testing.md +++ /dev/null @@ -1,155 +0,0 @@ -# Testing the Recovery Firmware, Rollback and Crash-Loop Fallback - -This guide covers testing on hardware of: - -- the EMS-ESP Recovery firmware in the factory `boot` partition (16MB boards only); -- installing the recovery from the EMS-ESP WebUI; -- the bootloader rollback of a new firmware that fails to start; -- the fallback to the recovery after repeated crashes; -- Ethernet and static IP support in the recovery. - -It's written for an ESP32 16MB board with PSRAM (`s_16M_P`, e.g. a BBQKees E32V2). Other 16MB boards (`s_16M`, `s3_16M_P`) follow the same steps with their matching PlatformIO envs; Ethernet is E32V2 only. Keep a USB serial cable connected with a serial monitor open throughout, as most of the checks are log lines. - -## 0. Preparation - -Build everything: - -```bash -pio run -e s_16M_P -e crashtest_s_16M_P -e recovery_s_16M_P -``` - -The firmware files are written to `build/firmware/`: - -| File | What it is | -| --- | --- | -| `EMS-ESP--ESP32-16MB+.bin` | EMS-ESP | -| `EMS-ESP-CrashTest--ESP32-16MB+.bin` | crash test build | -| `EMS-ESP-Recovery--ESP32-16MB+.bin` | the recovery | - -Each has a matching `.md5` file. - -Then: - -1. Run the unit tests with `pio run -e native-test -t exec`. All tests should pass. -2. Optionally, check the recovery UI against mock data first: `cd interface && pnpm recovery`, then open . -3. Open the serial monitor with `pio device monitor -e s_16M_P`. Recovery log lines start with `[recovery]`. - -The crash counter is kept in RTC memory. It survives crashes and software restarts but not a power cycle, so don't unplug the board during the crash tests. - -## 1. Baseline: EMS-ESP over serial, no recovery - -1. Flash with `pio run -e s_16M_P -t upload`. This also writes the current bootloader, which has rollback enabled. Boards flashed with an older bootloader don't roll back until they've been flashed over serial once. -2. In the WebUI, open **Settings → Version**. Check that: - - the **Recovery** row shows "not installed"; - - the firmware partition list doesn't include `boot`. -3. Wait more than 2 minutes. "New firmware confirmed as working" should **not** appear in the log, because a serial flash isn't in the pending-verify state. - -## 2. Install the recovery from the EMS-ESP WebUI - -1. On **Settings → Version**, upload `EMS-ESP-Recovery-…bin`. Expect: - - a "recovery installed" toast; - - **no** restart; - - the Recovery row now shows the recovery version; - - the install logged. -2. Repeat, uploading the `.md5` first and then the `.bin`. The MD5 check should pass. -3. Negative tests: - - Upload the recovery built for another chip, e.g. the ESP32-S3 file on this ESP32. It should be rejected, and nothing should be written. - - Start a recovery upload and close the browser tab halfway through. EMS-ESP should keep running and the Recovery row should show "not installed". Reinstall it properly afterwards. -4. Restart EMS-ESP normally. It should boot EMS-ESP again, not the recovery, because installing the recovery doesn't change the boot partition. - -## 3. Enter the recovery on request - -1. Enter the recovery in one of these ways: - - in the console (telnet or serial) as admin, run `restart boot`; - - long-press the button. -2. On the serial monitor, check that: - - `[recovery] EMS-ESP Recovery v…` is logged; - - LittleFS is mounted; - - the access point has started; - - Ethernet or WiFi connects. -3. Join the access point. Its name is the AP SSID plus `-recovery` (default `ems-esp-recovery`), and it uses the AP password (default `ems-esp-neo`). Open ; a captive portal page may open by itself. Alternatively, use the device's normal IP or `http://.local` from the LAN. -4. Sign in with an EMS-ESP admin account (default `admin`/`admin`). A non-admin user must be refused. -5. On the dashboard, check that: - - there's **no** red reason box, because the restart was requested; - - `Recovery (boot)` has the "running" chip; - - the installed firmware shows the correct version; - - the System section shows the chip, flash, MAC, access point, and the active network (Ethernet or WiFi) with its IP. -6. Click **Restart**. It should come back into the recovery, still without a reason box, since the reason is only shown once. -7. Click **Start** on the EMS-ESP slot. EMS-ESP should boot, and the Version page should still show the recovery version. - -## 4. Upload firmware from the recovery - -1. Enter the recovery again with `restart boot`. -2. Check the "upload target" chip: - - if there's an empty slot, that slot is chosen; - - if both slots hold a firmware, the one with the older install date is chosen, so the newest firmware is kept. -3. Upload the `.md5`, then the EMS-ESP `.bin`. EMS-ESP should start from that slot. About 2 minutes after it boots, "New firmware confirmed as working" should appear in the log. -4. The recovery must refuse these uploads: - - a recovery `.bin`: a 409 with a message that it can't install itself; - - a settings `.json`: a 406; - - an EMS-ESP `.bin` for another chip: rejected as incompatible. -5. In **Write to**, pick the other slot and upload there. That should also work. - -## 5. Bootloader rollback (crash test mode 1) - -`platformio.ini` ships with `-D EMSESP_CRASH_TEST=1`. - -1. Boot a working EMS-ESP and let it run for at least 2 minutes so it's confirmed. -2. Upload `EMS-ESP-CrashTest-…bin` from the **WebUI**, not over serial. -3. On the serial monitor you should see: - - `Crash test, aborting`, a panic, and a restart; - - the bootloader rolling back to the previous slot, which then starts normally. -4. In the EMS-ESP log (serial, or **Status → System Log**) you should see: - - `Restarted after a crash (1 in a row)`; - - `Firmware in partition appX failed to start and was rolled back`. -5. Enter the recovery with `restart boot`. Check that: - - the red box says the firmware in appX failed to start; - - that slot has the "failed to start" chip; - - that slot is the upload target. -6. Click **Start** on the failed slot. The dialog should show a warning; cancel it. -7. Start the good slot. - -If step 3 ends in a crash loop instead of a rollback, the bootloader on the board doesn't have rollback enabled. Flash over serial as in section 1, step 1. - -## 6. Crash-loop fallback to the recovery (crash test mode 2) - -1. In `platformio.ini`, change `-D EMSESP_CRASH_TEST=1` to `=2`, then run `pio run -e crashtest_s_16M_P`. -2. Upload the crash test build from the WebUI. It runs normally. -3. Wait for "New firmware confirmed as working", about 2 minutes after boot. -4. Restart it from the WebUI. It should crash 5 times in a row, logging the "N in a row" count each time. -5. After the 5th crash you should see `Crashed 5 times in a row, restarting into the boot partition`, and the recovery starts. -6. In the recovery: - - the red box should say "EMS-ESP crashed repeatedly and switched to the recovery"; - - after restarting the recovery once, the box should be gone. -7. Clean up: in **Write to**, pick the crash test slot **by hand** and upload a good EMS-ESP, or Start the good slot. - - Switching to the recovery wipes the per-slot rollback states, so the default upload target may be the slot with the good firmware. -8. Change the flag back to `=1`. - -Optional, without a recovery installed: - -1. Erase the boot partition with `esptool --chip esp32 erase-region 0x10000 0x480000`. Older esptool versions use `erase_region`. -2. Repeat steps 2 to 4. It should keep crashing past 5, and needs to be reflashed over serial. - -## 7. Network in the recovery - -| Test | Setup | Expected in the recovery | -| --- | --- | --- | -| WiFi with static IP | Enable a static IP in the EMS-ESP Network settings, then `restart boot` | Same IP as EMS-ESP, and the WiFi row ends with ", static IP" | -| Ethernet | Cable plugged in, then `restart boot` | Log shows "Ethernet started" and "Ethernet connected, IP …". The Ethernet row shows "connected", and WiFi shows "not used, Ethernet is connected" | -| Ethernet with static IP | As above, with a static IP set | Ethernet uses the static IP and WiFi isn't started | -| No cable | Unplugged, then `restart boot` | Ethernet shows "no cable connected" and WiFi shows "waiting for Ethernet". After about 15 seconds the log shows "No Ethernet connection" and WiFi connects | -| Cable plugged in later | Plug in after WiFi has joined | Ethernet gets an IP as well, and WiFi stays connected | -| Access point | Any of the above | `ems-esp-recovery` is always reachable on 192.168.4.1 | - -## 8. Other checks - -- **Online update during the pending window:** after a WebUI upgrade, start an online update from the Version page within 2 minutes. It should install, not fail. -- **4MB boards:** flash `s_4M` and check that: - - there's no Recovery row; - - a normal WebUI upgrade still gets confirmed after 2 minutes. -- **Translations:** switch the WebUI language and check the Recovery row and toast. -- **CI:** run the dev release workflow manually (`workflow_dispatch`) and check that the `EMS-ESP-Recovery-*` files for all three 16MB boards are among the release assets. - -## Reset to a clean state - -`pio run -e s_16M_P -t upload` restores EMS-ESP at any point. It leaves the recovery in `boot` and the settings untouched. diff --git a/docs/Recovery.md b/docs/Recovery.md deleted file mode 100644 index 603c06030..000000000 --- a/docs/Recovery.md +++ /dev/null @@ -1,104 +0,0 @@ -# EMS-ESP Recovery Firmware - -Small standalone app that lives in the factory (`boot`) partition on **16MB** boards. If EMS-ESP will not start, the recovery still can: it brings up an access point (and the saved WiFi/Ethernet if it can) and serves a cut-down WebUI to upload a new EMS-ESP image into `app0`/`app1` or choose which slot to boot. - -4MB boards have no factory partition and no recovery. - -Hardware test steps are in [Recovery-Testing.md](Recovery-Testing.md). - -## Why it exists - -ESP-IDF already rolls a failed OTA back to the **other** OTA slot (`app0` ↔ `app1`). That does not help when: - -- both OTA slots are empty, corrupt, or crash on start -- a serial flash left otadata pointing at an empty slot -- the user needs a known-good way back in without a USB cable - -Recovery is a third, smaller image that EMS-ESP does not overwrite during a normal upgrade. Installing it does **not** change which app boots. - -## Partitions (16MB) - -From `partitions/esp32_partition_16M.csv`: - -| Label | Subtype | Role | -| --- | --- | --- | -| `boot` | factory | Recovery image | -| `app0` | ota_0 | EMS-ESP | -| `app1` | ota_1 | EMS-ESP (the other OTA slot) | -| `otadata` | ota | Which OTA slot the bootloader starts | - -The bootloader reads **otadata**, not “whatever was flashed last”: - -1. Blank otadata (`0xFF`, e.g. after `erase-flash` or a recovery serial upload) → start **factory** (`boot`). -2. Valid otadata → start that OTA slot (`app0` or `app1`). -3. If that image is invalid, it walks **down** toward factory, then **up** through remaining OTA slots. A valid recovery in `boot` is therefore preferred over a valid image sitting only in `app1` while otadata still points at empty `app0`. - -PlatformIO’s `boot_app0.bin` always selects `app0`. The recovery upload script (`scripts/upload_factory.py`) replaces it with a blank otadata so a serial flash of recovery actually boots recovery. - -A normal serial flash of EMS-ESP goes to `app0` and writes `boot_app0.bin`, so the board starts EMS-ESP. Recovery, if already in `boot`, is left in place. - -## How recovery is installed - -From a running EMS-ESP (**Settings → Version**, or the upload drop zone): - -- Drop `EMS-ESP-Recovery-*.bin` (optional `.md5` first). -- EMS-ESP recognises it from the app description (`project_name` = `EMS-ESP-Recovery`) and writes it to the factory partition **without** calling `esp_ota_set_boot_partition`. -- EMS-ESP keeps running. The Version page shows the recovery version once it is in flash. - -From serial, recovery must be written at **`0x10000`**, not at `0x1000` (that is the bootloader; the recovery `.bin` overlaps the partition table). See `scripts/do_fresh_install.sh`. - -The recovery app itself **refuses** to install a recovery image. It only writes EMS-ESP into `app0`/`app1`. - -## How EMS-ESP boots into recovery - -Only 16MB builds with `-DEMSESP_HAS_RECOVERY` and a valid recovery image in `boot`. - -| How | What happens | -| --- | --- | -| **Settings → Version** | Developer mode shows a Recovery row. If a version is installed, **Restart** confirms, then `setPartition("boot")`. | -| **Console** (serial or telnet, admin) | `restart boot` → `system_restart("boot")`. | -| **Button** | Long press ~3 s (not the ~10 s factory reset). | -| **Crash loop** | 5 panics or watchdog resets in a row (RTC counter; survives software restart, not a power cycle). | - -The first three record NVS `rec_reason=request`. The crash path records `crash`. Recovery reads that once and clears it, so a user-requested entry does not show the red crash banner. - -These do **not** enter recovery: - -- Uploading the recovery `.bin` (install only). -- `/api/system/restart` or MQTT `system/restart` (ignore any partition argument; reboot the current app). -- Bootloader rollback of a pending OTA (goes to the previous **OTA** slot). - -## What recovery does - -- Access point `ems-esp-recovery` (AP SSID plus `-recovery`), plus the saved LAN (Ethernet preferred, else WiFi, including a static IP if one is set). -- Sign-in with an EMS-ESP admin account (settings are read from LittleFS, never written). -- List `boot` / `app0` / `app1`, show which is running, which will boot next, and rollback state. -- Upload an EMS-ESP `.bin` into an OTA slot (empty slot, or the older of the two if both are filled) and boot it. -- **Start** a chosen OTA slot without uploading. - -After a WebUI/OTA install from recovery, the new EMS-ESP image is pending verification. After ~2 minutes of uptime it is confirmed so the bootloader will not roll it back. - -## Rough boot flow - -``` -reset - → ESP-IDF bootloader - otadata blank? → factory/boot (recovery) - otadata → app0/1 → that slot if the image is valid - selected image bad → try factory, then the other OTA slot - → recovery or EMS-ESP - -EMS-ESP, if it starts: - crash counter (RTC) - 5 crashes in a row + recovery present → set boot=factory, restart - healthy for 2 minutes → confirm OTA image, clear crash counter -``` - -## Builds - -| Env | Image | -| --- | --- | -| `recovery_s_16M` / `recovery_s_16M_P` / `recovery_s3_16M_P` | Recovery, flashed into `boot` at `0x10000` | -| `s_16M` / `s_16M_P` / `s3_16M_P` | EMS-ESP (`app0` on serial upload) | - -Firmware files land in `build/firmware/` as `EMS-ESP-Recovery---16MB[+].bin`. From 3f038e5ba0628516e03c7c57aa0ad9bb4909a006 Mon Sep 17 00:00:00 2001 From: proddy Date: Tue, 6 Oct 2026 21:42:51 +0200 Subject: [PATCH 08/13] Ack polls during gateway Tx hold (#3094) --- src/core/emsesp.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/core/emsesp.cpp b/src/core/emsesp.cpp index 5ca642a76..977b3c2b9 100644 --- a/src/core/emsesp.cpp +++ b/src/core/emsesp.cpp @@ -1703,6 +1703,8 @@ void EMSESP::incoming_telegram(uint8_t * data, const uint8_t length) { // check for poll to us, if so send top message from Tx queue immediately and quit #ifdef EMSESP_GATEWAY_TX_HOLD if (poll_id == EMSbus::ems_bus_id() && gateway_tx_hold()) { + // still ack the poll, otherwise the master drops us from 0x07 and the gateway sees the device list change + txservice_.send_poll(); return; } #endif From 105ef8cf402197dd87454e316f43881f9ecdaeea Mon Sep 17 00:00:00 2001 From: proddy Date: Thu, 8 Oct 2026 18:03:46 +0200 Subject: [PATCH 09/13] update dev-16 --- CHANGELOG_LATEST.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG_LATEST.md b/CHANGELOG_LATEST.md index 132ca0314..1c64d5598 100644 --- a/CHANGELOG_LATEST.md +++ b/CHANGELOG_LATEST.md @@ -65,4 +65,4 @@ This release is based on the latest Espressif/Arduino core version 3. It brings - 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) From 30c55c4f0ee61602d0316b964aac7fa646257106 Mon Sep 17 00:00:00 2001 From: proddy Date: Thu, 8 Oct 2026 18:24:51 +0200 Subject: [PATCH 10/13] update --- interface/pnpm-lock.yaml | 142 +++++++++++++++++++-------------------- project-words.txt | 3 +- 2 files changed, 73 insertions(+), 72 deletions(-) diff --git a/interface/pnpm-lock.yaml b/interface/pnpm-lock.yaml index 363cf360f..91d2b5913 100644 --- a/interface/pnpm-lock.yaml +++ b/interface/pnpm-lock.yaml @@ -247,7 +247,7 @@ importers: version: 3.9.9 rollup-plugin-visualizer: specifier: ^7.1.1 - version: 7.1.1(rolldown@1.2.12) + version: 7.1.1(rolldown@1.2.13) terser: specifier: ^5.51.2 version: 5.51.2 @@ -627,8 +627,8 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} - '@oxc-project/types@0.152.0': - resolution: {integrity: sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==} + '@oxc-project/types@0.153.0': + resolution: {integrity: sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==} '@popperjs/core@2.11.8': resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} @@ -661,98 +661,98 @@ packages: '@remix-run/route-pattern@0.22.1': resolution: {integrity: sha512-czdGJWh09Lapvcqt7Vb09KlrU2PhRJ8xQaI+AItTuD9aDJ5MAgxnS38lnys6Ll+6RJEqPiaUqTwIQhVLwFvv+w==} - '@rolldown/binding-android-arm-eabi@1.2.12': - resolution: {integrity: sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==} + '@rolldown/binding-android-arm-eabi@1.2.13': + resolution: {integrity: sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@rolldown/binding-android-arm64@1.2.12': - resolution: {integrity: sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg==} + '@rolldown/binding-android-arm64@1.2.13': + resolution: {integrity: sha512-6LEoIrGy5vvCd2boNnv2soPSUZj/PRvhp8R/t9KlX+mIZxThIE/ZqxrGXxmuNX0FuvQFvLdZr7S4HrYpYBVhEQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@rolldown/binding-darwin-arm64@1.2.12': - resolution: {integrity: sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w==} + '@rolldown/binding-darwin-arm64@1.2.13': + resolution: {integrity: sha512-CaJSRzlQ6Q+zkDMVA9OkVxBjVoPRCDEE3v0FSJ9kIQBgRjFApA3Si4ijhbNVUji7ZJt+PfO0e46qy4nPInHUPA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-x64@1.2.12': - resolution: {integrity: sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA==} + '@rolldown/binding-darwin-x64@1.2.13': + resolution: {integrity: sha512-e4r340I5hxSzJkD3YJBxgzq/rJ3wwxwdLLb2FPt1Zpyem20nrQPT11DDEEkazwxaHumH8wpglsp/Ozgj/wT6mw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@rolldown/binding-freebsd-x64@1.2.12': - resolution: {integrity: sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ==} + '@rolldown/binding-freebsd-x64@1.2.13': + resolution: {integrity: sha512-hoBWDZ4/K/mJKVMbsgUd1jfpA2DiOrJ54mpegObU9TBHBBrtX4FLIXJJQRDPZ/2Dn91GvPob+Z2hwKJzf4E5SQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@rolldown/binding-linux-arm-gnueabihf@1.2.12': - resolution: {integrity: sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g==} + '@rolldown/binding-linux-arm-gnueabihf@1.2.13': + resolution: {integrity: sha512-oF9fZu3vTWnTiVAkDFxU9PJLt1nh20K9RRpuHsR7ZnE2L7XookBbDBVLojCVSLGbvtSlxeUNnjAj6DbNsKyzbg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.2.12': - resolution: {integrity: sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw==} + '@rolldown/binding-linux-arm64-gnu@1.2.13': + resolution: {integrity: sha512-DTpb/+jMiqWMPgphn4ksW1zW+XLJ6dJJ9FMvnIk9I291LhCctMPhMYJrP5t9p6C9qa3KXadDdUEZHBaPh3kNxQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-arm64-musl@1.2.12': - resolution: {integrity: sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw==} + '@rolldown/binding-linux-arm64-musl@1.2.13': + resolution: {integrity: sha512-xU+Kvv9N2A3SY35zFh1FWWNjxbadE/BKeqnkXe7vScU4GZsw757BlVmDLITZEq8DPpk6m7uoDYkK7P+x/uU5Ow==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@rolldown/binding-linux-ppc64-gnu@1.2.12': - resolution: {integrity: sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ==} + '@rolldown/binding-linux-ppc64-gnu@1.2.13': + resolution: {integrity: sha512-XMk/hnhh25oQ756Lclb1Q11h4ll0xC0Esa5w4Vzp+mAE2S3AGJgeM0SonrIgY8fHhjarb6hcoTuwoFG0VVP2ig==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-s390x-gnu@1.2.12': - resolution: {integrity: sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw==} + '@rolldown/binding-linux-s390x-gnu@1.2.13': + resolution: {integrity: sha512-U1BoTHYmlywE2n3HUWjzZ+8EQsKr+OwpxYuDWaubx42tUL8QSL7nOqRz/NVHanibbFP3M1Rxuprz/wNcbMKjqw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-gnu@1.2.12': - resolution: {integrity: sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw==} + '@rolldown/binding-linux-x64-gnu@1.2.13': + resolution: {integrity: sha512-J4cm80Pc6wEwV5AZ42o62sIQgDk+Y7R2s646jIwuvO+4V46QB58N4vVuMPWCBaNgursXLpmUWJV//WQAkPvHBQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-musl@1.2.12': - resolution: {integrity: sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g==} + '@rolldown/binding-linux-x64-musl@1.2.13': + resolution: {integrity: sha512-W0euZS40U4V6JkmEnVCIX9mcsL6cbXQ4X8c2w+MMOIjFg68TXUW+u3RT+2e1t1pv9mg/EP3pDtK3POXdQ7Rxyw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@rolldown/binding-openharmony-arm64@1.2.12': - resolution: {integrity: sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA==} + '@rolldown/binding-openharmony-arm64@1.2.13': + resolution: {integrity: sha512-do/AF/4n9MRshia3a5aXoy8xM2c6mgfT6D7H/dY6my5z+hfCIR4ab7mk4IRj++nsEQpKy39kd0DQnDq+r692tw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@rolldown/binding-win32-arm64-msvc@1.2.12': - resolution: {integrity: sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew==} + '@rolldown/binding-win32-arm64-msvc@1.2.13': + resolution: {integrity: sha512-6FtUf2cJZobutJdxnGYX8z0vtbgqU0b2v8vfBhkXb9wp2MppMFtkunE+IaOntyi01rkIQoPW2GHABtfzsC0BRQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.2.12': - resolution: {integrity: sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ==} + '@rolldown/binding-win32-x64-msvc@1.2.13': + resolution: {integrity: sha512-UKhFaZTeTcbKrb3iU82m17jVcPEAoqDkCN5MBqgPyZSwp/cQmib21crTS0DdH7T1B7wZRWEI89YQAOJMet5yhg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -2682,8 +2682,8 @@ packages: deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true - rolldown@1.2.12: - resolution: {integrity: sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==} + rolldown@1.2.13: + resolution: {integrity: sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -3587,7 +3587,7 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.3 - '@oxc-project/types@0.152.0': {} + '@oxc-project/types@0.153.0': {} '@popperjs/core@2.11.8': {} @@ -3634,49 +3634,49 @@ snapshots: '@remix-run/route-pattern@0.22.1': {} - '@rolldown/binding-android-arm-eabi@1.2.12': + '@rolldown/binding-android-arm-eabi@1.2.13': optional: true - '@rolldown/binding-android-arm64@1.2.12': + '@rolldown/binding-android-arm64@1.2.13': optional: true - '@rolldown/binding-darwin-arm64@1.2.12': + '@rolldown/binding-darwin-arm64@1.2.13': optional: true - '@rolldown/binding-darwin-x64@1.2.12': + '@rolldown/binding-darwin-x64@1.2.13': optional: true - '@rolldown/binding-freebsd-x64@1.2.12': + '@rolldown/binding-freebsd-x64@1.2.13': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.2.12': + '@rolldown/binding-linux-arm-gnueabihf@1.2.13': optional: true - '@rolldown/binding-linux-arm64-gnu@1.2.12': + '@rolldown/binding-linux-arm64-gnu@1.2.13': optional: true - '@rolldown/binding-linux-arm64-musl@1.2.12': + '@rolldown/binding-linux-arm64-musl@1.2.13': optional: true - '@rolldown/binding-linux-ppc64-gnu@1.2.12': + '@rolldown/binding-linux-ppc64-gnu@1.2.13': optional: true - '@rolldown/binding-linux-s390x-gnu@1.2.12': + '@rolldown/binding-linux-s390x-gnu@1.2.13': optional: true - '@rolldown/binding-linux-x64-gnu@1.2.12': + '@rolldown/binding-linux-x64-gnu@1.2.13': optional: true - '@rolldown/binding-linux-x64-musl@1.2.12': + '@rolldown/binding-linux-x64-musl@1.2.13': optional: true - '@rolldown/binding-openharmony-arm64@1.2.12': + '@rolldown/binding-openharmony-arm64@1.2.13': optional: true - '@rolldown/binding-win32-arm64-msvc@1.2.12': + '@rolldown/binding-win32-arm64-msvc@1.2.13': optional: true - '@rolldown/binding-win32-x64-msvc@1.2.12': + '@rolldown/binding-win32-x64-msvc@1.2.13': optional: true '@rolldown/pluginutils@1.0.1': {} @@ -5639,35 +5639,35 @@ snapshots: dependencies: glob: 7.2.3 - rolldown@1.2.12: + rolldown@1.2.13: dependencies: - '@oxc-project/types': 0.152.0 + '@oxc-project/types': 0.153.0 '@rolldown/pluginutils': 1.0.1 optionalDependencies: - '@rolldown/binding-android-arm-eabi': 1.2.12 - '@rolldown/binding-android-arm64': 1.2.12 - '@rolldown/binding-darwin-arm64': 1.2.12 - '@rolldown/binding-darwin-x64': 1.2.12 - '@rolldown/binding-freebsd-x64': 1.2.12 - '@rolldown/binding-linux-arm-gnueabihf': 1.2.12 - '@rolldown/binding-linux-arm64-gnu': 1.2.12 - '@rolldown/binding-linux-arm64-musl': 1.2.12 - '@rolldown/binding-linux-ppc64-gnu': 1.2.12 - '@rolldown/binding-linux-s390x-gnu': 1.2.12 - '@rolldown/binding-linux-x64-gnu': 1.2.12 - '@rolldown/binding-linux-x64-musl': 1.2.12 - '@rolldown/binding-openharmony-arm64': 1.2.12 - '@rolldown/binding-win32-arm64-msvc': 1.2.12 - '@rolldown/binding-win32-x64-msvc': 1.2.12 + '@rolldown/binding-android-arm-eabi': 1.2.13 + '@rolldown/binding-android-arm64': 1.2.13 + '@rolldown/binding-darwin-arm64': 1.2.13 + '@rolldown/binding-darwin-x64': 1.2.13 + '@rolldown/binding-freebsd-x64': 1.2.13 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.13 + '@rolldown/binding-linux-arm64-gnu': 1.2.13 + '@rolldown/binding-linux-arm64-musl': 1.2.13 + '@rolldown/binding-linux-ppc64-gnu': 1.2.13 + '@rolldown/binding-linux-s390x-gnu': 1.2.13 + '@rolldown/binding-linux-x64-gnu': 1.2.13 + '@rolldown/binding-linux-x64-musl': 1.2.13 + '@rolldown/binding-openharmony-arm64': 1.2.13 + '@rolldown/binding-win32-arm64-msvc': 1.2.13 + '@rolldown/binding-win32-x64-msvc': 1.2.13 - rollup-plugin-visualizer@7.1.1(rolldown@1.2.12): + rollup-plugin-visualizer@7.1.1(rolldown@1.2.13): dependencies: open: 11.0.4 picomatch: 4.0.7 source-map: 0.8.0 yargs: 18.2.0 optionalDependencies: - rolldown: 1.2.12 + rolldown: 1.2.13 run-applescript@7.1.0: {} @@ -6026,7 +6026,7 @@ snapshots: lightningcss: 1.33.0 picomatch: 4.0.7 postcss: 8.5.29 - rolldown: 1.2.12 + rolldown: 1.2.13 tinyglobby: 0.2.17 optionalDependencies: '@types/node': 26.6.4 diff --git a/project-words.txt b/project-words.txt index 562011343..12f5c75f8 100644 --- a/project-words.txt +++ b/project-words.txt @@ -1356,4 +1356,5 @@ firmwares recognises POWERON crashtest -otadata \ No newline at end of file +otadata +merase \ No newline at end of file From 8c835a9e8b237d5a902327eab793c3ee1bf49376 Mon Sep 17 00:00:00 2001 From: proddy Date: Sat, 10 Oct 2026 11:31:44 +0200 Subject: [PATCH 11/13] add factory reset to Recovery --- interface/package.json | 6 +- interface/pnpm-lock.yaml | 102 +++++++++++++-------------- interface/src/recovery/Dashboard.tsx | 53 +++++++++++++- interface/src/recovery/api.ts | 2 + interface/src/recovery/types.ts | 1 + mock-api/recoveryMock.js | 7 ++ platformio.ini | 6 -- recovery/main.cpp | 37 +++++++++- src/core/system.cpp | 33 +++++---- src/core/system.h | 4 +- 10 files changed, 172 insertions(+), 79 deletions(-) diff --git a/interface/package.json b/interface/package.json index 58d8d8f4c..36f53b16b 100644 --- a/interface/package.json +++ b/interface/package.json @@ -33,7 +33,7 @@ "@table-library/react-table-library": "4.1.15", "alova": "^3.5.5", "jwt-decode": "^4.0.0", - "preact": "^11.0.0", + "preact": "^11.0.1", "react": "^19.3.0", "react-dom": "^19.3.0", "react-icons": "^5.7.0", @@ -47,7 +47,7 @@ "@types/node": "^26.6.4", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", - "concurrently": "^10.0.5", + "concurrently": "^10.0.6", "eslint": "^10.12.0", "eslint-config-prettier": "^10.1.8", "etag": "^1.8.1", @@ -57,7 +57,7 @@ "terser": "^5.51.2", "typescript": "^6.0.3", "typescript-eslint": "^8.71.1", - "vite": "^8.3.3", + "vite": "^8.3.4", "vite-plugin-imagemin": "^0.6.1" }, "packageManager": "pnpm@12.10.1" diff --git a/interface/pnpm-lock.yaml b/interface/pnpm-lock.yaml index 91d2b5913..dec596efb 100644 --- a/interface/pnpm-lock.yaml +++ b/interface/pnpm-lock.yaml @@ -191,8 +191,8 @@ importers: specifier: ^4.0.0 version: 4.0.0 preact: - specifier: ^11.0.0 - version: 11.0.0 + specifier: ^11.0.1 + version: 11.0.1 react: specifier: ^19.3.0 version: 19.3.0 @@ -214,7 +214,7 @@ importers: version: 10.0.1(eslint@10.12.0(supports-color@10.2.2)) '@preact/preset-vite': specifier: ^2.10.6 - version: 2.10.6(@babel/core@7.29.7(supports-color@10.2.2))(preact@11.0.0)(supports-color@10.2.2)(vite@8.3.3(@types/node@26.6.4)(terser@5.51.2)) + version: 2.10.6(@babel/core@7.29.7(supports-color@10.2.2))(preact@11.0.1)(supports-color@10.2.2)(vite@8.3.4(@types/node@26.6.4)(terser@5.51.2)) '@trivago/prettier-plugin-sort-imports': specifier: ^6.0.2 version: 6.0.2(prettier@3.9.9)(supports-color@10.2.2) @@ -228,8 +228,8 @@ importers: specifier: ^19.3.0 version: 19.3.0(@types/react@19.3.0) concurrently: - specifier: ^10.0.5 - version: 10.0.5 + specifier: ^10.0.6 + version: 10.0.6 eslint: specifier: ^10.12.0 version: 10.12.0(supports-color@10.2.2) @@ -258,11 +258,11 @@ importers: specifier: ^8.71.1 version: 8.71.1(eslint@10.12.0(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) vite: - specifier: ^8.3.3 - version: 8.3.3(@types/node@26.6.4)(terser@5.51.2) + specifier: ^8.3.4 + version: 8.3.4(@types/node@26.6.4)(terser@5.51.2) vite-plugin-imagemin: specifier: ^0.6.1 - version: 0.6.1(supports-color@10.2.2)(vite@8.3.3(@types/node@26.6.4)(terser@5.51.2)) + version: 0.6.1(supports-color@10.2.2)(vite@8.3.4(@types/node@26.6.4)(terser@5.51.2)) packages: @@ -1004,8 +1004,8 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - baseline-browser-mapping@2.11.27: - resolution: {integrity: sha512-ElY12DaROGuan+lMmZ8Cvo/ZUbXPe7Enc/9VU/b1T3Kp4dwytRcNdR8DoSJN5SNJT/CuvcCA0DHDVmMOCePdRQ==} + baseline-browser-mapping@2.11.28: + resolution: {integrity: sha512-br0LOmjJqr6Rt4AEkadbcNf67Zpo7EPwKsWbjnRrJIa1QYFHQBqM4C7F2WTbV8ahTEcg1Eo+Y0aWjjORInYFyA==} engines: {node: '>=6.0.0'} hasBin: true @@ -1157,8 +1157,8 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - concurrently@10.0.5: - resolution: {integrity: sha512-JaP/CoftUrCcAFW/g//RbgEGwlelnEae6cfBLgH6ZdO6s8jPkn6p9SB9u6pdVxYXoiSnFqseOlHfrEfF82TVOg==} + concurrently@10.0.6: + resolution: {integrity: sha512-qgICXXFp2/3nAebRLmxcINKEXM1mLK3ij/y31P02VTs/KIkddIeC05n73DxOXsXMB9LFSqNU1/na3lDlOhcbIw==} engines: {node: '>=22'} hasBin: true @@ -1338,8 +1338,8 @@ packages: duplexer3@0.1.5: resolution: {integrity: sha512-1A8za6ws41LQgv9HrE/66jyC5yuSjQ3L/KOpFtoBilsAK2iA2wuS5rTt1OCzIvtS2V7nVmedsUU+DGRcjBmOYA==} - electron-to-chromium@1.5.449: - resolution: {integrity: sha512-dKeYk/3SFQz+nXi5whp3OuJzeWABEG4zCtkWJ9+Vby1E1iFM2y0gElRtQvNXRPJTsj8WAF4HMP7hvP790QtJ9g==} + electron-to-chromium@1.5.452: + resolution: {integrity: sha512-cVvk1YBSj6571VmV6hdJTqj94Y79xSu8cj6Y9t6ae4Y4431z8/7EJFvu3rVp90xhfuv9BpPKUmaLfTo4+FOGBg==} emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -2318,8 +2318,8 @@ packages: node-html-parser@6.1.13: resolution: {integrity: sha512-qIsTMOY4C/dAa5Q5vsobRpOOvPfC4pB61UVW2uSwZNUp0QU/jCekTal1vMmbO0DgdHeLUJpv/ARmDqErVxA3Sg==} - node-releases@2.0.57: - resolution: {integrity: sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==} + node-releases@2.0.58: + resolution: {integrity: sha512-BdiKAgbfRlvfMq/XzO/AF9NPmaA8a+YpLAyAFTVFBj3gARrAIfWvNq7Q7NE4se3KgFu0KkrvQMxTS0XVnsGzHg==} engines: {node: '>=18'} normalize-package-data@2.5.0: @@ -2531,8 +2531,8 @@ packages: resolution: {integrity: sha512-C+y9x90UElAddDZmV4qOx9W53B61PO7cIqWz2dQsWlwswuq4mr8NEwytdGKboYbQlGZ3awrkTeNvcZiZNHnQ8A==} engines: {node: '>=20'} - preact@11.0.0: - resolution: {integrity: sha512-LRTLKuSe6VYPhvBDjwFaX+nuZnpsw6IN2GSboisAvhGsncylBmrbDCERhgKDn6yP2mAwbGvsB5OVyTe33M8gBg==} + preact@11.0.1: + resolution: {integrity: sha512-TNNwEq++jYNHy747LziCjPRVgEwnLSOIOAw/oYcjI3Tdnubx2EXtt76I0an8PATvUWtx/DX1kFIg1eFN+O/fkw==} peerDependencies: preact-render-to-string: '>=6.7.0' peerDependenciesMeta: @@ -2768,8 +2768,8 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} - shell-quote@1.9.0: - resolution: {integrity: sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==} + shell-quote@1.12.0: + resolution: {integrity: sha512-PcByqNyT/38F2kDNi006HAMRJaULuBzq/FOsw3qdZvX/GA9W/jamDaRskgHjubHiftXK5sIFxLNkvrXUwcof6Q==} engines: {node: '>= 0.4'} signal-exit@3.0.7: @@ -3015,8 +3015,8 @@ packages: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} - update-browserslist-db@1.3.3: - resolution: {integrity: sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==} + update-browserslist-db@1.3.4: + resolution: {integrity: sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==} hasBin: true peerDependencies: browserslist: '>= 4.21.0' @@ -3057,8 +3057,8 @@ packages: peerDependencies: vite: 5.x || 6.x || 7.x || 8.x - vite@8.3.3: - resolution: {integrity: sha512-cTAldKPImjg6c+gk48U19POPn3GCBzZwpdsN8ZMEEcbpes+6/wvfqUd0C2y3qYY4wsj8PwgFwrZ/1jBPVttMSg==} + vite@8.3.4: + resolution: {integrity: sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -3591,19 +3591,19 @@ snapshots: '@popperjs/core@2.11.8': {} - '@preact/preset-vite@2.10.6(@babel/core@7.29.7(supports-color@10.2.2))(preact@11.0.0)(supports-color@10.2.2)(vite@8.3.3(@types/node@26.6.4)(terser@5.51.2))': + '@preact/preset-vite@2.10.6(@babel/core@7.29.7(supports-color@10.2.2))(preact@11.0.1)(supports-color@10.2.2)(vite@8.3.4(@types/node@26.6.4)(terser@5.51.2))': dependencies: '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/plugin-transform-react-jsx': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) '@babel/plugin-transform-react-jsx-development': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) - '@prefresh/vite': 2.4.12(preact@11.0.0)(supports-color@10.2.2)(vite@8.3.3(@types/node@26.6.4)(terser@5.51.2)) + '@prefresh/vite': 2.4.12(preact@11.0.1)(supports-color@10.2.2)(vite@8.3.4(@types/node@26.6.4)(terser@5.51.2)) '@rollup/pluginutils': 5.4.0 babel-plugin-transform-hook-names: 1.0.2(@babel/core@7.29.7(supports-color@10.2.2)) debug: 4.4.3(supports-color@10.2.2) magic-string: 0.30.21 picocolors: 1.1.1 - vite: 8.3.3(@types/node@26.6.4)(terser@5.51.2) - vite-prerender-plugin: 0.5.14(vite@8.3.3(@types/node@26.6.4)(terser@5.51.2)) + vite: 8.3.4(@types/node@26.6.4)(terser@5.51.2) + vite-prerender-plugin: 0.5.14(vite@8.3.4(@types/node@26.6.4)(terser@5.51.2)) zimmerframe: 1.1.5 transitivePeerDependencies: - preact @@ -3614,21 +3614,21 @@ snapshots: dependencies: '@babel/core': 7.29.7(supports-color@10.2.2) - '@prefresh/core@1.5.11(preact@11.0.0)': + '@prefresh/core@1.5.11(preact@11.0.1)': dependencies: - preact: 11.0.0 + preact: 11.0.1 '@prefresh/utils@1.2.1': {} - '@prefresh/vite@2.4.12(preact@11.0.0)(supports-color@10.2.2)(vite@8.3.3(@types/node@26.6.4)(terser@5.51.2))': + '@prefresh/vite@2.4.12(preact@11.0.1)(supports-color@10.2.2)(vite@8.3.4(@types/node@26.6.4)(terser@5.51.2))': dependencies: '@babel/core': 7.29.7(supports-color@10.2.2) '@prefresh/babel-plugin': 0.5.4(@babel/core@7.29.7(supports-color@10.2.2)) - '@prefresh/core': 1.5.11(preact@11.0.0) + '@prefresh/core': 1.5.11(preact@11.0.1) '@prefresh/utils': 1.2.1 '@rollup/pluginutils': 4.2.1 - preact: 11.0.0 - vite: 8.3.3(@types/node@26.6.4)(terser@5.51.2) + preact: 11.0.1 + vite: 8.3.4(@types/node@26.6.4)(terser@5.51.2) transitivePeerDependencies: - supports-color @@ -3942,7 +3942,7 @@ snapshots: base64-js@1.5.1: {} - baseline-browser-mapping@2.11.27: {} + baseline-browser-mapping@2.11.28: {} bin-build@3.0.0: dependencies: @@ -4003,11 +4003,11 @@ snapshots: browserslist@4.29.3: dependencies: - baseline-browser-mapping: 2.11.27 + baseline-browser-mapping: 2.11.28 caniuse-lite: 1.0.30001815 - electron-to-chromium: 1.5.449 - node-releases: 2.0.57 - update-browserslist-db: 1.3.3(browserslist@4.29.3) + electron-to-chromium: 1.5.452 + node-releases: 2.0.58 + update-browserslist-db: 1.3.4(browserslist@4.29.3) buffer-alloc-unsafe@1.1.0: {} @@ -4125,11 +4125,11 @@ snapshots: concat-map@0.0.1: {} - concurrently@10.0.5: + concurrently@10.0.6: dependencies: chalk: 5.6.2 rxjs: 7.8.2 - shell-quote: 1.9.0 + shell-quote: 1.12.0 supports-color: 10.2.2 tree-kill: 1.2.2 yargs: 18.0.0 @@ -4370,7 +4370,7 @@ snapshots: duplexer3@0.1.5: {} - electron-to-chromium@1.5.449: {} + electron-to-chromium@1.5.452: {} emoji-regex@10.6.0: {} @@ -5307,7 +5307,7 @@ snapshots: css-select: 5.2.2 he: 1.2.0 - node-releases@2.0.57: {} + node-releases@2.0.58: {} normalize-package-data@2.5.0: dependencies: @@ -5501,7 +5501,7 @@ snapshots: powershell-utils@0.2.1: {} - preact@11.0.0: {} + preact@11.0.1: {} prelude-ls@1.2.1: {} @@ -5724,7 +5724,7 @@ snapshots: shebang-regex@3.0.0: {} - shell-quote@1.9.0: {} + shell-quote@1.12.0: {} signal-exit@3.0.7: {} @@ -5953,7 +5953,7 @@ snapshots: universalify@2.0.1: {} - update-browserslist-db@1.3.3(browserslist@4.29.3): + update-browserslist-db@1.3.4(browserslist@4.29.3): dependencies: browserslist: 4.29.3 escalade: 3.2.0 @@ -5982,7 +5982,7 @@ snapshots: spdx-correct: 3.2.0 spdx-expression-parse: 3.0.1 - vite-plugin-imagemin@0.6.1(supports-color@10.2.2)(vite@8.3.3(@types/node@26.6.4)(terser@5.51.2)): + vite-plugin-imagemin@0.6.1(supports-color@10.2.2)(vite@8.3.4(@types/node@26.6.4)(terser@5.51.2)): dependencies: '@types/imagemin': 7.0.1 '@types/imagemin-gifsicle': 7.0.4 @@ -6007,11 +6007,11 @@ snapshots: imagemin-webp: 6.1.0 jpegtran-bin: 6.0.1 pathe: 0.2.0 - vite: 8.3.3(@types/node@26.6.4)(terser@5.51.2) + vite: 8.3.4(@types/node@26.6.4)(terser@5.51.2) transitivePeerDependencies: - supports-color - vite-prerender-plugin@0.5.14(vite@8.3.3(@types/node@26.6.4)(terser@5.51.2)): + vite-prerender-plugin@0.5.14(vite@8.3.4(@types/node@26.6.4)(terser@5.51.2)): dependencies: kolorist: 1.8.0 magic-string: 0.30.21 @@ -6019,9 +6019,9 @@ snapshots: simple-code-frame: 1.3.0 source-map: 0.7.6 stack-trace: 1.0.0 - vite: 8.3.3(@types/node@26.6.4)(terser@5.51.2) + vite: 8.3.4(@types/node@26.6.4)(terser@5.51.2) - vite@8.3.3(@types/node@26.6.4)(terser@5.51.2): + vite@8.3.4(@types/node@26.6.4)(terser@5.51.2): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 diff --git a/interface/src/recovery/Dashboard.tsx b/interface/src/recovery/Dashboard.tsx index 3ae9ba806..065a88844 100644 --- a/interface/src/recovery/Dashboard.tsx +++ b/interface/src/recovery/Dashboard.tsx @@ -6,6 +6,7 @@ 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, @@ -32,7 +33,7 @@ import SectionContent from 'components/SectionContent'; import { toast } from 'components/toast'; import FirmwareUpload from './FirmwareUpload'; -import { HttpError, restart, setBootPartition } from './api'; +import { HttpError, factoryReset, restart, setBootPartition } from './api'; import type { RecoveryPartition, RecoveryStatus } from './types'; const formatMB = (bytes: number) => `${(bytes / 1048576).toFixed(2)} MB`; @@ -97,6 +98,7 @@ interface DashboardProps { const Dashboard = ({ status, onRestarting, onSignOut }: DashboardProps) => { const [confirmBoot, setConfirmBoot] = useState(); + const [confirmFactoryReset, setConfirmFactoryReset] = useState(false); const [pickedTarget, setPickedTarget] = useState(); const uploadTarget = pickedTarget ?? status.upload_target; @@ -132,6 +134,16 @@ const Dashboard = ({ status, onRestarting, onSignOut }: DashboardProps) => { } }; + 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( @@ -317,6 +329,16 @@ const Dashboard = ({ status, onRestarting, onSignOut }: DashboardProps) => { + {!status.disable_reset && ( + + )} + + + + export const restart = () => request('POST', '/rest/recovery/restart'); +export const factoryReset = () => request('POST', '/rest/recovery/format'); + export interface UploadHandle { promise: Promise; abort: () => void; diff --git a/interface/src/recovery/types.ts b/interface/src/recovery/types.ts index fa1ac57df..6f2b77397 100644 --- a/interface/src/recovery/types.ts +++ b/interface/src/recovery/types.ts @@ -39,6 +39,7 @@ export interface RecoveryStatus { 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; diff --git a/mock-api/recoveryMock.js b/mock-api/recoveryMock.js index a6aeb82b7..5549dc5de 100644 --- a/mock-api/recoveryMock.js +++ b/mock-api/recoveryMock.js @@ -83,6 +83,7 @@ const status = (authenticated) => ({ 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', @@ -162,6 +163,12 @@ export default function recoveryMock() { 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); diff --git a/platformio.ini b/platformio.ini index e07f18028..96b90738b 100644 --- a/platformio.ini +++ b/platformio.ini @@ -60,8 +60,6 @@ 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 ; platform = https://github.com/tasmota/platform-espressif32/releases/download/2026.09.50/platform-espressif32.zip ; Tasmota Arduino Core 3.3.12 based on IDF 5.5.5 @@ -69,8 +67,6 @@ platform = https://github.com/tasmota/platform-espressif32/releases/download/202 [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 ; platform = https://github.com/tasmota/platform-espressif32/releases/download/2026.09.50/platform-espressif32.zip ; Tasmota Arduino Core 3.3.12 based on IDF 5.5.5 build_flags = @@ -82,8 +78,6 @@ build_flags = [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 ; platform = https://github.com/tasmota/platform-espressif32/releases/download/2026.09.50/platform-espressif32.zip ; Tasmota Arduino Core 3.3.12 based on IDF 5.5.5 diff --git a/recovery/main.cpp b/recovery/main.cpp index 5e3870927..23089223f 100644 --- a/recovery/main.cpp +++ b/recovery/main.cpp @@ -5,7 +5,8 @@ // 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. +// The EMS-ESP settings in LittleFS are only ever read, never written, except for a factory reset +// which erases them. #include #include @@ -76,6 +77,7 @@ struct Settings { 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 admins; }; @@ -174,6 +176,7 @@ static void loadSettings() { 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(); @@ -185,6 +188,8 @@ static void loadSettings() { } } 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}); } @@ -449,8 +454,9 @@ static void handleStatus(AsyncWebServerRequest * request) { root["wifi_ip"] = WiFi.localIP().toString(); root["wifi_rssi"] = WiFi.RSSI(); } - root["wifi_started"] = wifi_started; - root["static_ip"] = settings.static_ip; + 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') { @@ -550,6 +556,30 @@ static void handleRestart(AsyncWebServerRequest * request) { 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; + } + + 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) { if (upload_error) { return; @@ -784,6 +814,7 @@ static void startNetwork() { 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)); diff --git a/src/core/system.cpp b/src/core/system.cpp index 3d4523a8a..27d8489e7 100644 --- a/src/core/system.cpp +++ b/src/core/system.cpp @@ -959,23 +959,30 @@ void System::button_OnDblClick(PButton & b) { #endif } -// button long press +// button long press, held for BUTTON_LongPressDelay (3000 ms, 3 seconds) +// restart EMS-ESP void System::button_OnLongPress(PButton & b) { - LOG_NOTICE("Button pressed - long press - restart EMS-ESP"); - EMSESP::system_.system_restart("boot"); -} - -// button indefinite press -void System::button_OnVLongPress(PButton & b) { if (EMSESP::system_.disable_reset()) { - LOG_NOTICE("Factory reset disabled"); + LOG_NOTICE("reset disabled, can't boot"); return; } - LOG_NOTICE("Button pressed - very long press - perform factory reset"); - EMSESP::led_.start_led_fast_flash(5); // Start LED flash timer for 5 seconds + LOG_NOTICE("Button pressed - long press - restart EMS-ESP"); + EMSESP::system_.system_restart(); } -// push button +// button indefinite press, held for BUTTON_VLongPressDelay (6000 ms, 6 seconds) +// restart EMS_ESP into the Recovery boot menu, where you can optionally do a factory reset +void System::button_OnVLongPress(PButton & b) { + if (EMSESP::system_.disable_reset()) { + LOG_NOTICE("reset disabled, can't boot"); + return; + } + LOG_NOTICE("Button pressed - very long press - perform factory reset in Recovery Mode"); + EMSESP::system_.system_restart("boot"); + // EMSESP::led_.start_led_fast_flash(5); // Start LED flash timer for 5 seconds +} + +// push button, held for BUTTON_Debounce (200 ms) void System::button_init() { #ifndef EMSESP_STANDALONE if (!myPButton_.init(pbutton_gpio_, HIGH)) { @@ -3934,9 +3941,9 @@ void System::remove_optional_gpio(uint8_t pin) { // set unused gpios to default state input high-Z void System::reset_unused_gpios() { #if CONFIG_IDF_TARGET_ESP32 -constexpr uint8_t tx0 = 1; // don't change tx0 pin + constexpr uint8_t tx0 = 1; // don't change tx0 pin #else -constexpr uint8_t tx0 = 255; // no valid pin, chips have native USB, tx0 not connected to transceiver + constexpr uint8_t tx0 = 255; // no valid pin, chips have native USB, tx0 not connected to transceiver #endif for (const auto & pin : valid_system_gpios_) { auto it = std::find_if(used_gpios_.begin(), used_gpios_.end(), [pin](const GpioUsage & usage) { return usage.pin == pin; }); diff --git a/src/core/system.h b/src/core/system.h index 7eb43a802..58e5787a0 100644 --- a/src/core/system.h +++ b/src/core/system.h @@ -409,8 +409,8 @@ class System { static constexpr uint32_t BUTTON_DblClickDelay = 250; // Max period between clicks for a double click event (in ms) // button press delays - static constexpr uint32_t BUTTON_LongPressDelay = 3000; // Hold period for a long press event (in ms) - ~3 seconds - static constexpr uint32_t BUTTON_VLongPressDelay = 9500; // Hold period for a very long press event (in ms) - !10 seconds + static constexpr uint32_t BUTTON_LongPressDelay = 3000; // Hold period for a long press event (in ms) - 3 seconds + static constexpr uint32_t BUTTON_VLongPressDelay = 9500; // Hold period for a very long press event (in ms) - 9.5 seconds // healthcheck #ifdef EMSESP_PINGTEST From 0719340f463aed0289ce6ec494051c265b1f6ab2 Mon Sep 17 00:00:00 2001 From: proddy Date: Sat, 10 Oct 2026 12:18:07 +0200 Subject: [PATCH 12/13] formatting --- src/ESP32React/UploadFileService.cpp | 8 ++++---- src/core/system.h | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/ESP32React/UploadFileService.cpp b/src/ESP32React/UploadFileService.cpp index 801a67ffe..67d950fa5 100644 --- a/src/ESP32React/UploadFileService.cpp +++ b/src/ESP32React/UploadFileService.cpp @@ -32,10 +32,10 @@ void UploadFileService::handleUpload(AsyncWebServerRequest * request, const Stri // check details of the file, to see if its a valid bin or json file const std::size_t filesize = request->contentLength(); - _is_firmware = false; - _is_filesystem = false; - _is_recovery = false; - _response_sent = false; + _is_firmware = false; + _is_filesystem = false; + _is_recovery = false; + _response_sent = false; switch (OtaUpdater::classify(filename, filesize, emsesp::System::MIN_FIRMWARE_SIZE)) { case OtaUpdater::FileType::FILESYSTEM: diff --git a/src/core/system.h b/src/core/system.h index 58e5787a0..9ed145795 100644 --- a/src/core/system.h +++ b/src/core/system.h @@ -83,7 +83,7 @@ class System { static void check_crash_loop(); // call at boot once NVS is open, may restart into the factory partition - void recovery_installed(); // called on the AsyncTCP task after the recovery firmware was written + void recovery_installed(); // called on the AsyncTCP task after the recovery firmware was written std::string recovery_version() const; // empty if there's no recovery firmware // commands From b7c795e32077f1c9975bc0511a3f2270c0c44dce Mon Sep 17 00:00:00 2001 From: proddy Date: Sat, 10 Oct 2026 12:28:49 +0200 Subject: [PATCH 13/13] pkg update --- interface/package.json | 4 +- interface/pnpm-lock.yaml | 101 ++++++++++++++++++++------------------- 2 files changed, 55 insertions(+), 50 deletions(-) diff --git a/interface/package.json b/interface/package.json index 36f53b16b..31905b4ab 100644 --- a/interface/package.json +++ b/interface/package.json @@ -28,8 +28,8 @@ "@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", diff --git a/interface/pnpm-lock.yaml b/interface/pnpm-lock.yaml index dec596efb..d4020ce46 100644 --- a/interface/pnpm-lock.yaml +++ b/interface/pnpm-lock.yaml @@ -176,11 +176,11 @@ importers: specifier: ^11.14.1 version: 11.14.1(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2) '@mui/icons-material': - specifier: ^9.4.0 - version: 9.4.0(@mui/material@9.4.0(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(@types/react@19.3.0)(react@19.3.0) + specifier: ^9.5.0 + version: 9.5.0(@mui/material@9.5.0(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(@types/react@19.3.0)(react@19.3.0) '@mui/material': - specifier: ^9.4.0 - version: 9.4.0(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + specifier: ^9.5.0 + version: 9.5.0(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@table-library/react-table-library': specifier: 4.1.15 version: 4.1.15(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(react-dom@19.3.0(react@19.3.0))(react@19.3.0) @@ -359,6 +359,9 @@ packages: resolution: {integrity: sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==} engines: {node: '>=6.9.0'} + '@babel/runtime@8.0.7': + resolution: {integrity: sha512-k0hagDDq/6w4v+0Sp4wFvIz/yswH5O7Va6aRTnD7N62Tnborp4X+YB+H3XUcWVSR4aE47WPEXiIIZchBs0GmYg==} + '@babel/template@7.29.7': resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} engines: {node: '>=6.9.0'} @@ -524,27 +527,27 @@ packages: '@keyv/serialize@1.1.1': resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==} - '@mui/core-downloads-tracker@9.4.0': - resolution: {integrity: sha512-d+gIYM1raJP87yrVQcVg+Q3vycVMArMkgu/TjJLi2vTvO9FHKWNRR1xs9nnN4eLpM9nGxxm02UMvc9BrmcUKvQ==} + '@mui/core-downloads-tracker@9.5.0': + resolution: {integrity: sha512-PErEpl3Lp+1ZqWYKI7GQU8XSiciAFmLbUAZ7IoaUL598HLy1UAFuEUMEJu8yAS1v8vkXYzWprMVZzky4UwXoOA==} - '@mui/icons-material@9.4.0': - resolution: {integrity: sha512-5PVgBYtLOXTk6u0YUAjoV9GoTUQDbeZ8g+pus2h0GphLT/rpmftRnB5D/Kw6QCAovB7EyBX7UxKdZqPBBAHUKw==} + '@mui/icons-material@9.5.0': + resolution: {integrity: sha512-dahtVaqnRUIBgaZ4WC9EdP+LgJ5d2rslASYIX+nDFG6+ssUiM11jQ+Gd1wH8vhUGoNA9ES40MYics5BI8zJFXQ==} engines: {node: '>=14.0.0'} peerDependencies: - '@mui/material': ^9.4.0 + '@mui/material': ^9.5.0 '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 react: ^17.0.0 || ^18.0.0 || ^19.0.0 peerDependenciesMeta: '@types/react': optional: true - '@mui/material@9.4.0': - resolution: {integrity: sha512-6KKUIvkQ2r/s48s9VZV9JEZ1W2dRa7RndVsF+Ipx4CYxDOQeozeRsS5r9NiheFf8A5hpbqJJIGoX7hjg5X4XUw==} + '@mui/material@9.5.0': + resolution: {integrity: sha512-QgqvdVjZoKxP8E8SBxYY6LrdV+kw00moXweZQWBbMMdifPKZAGfUaaXhGKIca6fjKnCL6HJIe/AWWCpqNvyHVw==} engines: {node: '>=14.0.0'} peerDependencies: '@emotion/react': ^11.5.0 '@emotion/styled': ^11.3.0 - '@mui/material-pigment-css': ^9.4.0 + '@mui/material-pigment-css': ^9.5.0 '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 react: ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -558,8 +561,8 @@ packages: '@types/react': optional: true - '@mui/private-theming@9.4.0': - resolution: {integrity: sha512-qQpX/2XPGDz5F2OIWzo4kkorURlDeXGKytRQudXCdLS5TuwKsMKaZyx77LZEVSB73c7o3UrLWwQ/goRuHBdtjw==} + '@mui/private-theming@9.5.0': + resolution: {integrity: sha512-UHZKySlr4ZJeA5HEhe/Tq1fhsR5573ViKYn7JV80dCVJtgqRQIplRSer1DHPg2Yi22llX7QRhgM0Vtrx4kZHSA==} engines: {node: '>=14.0.0'} peerDependencies: '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -568,8 +571,8 @@ packages: '@types/react': optional: true - '@mui/styled-engine@9.4.0': - resolution: {integrity: sha512-SlqVEYqnyEgXMFW4JuIDMelyhKzoWBJ85v2mNCQd0XtU0uk7U9E1zaJZ06XqodhA8oJrb+4Q+3E/JlwBe4jWkw==} + '@mui/styled-engine@9.5.0': + resolution: {integrity: sha512-IX1ceHnZ0bJgzJRWO/Ayl4ME1rNmpWVYwV28yb089UAMR17rJkV1qfr7ApsWQXXPi1oi+2JapJrWQAiAnTlJHQ==} engines: {node: '>=14.0.0'} peerDependencies: '@emotion/react': ^11.4.1 @@ -581,8 +584,8 @@ packages: '@emotion/styled': optional: true - '@mui/system@9.4.0': - resolution: {integrity: sha512-AZz9z13oGS1J0BYeAFHTbV7PegCtPiE/uMEIi7TJ1kTx3Vkt+jUpLyXF/2uV8B6Y79iTh98kVeOH9fX3x3zXXg==} + '@mui/system@9.5.0': + resolution: {integrity: sha512-OZuAwvoiaaHpvn0a540VH1DUwwmojJubGfNmO2g+nb2ybNDP4/gF7t7aA6AMduELSuJ5yxwlzyFvUO3CfLv1Yg==} engines: {node: '>=14.0.0'} peerDependencies: '@emotion/react': ^11.5.0 @@ -597,16 +600,16 @@ packages: '@types/react': optional: true - '@mui/types@9.4.0': - resolution: {integrity: sha512-13DH0Oniua4WmGqeYbQAZqmiN7r4nfd0zwIoGJ5QeJwyIHgEmf+LkRw/jV1HZb6AiUoHX8Oxf4xgalq3vYHcIw==} + '@mui/types@9.5.0': + resolution: {integrity: sha512-O0S6xS80qEQqW2CYNrd4GlBEQ1xpMOHZ5b4zO/DaLu6NZmtthM6smEg0Gz/u9gX5vL+8NB3EiBIxmxSCcG590Q==} peerDependencies: '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 peerDependenciesMeta: '@types/react': optional: true - '@mui/utils@9.4.0': - resolution: {integrity: sha512-WkzY8uYtqUDyGKPShrRQRBomfMQHddlsyKu/gRRESYO24tDPD2z0xRE8CBoUsWqpBa+YrFs9KixiS7kQRjsPAQ==} + '@mui/utils@9.5.0': + resolution: {integrity: sha512-8m0LfSgE2pj58znY3kZ+anwRKbnBkcu/7AJPA8L/77wEjN0QnpX4/zRAccyplp1VEUnMsUBADeTRin5Mkf7epA==} engines: {node: '>=14.0.0'} peerDependencies: '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -3285,6 +3288,8 @@ snapshots: '@babel/runtime@7.29.10': {} + '@babel/runtime@8.0.7': {} + '@babel/template@7.29.7': dependencies: '@babel/code-frame': 7.29.7 @@ -3488,23 +3493,23 @@ snapshots: '@keyv/serialize@1.1.1': {} - '@mui/core-downloads-tracker@9.4.0': {} + '@mui/core-downloads-tracker@9.5.0': {} - '@mui/icons-material@9.4.0(@mui/material@9.4.0(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(@types/react@19.3.0)(react@19.3.0)': + '@mui/icons-material@9.5.0(@mui/material@9.5.0(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(@types/react@19.3.0)(react@19.3.0)': dependencies: - '@babel/runtime': 7.29.10 - '@mui/material': 9.4.0(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@babel/runtime': 8.0.7 + '@mui/material': 9.5.0(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) react: 19.3.0 optionalDependencies: '@types/react': 19.3.0 - '@mui/material@9.4.0(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + '@mui/material@9.5.0(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: - '@babel/runtime': 7.29.10 - '@mui/core-downloads-tracker': 9.4.0 - '@mui/system': 9.4.0(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@types/react@19.3.0)(react@19.3.0) - '@mui/types': 9.4.0(@types/react@19.3.0) - '@mui/utils': 9.4.0(@types/react@19.3.0)(react@19.3.0) + '@babel/runtime': 8.0.7 + '@mui/core-downloads-tracker': 9.5.0 + '@mui/system': 9.5.0(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@types/react@19.3.0)(react@19.3.0) + '@mui/types': 9.5.0(@types/react@19.3.0) + '@mui/utils': 9.5.0(@types/react@19.3.0)(react@19.3.0) '@popperjs/core': 2.11.8 '@types/react-transition-group': 4.4.12(@types/react@19.3.0) clsx: 2.1.1 @@ -3519,18 +3524,18 @@ snapshots: '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2) '@types/react': 19.3.0 - '@mui/private-theming@9.4.0(@types/react@19.3.0)(react@19.3.0)': + '@mui/private-theming@9.5.0(@types/react@19.3.0)(react@19.3.0)': dependencies: - '@babel/runtime': 7.29.10 - '@mui/utils': 9.4.0(@types/react@19.3.0)(react@19.3.0) + '@babel/runtime': 8.0.7 + '@mui/utils': 9.5.0(@types/react@19.3.0)(react@19.3.0) prop-types: 15.8.1 react: 19.3.0 optionalDependencies: '@types/react': 19.3.0 - '@mui/styled-engine@9.4.0(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(react@19.3.0)': + '@mui/styled-engine@9.5.0(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(react@19.3.0)': dependencies: - '@babel/runtime': 7.29.10 + '@babel/runtime': 8.0.7 '@emotion/cache': 11.14.0 '@emotion/serialize': 1.3.3 '@emotion/sheet': 1.4.0 @@ -3541,13 +3546,13 @@ snapshots: '@emotion/react': 11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2) '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2) - '@mui/system@9.4.0(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@types/react@19.3.0)(react@19.3.0)': + '@mui/system@9.5.0(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@types/react@19.3.0)(react@19.3.0)': dependencies: - '@babel/runtime': 7.29.10 - '@mui/private-theming': 9.4.0(@types/react@19.3.0)(react@19.3.0) - '@mui/styled-engine': 9.4.0(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(react@19.3.0) - '@mui/types': 9.4.0(@types/react@19.3.0) - '@mui/utils': 9.4.0(@types/react@19.3.0)(react@19.3.0) + '@babel/runtime': 8.0.7 + '@mui/private-theming': 9.5.0(@types/react@19.3.0)(react@19.3.0) + '@mui/styled-engine': 9.5.0(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(react@19.3.0) + '@mui/types': 9.5.0(@types/react@19.3.0) + '@mui/utils': 9.5.0(@types/react@19.3.0)(react@19.3.0) clsx: 2.1.1 csstype: 3.2.3 prop-types: 15.8.1 @@ -3557,16 +3562,16 @@ snapshots: '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2) '@types/react': 19.3.0 - '@mui/types@9.4.0(@types/react@19.3.0)': + '@mui/types@9.5.0(@types/react@19.3.0)': dependencies: - '@babel/runtime': 7.29.10 + '@babel/runtime': 8.0.7 optionalDependencies: '@types/react': 19.3.0 - '@mui/utils@9.4.0(@types/react@19.3.0)(react@19.3.0)': + '@mui/utils@9.5.0(@types/react@19.3.0)(react@19.3.0)': dependencies: - '@babel/runtime': 7.29.10 - '@mui/types': 9.4.0(@types/react@19.3.0) + '@babel/runtime': 8.0.7 + '@mui/types': 9.5.0(@types/react@19.3.0) '@types/prop-types': 15.7.15 clsx: 2.1.1 prop-types: 15.8.1