From 6411b6ffbd143bc384afd1a293b6abeee08ff5de Mon Sep 17 00:00:00 2001 From: proddy Date: Tue, 6 Oct 2026 22:57:25 +0200 Subject: [PATCH] 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;