add recovery firmware with OTA updater and recovery web interface

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
proddy
2026-10-06 22:57:25 +02:00
co-authored by Cursor
parent 1375f87163
commit 6411b6ffbd
59 changed files with 3595 additions and 339 deletions
+4
View File
@@ -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:
+4
View File
@@ -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:
+4
View File
@@ -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
+2
View File
@@ -32,6 +32,8 @@ analyse.html
interface/vite.config.ts.timestamp*
*.local
src/ESP32React/WWWData.h
recovery/WWWData.h
/interface/dist-recovery
# i18n generated files
interface/src/i18n/i18n-react.tsx
+5
View File
@@ -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 `<AP name>-recovery` access point and connects to the network with the saved EMS-ESP settings (Ethernet if configured, otherwise WiFi, using the static IP if one is set), and has a WebUI to sign in with an existing admin user, upload a new firmware into either OTA partition, or start one of the installed firmwares. It only reads the EMS-ESP settings, never changes them. Released as `EMS-ESP-Recovery-<version>-<chip>-16MB[+].bin`, and installed by uploading it in the EMS-ESP WebUI like a normal firmware, or flashed with `pio run -e recovery_s3_16M_P -t upload`
- firmware rollback: a newly installed firmware is only kept once it has run for 2 minutes. If it crashes or restarts before then, the device goes back to the previous firmware, or to the Recovery if there is none
- on 16MB boards with the Recovery installed, EMS-ESP switches to it after crashing 5 times in a row. The Recovery shows why it was started and which firmware failed to start
- the Version page shows the installed Recovery version on 16MB boards
- crash test builds to try out the rollback and recovery, see `[env:crashtest_s3_16M_P]` in `platformio.ini`
## Fixed
+156
View File
@@ -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-<version>-ESP32S3-16MB+.bin` | EMS-ESP |
| `EMS-ESP-CrashTest-<version>-ESP32S3-16MB+.bin` | crash test build |
| `EMS-ESP-Recovery-<version>-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 <http://localhost:3001>.
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 <http://192.168.4.1>; a captive portal page may open by itself. Alternatively, use the device's normal IP or `http://<hostname>.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.
+4 -4
View File
@@ -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,
1 telegram_type_id name is_fetched
29 0x3B Energy
30 0x3D RC35Set
31 0x3E RC35Monitor
32 0x3F RC30Timer RC35Timer
33 0x40 RC30Temp
34 0x41 RC30Monitor
35 0x42 RC35Timer2
112 0x02A0 RC300Curves
113 0x02A1 RC300Curves
114 0x02A2 RC300Curves
115 0x02A5 CRFMonitor RC300Monitor
116 0x02A6 RC300Monitor CRFMonitor
117 0x02A7 RC300Monitor CRFMonitor
118 0x02A8 RC300Monitor
119 0x02A9 RC300Monitor
120 0x02AA RC300Monitor
+1
View File
@@ -1,6 +1,7 @@
node_modules/
build/
dist/
dist-recovery/
src/i18n/*
.prettierrc
+1
View File
@@ -18,6 +18,7 @@ export default defineConfig(
{
ignores: [
'dist/*',
'dist-recovery/*',
'*.mjs',
'build/*',
'*.js',
+4 -2
View File
@@ -18,6 +18,8 @@
"standalone": "concurrently -c \"auto\" \"typesafe-i18n\" \"pnpm:mock-rest\" \"vite dev\"",
"typesafe-i18n": "typesafe-i18n --no-watch",
"build-webUI": "typesafe-i18n --no-watch && vite build && node progmem-generator.js",
"recovery": "vite dev --mode recovery",
"build-recovery": "vite build --mode recovery && node progmem-generator.js --src ./dist-recovery --out ../recovery/WWWData.h --index recovery.html",
"format": "prettier -l -w '**/*.{ts,tsx,js,css,json,md}'",
"lint": "eslint . --fix",
"standalone-devcontainer": "concurrently -c \"auto\" \"typesafe-i18n\" \"pnpm:mock-rest\" \"vite --host\""
@@ -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"
}
+141 -141
View File
@@ -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:
+13 -3
View File
@@ -10,10 +10,17 @@ import mime from 'mime-types';
import { relative, resolve, sep } from 'path';
import zlib from 'zlib';
// optional args: --src <dir> --out <file> --index <html file served as /index.html>
const getArg = (name, fallback) => {
const i = process.argv.indexOf(name);
return i !== -1 && process.argv[i + 1] ? process.argv[i + 1] : fallback;
};
const ARDUINO_INCLUDES = '#include <Arduino.h>\n\n';
const INDENT = ' ';
const outputPath = '../src/ESP32React/WWWData.h';
const sourcePath = './dist';
const outputPath = getArg('--out', '../src/ESP32React/WWWData.h');
const sourcePath = getArg('--src', './dist');
const indexFile = getArg('--index', 'index.html');
const bytesPerLine = 20;
let totalSize = 0;
let bundleStats = {
@@ -129,7 +136,10 @@ const writeFile = (relativeFilePath, buffer) => {
bundleStats[fileType].compressed += zipBuffer.length;
fileInfo.push({
uri: '/' + relativeFilePath.replace(sep, '/'),
uri:
relativeFilePath === indexFile
? '/index.html'
: '/' + relativeFilePath.replace(sep, '/'),
mimeType,
variable,
size,
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" />
<link rel="stylesheet" href="/css/roboto.css" />
<title>EMS-ESP Recovery</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/recovery/index.tsx"></script>
</body>
</html>
+19 -1
View File
@@ -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 = () => {
</Typography>
</Typography>
</Grid>
{data.recovery_version !== undefined && (
<>
<Grid size={{ xs: 4, md: 2 }}>
<Typography color="secondary">{LL.RECOVERY()}</Typography>
</Grid>
<Grid size={{ xs: 8, md: 10 }}>
<Typography>
{data.recovery_version || LL.RECOVERY_NOT_INSTALLED()}
</Typography>
</Grid>
</>
)}
</Grid>
{internetLive ? (
@@ -848,7 +862,11 @@ const Version = () => {
<Typography sx={{ pt: 2, pb: 2 }} variant="h6" color="primary">
{LL.UPLOAD()}
</Typography>
<SingleUpload text={LL.UPLOAD_FIRMWARE_TEXT()} doRestart={doRestart} />
<SingleUpload
text={LL.UPLOAD_FIRMWARE_TEXT()}
doRestart={doRestart}
onRecoveryInstalled={() => void loadData()}
/>
</>
)}
</Box>
@@ -14,10 +14,15 @@ import { LinearProgressWithLabel } from './LinearProgressWithLabel';
interface SingleUploadProps {
doRestart: () => void;
onRecoveryInstalled?: () => void;
text: string;
}
const SingleUpload = ({ doRestart, text }: SingleUploadProps) => {
const SingleUpload = ({
doRestart,
onRecoveryInstalled,
text
}: SingleUploadProps) => {
const [md5, setMd5] = useState<string>();
const [file, setFile] = useState<File>();
const { LL } = useI18nContext();
@@ -42,6 +47,12 @@ const SingleUpload = ({ doRestart, text }: SingleUploadProps) => {
setMd5((payload as { md5: string }).md5);
toast.success(LL.UPLOAD_MD5_RECEIVED());
setFile(undefined);
} else if (payload && typeof payload === 'object' && 'recovery' in payload) {
// written to the factory partition, EMS-ESP keeps running
toast.success(LL.RECOVERY_INSTALLED());
setMd5(undefined);
setFile(undefined);
onRecoveryInstalled?.();
} else {
if (payload && typeof payload === 'object' && 'md5_ok' in payload) {
toast.success(LL.UPLOAD_MD5_MATCHED());
+4 -1
View File
@@ -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;
+4 -1
View File
@@ -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;
+4 -1
View File
@@ -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;
+4 -1
View File
@@ -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;
+4 -1
View File
@@ -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;
+4 -1
View File
@@ -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;
+4 -1
View File
@@ -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;
+4 -1
View File
@@ -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;
+4 -1
View File
@@ -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;
+4 -1
View File
@@ -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;
+4 -1
View File
@@ -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;
+380
View File
@@ -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<RecoveryStatus['eth_state'], undefined | 'connected'>,
string
> = {
disabled: 'disabled, the clock mode conflicts with PSRAM',
failed: 'failed to start, no PHY found',
no_link: 'no cable connected',
connecting: 'waiting for an IP address'
};
const failedToStart = (p: RecoveryPartition) =>
p.ota_state === 'aborted' || p.ota_state === 'invalid';
// why the device is running the recovery, if it can be worked out
const recoveryReason = (status: RecoveryStatus, failed: RecoveryPartition[]) => {
if (status.reason === 'crash') {
return 'EMS-ESP crashed repeatedly and switched to the recovery.';
}
if (status.reason === 'request') {
return undefined; // started on purpose from EMS-ESP, e.g. a long press of the button
}
if (failed.length > 0) {
return `The firmware in ${failed.map((p) => p.label).join(' and ')} failed to start.`;
}
if (status.reset_reason === 'panic' || status.reset_reason === 'watchdog') {
return 'EMS-ESP crashed while starting.';
}
return undefined;
};
const SectionTitle = ({ children }: { children: ReactNode }) => (
<Typography sx={{ pb: 1 }} variant="h6" color="primary">
{children}
</Typography>
);
const InfoRow = ({ label, value }: { label: string; value: ReactNode }) => (
<Box sx={{ display: 'flex', py: 0.5 }}>
<Typography sx={{ width: 160, flexShrink: 0 }} color="text.secondary">
{label}
</Typography>
<Typography>{value}</Typography>
</Box>
);
interface DashboardProps {
status: RecoveryStatus;
onRestarting: (target: string) => void;
onSignOut: () => void;
}
const Dashboard = ({ status, onRestarting, onSignOut }: DashboardProps) => {
const [confirmBoot, setConfirmBoot] = useState<RecoveryPartition>();
const [pickedTarget, setPickedTarget] = useState<string>();
const uploadTarget = pickedTarget ?? status.upload_target;
const handleError = (error: unknown) => {
if (error instanceof HttpError && error.status === 401) {
toast.warning('Session expired, please sign in again');
onSignOut();
} else {
toast.error((error as Error).message);
}
};
const doBoot = async (partition: RecoveryPartition) => {
setConfirmBoot(undefined);
try {
await setBootPartition(partition.label);
onRestarting(partition.label);
} catch (error) {
if (error instanceof HttpError && error.status === 400) {
toast.error(`${partition.label} does not contain a bootable firmware`);
} else {
handleError(error);
}
}
};
const doRestart = async (target: string) => {
try {
await restart();
onRestarting(target);
} catch (error) {
handleError(error);
}
};
const otaPartitions = status.partitions.filter((p) => !p.factory);
const hasFirmware = otaPartitions.some((p) => p.valid);
const reason = recoveryReason(
status,
otaPartitions.filter((p) => p.valid && failedToStart(p))
);
return (
<>
<SectionContent>
{reason && <MessageBox sx={{ mb: 2 }} level="error" message={reason} />}
<MessageBox level={hasFirmware ? 'info' : 'warning'}>
{hasFirmware
? 'This device is running the EMS-ESP Recovery. Start one of the installed firmwares, or upload a new one.'
: 'No EMS-ESP firmware is installed. Upload a firmware to continue.'}
</MessageBox>
</SectionContent>
<SectionContent>
<SectionTitle>Firmware</SectionTitle>
<List dense>
{status.partitions.map((p, index) => (
<Box key={p.label}>
{index > 0 && <Divider component="li" />}
<ListItem
secondaryAction={
!p.factory &&
p.valid && (
<Button
startIcon={<PlayArrowIcon />}
variant="outlined"
color="primary"
size="small"
onClick={() => setConfirmBoot(p)}
>
Start
</Button>
)
}
>
<ListItemAvatar>
<Avatar
sx={{
bgcolor: p.factory
? '#5d89f7'
: p.valid
? '#2e586a'
: '#5f5f5f'
}}
>
{p.factory ? <HealthAndSafetyIcon /> : <MemoryIcon />}
</Avatar>
</ListItemAvatar>
<ListItemText
primary={
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
flexWrap: 'wrap'
}}
>
<span>{p.factory ? `Recovery (${p.label})` : p.label}</span>
{p.label === status.running && (
<Chip size="small" color="success" label="running" />
)}
{p.label === status.boot && p.label !== status.running && (
<Chip size="small" color="secondary" label="next boot" />
)}
{p.label === uploadTarget && (
<Chip
size="small"
variant="outlined"
label="upload target"
/>
)}
{!p.valid && (
<Chip
size="small"
color="warning"
variant="outlined"
label="empty"
/>
)}
{p.valid && failedToStart(p) && (
<Chip size="small" color="error" label="failed to start" />
)}
{p.valid && p.ota_state === 'pending' && (
<Chip
size="small"
color="warning"
variant="outlined"
label="unconfirmed"
/>
)}
</Box>
}
secondary={
p.valid
? `${p.version ? 'v' + p.version + ' · ' : ''}${formatMB(p.image_size)} of ${formatMB(p.size)}`
: formatMB(p.size)
}
/>
</ListItem>
</Box>
))}
</List>
</SectionContent>
<SectionContent>
<SectionTitle>Upload Firmware</SectionTitle>
<Typography sx={{ pb: 2 }} variant="body2" color="text.secondary">
The firmware is started automatically after the upload. To enable the MD5
check, upload the matching .md5 file first.
</Typography>
<TextField
select
size="small"
sx={{ mb: 2, minWidth: 260 }}
label="Write to"
value={uploadTarget}
onChange={(e) => setPickedTarget(e.target.value)}
>
{otaPartitions.map((p) => (
<MenuItem key={p.label} value={p.label}>
{p.label}
{p.valid
? ` (replaces ${p.version ? 'v' + p.version : 'installed firmware'})`
: ' (empty)'}
</MenuItem>
))}
</TextField>
<FirmwareUpload
target={uploadTarget}
chip={status.chip}
onUploaded={() => void doRestart(uploadTarget)}
onUnauthorized={onSignOut}
/>
</SectionContent>
<SectionContent>
<SectionTitle>System</SectionTitle>
<InfoRow label="Recovery version" value={'v' + status.recovery_version} />
<InfoRow
label="Chip"
value={`${status.chip}, ${formatMB(status.flash_size)} flash${status.psram_size ? ', ' + formatMB(status.psram_size) + ' PSRAM' : ''}`}
/>
<InfoRow
label="Free heap"
value={`${Math.round(status.free_heap / 1024)} KB`}
/>
<InfoRow label="Uptime" value={formatUptime(status.uptime)} />
<InfoRow label="MAC address" value={status.mac} />
<InfoRow
label="Access point"
value={`${status.ap_ssid} on ${status.ap_ip} (${status.ap_clients} connected)`}
/>
{status.eth_state && (
<InfoRow
label="Ethernet"
value={
status.eth_state === 'connected'
? `connected, ${status.eth_ip ?? ''}${status.static_ip ? ' (static IP)' : ''}`
: ETH_STATE_TEXT[status.eth_state]
}
/>
)}
<InfoRow
label="WiFi"
value={
!status.wifi_ssid
? 'not configured'
: status.wifi_connected
? `${status.wifi_ssid}, ${status.wifi_ip ?? ''} (${status.wifi_rssi ?? 0} dBm)${status.static_ip ? ', static IP' : ''}`
: !status.wifi_started
? status.eth_state === 'connected'
? 'not used, Ethernet is connected'
: 'waiting for Ethernet'
: `connecting to ${status.wifi_ssid}`
}
/>
<InfoRow label="Hostname" value={status.hostname} />
<ButtonRow>
<Button
startIcon={<RestartAltIcon />}
variant="outlined"
color="error"
onClick={() => void doRestart(status.boot)}
>
Restart{status.boot !== status.running ? ` (boots ${status.boot})` : ''}
</Button>
<Button
startIcon={<LogoutIcon />}
variant="outlined"
color="secondary"
onClick={onSignOut}
>
Sign Out
</Button>
</ButtonRow>
</SectionContent>
<Dialog
sx={dialogStyle}
open={!!confirmBoot}
onClose={() => setConfirmBoot(undefined)}
>
<DialogTitle>Start Firmware</DialogTitle>
<DialogContent dividers>
Restart and run
{confirmBoot?.version ? ` EMS-ESP v${confirmBoot.version}` : ''} from{' '}
{confirmBoot?.label}?
{confirmBoot && failedToStart(confirmBoot) && (
<MessageBox
sx={{ mt: 2 }}
level="warning"
message="This firmware failed to start before. If it fails again the device falls back to the other firmware, or to the recovery."
/>
)}
</DialogContent>
<DialogActions>
<Button
startIcon={<CancelIcon />}
variant="outlined"
color="secondary"
onClick={() => setConfirmBoot(undefined)}
>
Cancel
</Button>
<Button
startIcon={<PlayArrowIcon />}
variant="outlined"
color="primary"
onClick={() => confirmBoot && void doBoot(confirmBoot)}
>
Start
</Button>
</DialogActions>
</Dialog>
</>
);
};
export default Dashboard;
+208
View File
@@ -0,0 +1,208 @@
import { type ChangeEvent, type DragEvent, useRef, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel';
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
import UploadIcon from '@mui/icons-material/Upload';
import { Box, Button, Typography, styled } from '@mui/material';
import { toast } from 'components/toast';
import { LinearProgressWithLabel } from 'components/upload/LinearProgressWithLabel';
import { HttpError, type UploadHandle, uploadFile } from './api';
const DocumentUploader = styled(Box)<{ active?: boolean }>(({ theme, active }) => ({
border: `2px dashed ${active ? '#6dc24b' : '#4282fe'}`,
backgroundColor: '#2e3339',
padding: theme.spacing(1.25),
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
borderRadius: theme.spacing(1),
cursor: 'pointer',
minHeight: '120px',
transition: 'border-color 0.2s ease-in-out'
}));
const uploadErrorMessage = (status: number, chip: string) => {
switch (status) {
case 400:
return 'Invalid target partition';
case 401:
case 403:
return 'Not authorized, please sign in again';
case 409:
return 'This is the recovery firmware, it can only be installed from EMS-ESP';
case 503:
return `This firmware is not built for the ${chip}`;
case 507:
return 'The firmware does not fit in the partition';
case 500:
return 'Upload failed, flash write error or MD5 mismatch';
default:
return 'Upload failed. Only EMS-ESP firmware .bin and .md5 files are accepted';
}
};
interface FirmwareUploadProps {
target: string;
chip: string;
onUploaded: () => void;
onUnauthorized: () => void;
}
const FirmwareUpload = ({
target,
chip,
onUploaded,
onUnauthorized
}: FirmwareUploadProps) => {
const [file, setFile] = useState<File>();
const [dragged, setDragged] = useState(false);
const [md5, setMd5] = useState<string>();
const [progress, setProgress] = useState<number>();
const uploadRef = useRef<UploadHandle | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const selectFile = (selected?: File) => {
if (!selected) return;
if (/\.(bin|md5)$/i.test(selected.name)) {
setFile(selected);
} else {
toast.warning('Please select an EMS-ESP firmware .bin or .md5 file');
}
};
const onFileChange = (e: ChangeEvent<HTMLInputElement>) => {
selectFile(e.target.files?.[0]);
e.target.value = ''; // allow the same file to be selected again
};
const onDrop = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
setDragged(false);
selectFile(e.dataTransfer.files[0]);
};
const startUpload = async () => {
if (!file) return;
setProgress(0);
uploadRef.current = uploadFile(file, target, setProgress);
try {
const result = await uploadRef.current.promise;
if (result.md5) {
setMd5(result.md5);
toast.success('MD5 received, now upload the firmware .bin');
} else {
if (result.md5_ok) {
toast.success('Firmware MD5 matches');
}
toast.success(`Firmware written to ${target}`);
onUploaded();
}
} catch (error) {
const status = error instanceof HttpError ? error.status : 0;
if (status === -1) {
toast.warning('Upload aborted');
} else {
toast.error(uploadErrorMessage(status, chip));
if (status === 401 || status === 403) onUnauthorized();
}
} finally {
uploadRef.current = null;
setProgress(undefined);
setFile(undefined);
}
};
if (progress !== undefined) {
return (
<>
<Typography sx={{ mb: 1 }}>
Uploading {file?.name} to {target}&hellip;
</Typography>
<LinearProgressWithLabel value={progress} />
<Button
sx={{ mt: 2 }}
startIcon={<CancelIcon />}
variant="outlined"
color="secondary"
onClick={() => uploadRef.current?.abort()}
>
Cancel
</Button>
</>
);
}
return (
<>
<DocumentUploader
active={!!(file || dragged)}
onDrop={onDrop}
onDragOver={(e) => {
e.preventDefault();
setDragged(true);
}}
onDragLeave={() => setDragged(false)}
onClick={() => inputRef.current?.click()}
>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<CloudUploadIcon sx={{ mr: 4 }} color="primary" fontSize="large" />
<Typography>
{md5
? 'MD5 received. Now drop the firmware .bin file here, or click to browse'
: 'Drop an EMS-ESP firmware .bin file here, or click to browse'}
</Typography>
</Box>
<input
type="file"
hidden
ref={inputRef}
accept=".bin,.md5"
onChange={onFileChange}
style={{ display: 'none' }}
/>
{file && (
<>
<Typography sx={{ fontSize: 14, color: '#6dc24b', my: 1 }}>
{file.name}
</Typography>
<Box>
<Button
startIcon={<CancelIcon />}
variant="outlined"
color="secondary"
onClick={(e) => {
e.stopPropagation();
setFile(undefined);
}}
>
Cancel
</Button>
<Button
sx={{ ml: 2 }}
startIcon={<UploadIcon />}
variant="outlined"
color="primary"
onClick={(e) => {
e.stopPropagation();
void startUpload();
}}
>
Upload
</Button>
</Box>
</>
)}
</DocumentUploader>
{md5 && (
<Typography sx={{ mt: 2 }} variant="body2" color="success">
{'MD5: ' + md5}
</Typography>
)}
</>
);
};
export default FirmwareUpload;
+132
View File
@@ -0,0 +1,132 @@
import { useCallback, useEffect, useState } from 'react';
import RefreshIcon from '@mui/icons-material/Refresh';
import {
AppBar,
Box,
Button,
CircularProgress,
Toolbar,
Typography
} from '@mui/material';
import CustomTheme from 'CustomTheme';
import MessageBox from 'components/MessageBox';
import SectionContent from 'components/SectionContent';
import { Toaster } from 'components/toast';
import Dashboard from './Dashboard';
import RestartMonitor from './RestartMonitor';
import SignIn from './SignIn';
import { getToken, readStatus, setToken } from './api';
import type { RecoveryStatus } from './types';
const STATUS_REFRESH_MS = 5000;
const RecoveryApp = () => {
const [status, setStatus] = useState<RecoveryStatus>();
const [error, setError] = useState<string>();
const [restartTarget, setRestartTarget] = useState<string>();
const loadStatus = useCallback(async () => {
try {
const data = await readStatus();
if (!data.authenticated && getToken()) {
setToken(null); // stale token, e.g. after the device restarted
}
setStatus(data);
setError(undefined);
} catch (e) {
setError((e as Error).message);
}
}, []);
useEffect(() => {
void loadStatus();
}, [loadStatus]);
useEffect(() => {
if (!status?.authenticated || restartTarget !== undefined) return;
const timer = setInterval(() => void loadStatus(), STATUS_REFRESH_MS);
return () => clearInterval(timer);
}, [status?.authenticated, restartTarget, loadStatus]);
const signOut = () => {
setToken(null);
void loadStatus();
};
let content;
if (restartTarget !== undefined && status) {
content = (
<RestartMonitor
target={restartTarget}
viaAccessPoint={window.location.hostname === status.ap_ip}
hostname={status.hostname}
/>
);
} else if (!status) {
content = (
<SectionContent>
{error ? (
<MessageBox level="error" message={error}>
<Button
sx={{ ml: 2 }}
startIcon={<RefreshIcon />}
variant="contained"
color="error"
onClick={() => void loadStatus()}
>
Retry
</Button>
</MessageBox>
) : (
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}>
<CircularProgress size={100} />
</Box>
)}
</SectionContent>
);
} else if (!status.authenticated) {
content = <SignIn onSignedIn={() => void loadStatus()} />;
} else {
content = (
<>
<AppBar
position="fixed"
sx={{ boxShadow: 'none', backgroundColor: '#2e586a' }}
>
<Toolbar>
<img
src="/app/icon.png"
alt="EMS-ESP"
style={{ height: 32, marginRight: 16 }}
/>
<Typography variant="h6">EMS-ESP</Typography>
<Typography variant="h6" sx={{ color: '#90caf9' }}>
&nbsp;&nbsp;|&nbsp;&nbsp;
</Typography>
<Typography variant="h6">Recovery</Typography>
</Toolbar>
</AppBar>
<Box component="main" sx={{ maxWidth: 900, mx: 'auto' }}>
<Toolbar />
<Dashboard
status={status}
onRestarting={setRestartTarget}
onSignOut={signOut}
/>
</Box>
</>
);
}
return (
<CustomTheme>
{content}
<Toaster />
</CustomTheme>
);
};
export default RecoveryApp;
+149
View File
@@ -0,0 +1,149 @@
import { useEffect, useState } from 'react';
import RefreshIcon from '@mui/icons-material/Refresh';
import { Box, Button, LinearProgress, Typography } from '@mui/material';
import MessageBox from 'components/MessageBox';
import { STATUS_URL } from './api';
const POLL_INTERVAL_MS = 1000;
const POLL_TIMEOUT_MS = 3000;
const FIRST_POLL_DELAY_MS = 1500;
// reload anyway if the device never appeared to go down
const NO_DOWNTIME_RELOAD_MS = 20000;
const GIVE_UP_MS = 90000;
interface RestartMonitorProps {
target: string;
viaAccessPoint: boolean;
hostname: string;
}
// Waits for the device to go down and come back, then reloads the page. Both the recovery app
// and EMS-ESP answer on STATUS_URL (EMS-ESP serves its WebUI for unknown GETs).
const RestartMonitor = ({
target,
viaAccessPoint,
hostname
}: RestartMonitorProps) => {
const [gaveUp, setGaveUp] = useState(false);
useEffect(() => {
const started = Date.now();
let seenDown = false;
let timer: ReturnType<typeof setTimeout>;
let cancelled = false;
const poll = async () => {
let up: boolean;
try {
const response = await fetch(STATUS_URL, {
cache: 'no-store',
signal: AbortSignal.timeout(POLL_TIMEOUT_MS)
});
up = response.ok;
} catch {
up = false;
}
if (cancelled) return;
const elapsed = Date.now() - started;
if (!up) {
seenDown = true;
} else if (seenDown || elapsed > NO_DOWNTIME_RELOAD_MS) {
window.location.reload();
return;
}
if (elapsed > GIVE_UP_MS) {
setGaveUp(true);
return;
}
timer = setTimeout(() => void poll(), POLL_INTERVAL_MS);
};
timer = setTimeout(() => void poll(), FIRST_POLL_DELAY_MS);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, []);
return (
<Box
sx={{
position: 'fixed',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<Box
sx={{
width: '30%',
minWidth: '300px',
maxWidth: '500px',
backgroundColor: '#393939',
border: 2,
borderColor: '#565656',
borderRadius: '8px',
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.3)',
p: 3,
display: 'flex',
alignItems: 'center',
flexDirection: 'column'
}}
>
<img
src="/app/icon.png"
alt="EMS-ESP"
style={{ width: '40px', height: '40px', marginBottom: '16px' }}
/>
<Typography sx={{ textAlign: 'center' }} variant="h6">
Restarting{target ? ` from ${target}` : ''}
</Typography>
{gaveUp ? (
<>
<MessageBox sx={{ mt: 2 }} level="warning">
The device has not come back on this address.
{viaAccessPoint &&
` If you were connected to the recovery access point, reconnect to your normal network and open http://${hostname}.local`}
</MessageBox>
<Button
sx={{ mt: 2 }}
startIcon={<RefreshIcon />}
variant="outlined"
onClick={() => window.location.reload()}
>
Reload
</Button>
</>
) : (
<>
<Typography sx={{ mt: 2, textAlign: 'center' }} variant="h6">
Please wait&hellip;
</Typography>
<Box sx={{ width: '100%', mt: 2 }}>
<LinearProgress />
</Box>
{viaAccessPoint && (
<Typography
sx={{ mt: 2, textAlign: 'center' }}
variant="body2"
color="text.secondary"
>
EMS-ESP does not use the recovery access point. If this page does not
reload, reconnect to your normal network and open http://{hostname}
.local
</Typography>
)}
</>
)}
</Box>
</Box>
);
};
export default RestartMonitor;
+142
View File
@@ -0,0 +1,142 @@
import { type KeyboardEvent, useState } from 'react';
import ForwardIcon from '@mui/icons-material/Forward';
import VisibilityIcon from '@mui/icons-material/Visibility';
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
import {
Box,
Button,
IconButton,
InputAdornment,
Paper,
TextField,
Typography
} from '@mui/material';
import type { Theme } from '@mui/material/styles';
import { toast } from 'components/toast';
import { HttpError, setToken, signIn } from './api';
interface SignInProps {
onSignedIn: () => void;
}
const SignIn = ({ onSignedIn }: SignInProps) => {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [processing, setProcessing] = useState(false);
const doSignIn = async () => {
if (!username || !password) {
toast.warning('Please enter the admin username and password');
return;
}
setProcessing(true);
try {
const { access_token } = await signIn(username, password);
setToken(access_token);
onSignedIn();
} catch (error) {
if (error instanceof HttpError && error.status === 401) {
toast.warning('Invalid login details');
} else {
toast.error('Error: ' + (error as Error).message);
}
setProcessing(false);
}
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Enter') {
void doSignIn();
}
};
return (
<Box
sx={(theme: Theme) => ({
display: 'flex',
height: '100vh',
margin: 'auto',
padding: 2,
justifyContent: 'center',
flexDirection: 'column',
maxWidth: theme.breakpoints.values.sm
})}
>
<Paper
sx={(theme) => ({
textAlign: 'center',
padding: theme.spacing(2),
paddingTop: '172px',
backgroundImage: 'url("/app/icon.png")',
backgroundRepeat: 'no-repeat',
backgroundPosition: '50% ' + theme.spacing(2),
width: '100%'
})}
>
<Typography variant="h4">EMS-ESP</Typography>
<Typography sx={{ mb: 2 }} variant="h6" color="secondary">
Recovery
</Typography>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: 1,
alignItems: 'center'
}}
>
<TextField
disabled={processing}
sx={{ width: '32ch' }}
name="username"
label="Username"
value={username}
onChange={(e) => setUsername(e.target.value)}
slotProps={{ input: { autoCapitalize: 'none', autoCorrect: 'off' } }}
/>
<TextField
disabled={processing}
sx={{ width: '32ch' }}
name="password"
label="Password"
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={onKeyDown}
slotProps={{
input: {
endAdornment: (
<InputAdornment position="end">
<IconButton
onClick={() => setShowPassword(!showPassword)}
edge="end"
>
{showPassword ? <VisibilityIcon /> : <VisibilityOffIcon />}
</IconButton>
</InputAdornment>
)
}
}}
/>
</Box>
<Button
variant="contained"
color="primary"
sx={{ mt: 2 }}
onClick={doSignIn}
disabled={processing}
>
<ForwardIcon sx={{ mr: 1 }} />
Sign In
</Button>
</Paper>
</Box>
);
};
export default SignIn;
+107
View File
@@ -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<string, string> => {
const token = getToken();
return token ? { Authorization: `Bearer ${token}` } : {};
};
const request = async <T>(
method: string,
url: string,
body?: unknown
): Promise<T> => {
const init: RequestInit = { method, headers: authHeaders(), cache: 'no-store' };
if (body !== undefined) {
init.headers = { ...authHeaders(), 'Content-Type': 'application/json' };
init.body = JSON.stringify(body);
}
const response = await fetch(url, init);
if (!response.ok) {
throw new HttpError(
response.status,
response.statusText || `HTTP ${response.status}`
);
}
const text = await response.text();
return (text ? JSON.parse(text) : undefined) as T;
};
export const STATUS_URL = '/rest/recovery/status';
export const readStatus = () => request<RecoveryStatus>('GET', STATUS_URL);
export const signIn = (username: string, password: string) =>
request<{ access_token: string }>('POST', '/rest/signIn', { username, password });
export const setBootPartition = (partition: string) =>
request<void>('POST', '/rest/recovery/boot', { partition });
export const restart = () => request<void>('POST', '/rest/recovery/restart');
export interface UploadHandle {
promise: Promise<UploadResult>;
abort: () => void;
}
// XHR instead of fetch, for upload progress events
export const uploadFile = (
file: File,
partition: string,
onProgress: (percent: number) => void
): UploadHandle => {
const xhr = new XMLHttpRequest();
const promise = new Promise<UploadResult>((resolve, reject) => {
xhr.upload.onprogress = (event) => {
if (event.lengthComputable && event.total > 0) {
onProgress(Math.round((event.loaded * 100) / event.total));
}
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
resolve(
(xhr.responseText ? JSON.parse(xhr.responseText) : {}) as UploadResult
);
} catch {
resolve({});
}
} else {
reject(new HttpError(xhr.status, xhr.statusText));
}
};
// the device closes the connection on unsupported files, so there is no status code
xhr.onerror = () => reject(new HttpError(0, 'Connection closed'));
xhr.onabort = () => reject(new HttpError(-1, 'Aborted'));
});
const formData = new FormData();
formData.append('file', file);
xhr.open('POST', `/rest/uploadFile?partition=${encodeURIComponent(partition)}`);
for (const [key, value] of Object.entries(authHeaders())) {
xhr.setRequestHeader(key, value);
}
xhr.send(formData);
return { promise, abort: () => xhr.abort() };
};
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import RecoveryApp from './RecoveryApp';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<RecoveryApp />
</StrictMode>
);
+54
View File
@@ -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;
}
+1
View File
@@ -52,6 +52,7 @@ export interface SystemStatus {
model: string;
board: string;
has_loader: boolean;
recovery_version?: string;
has_partition: boolean;
partitions: {
partition: string;
+60
View File
@@ -12,6 +12,7 @@ const KB_DIVISOR = 1024;
const REPEAT_CHAR = '=';
const REPEAT_COUNT = 50;
const DEFAULT_OUT_DIR = 'dist';
const RECOVERY_OUT_DIR = 'dist-recovery';
const ES_TARGET = 'es2020';
const CHUNK_SIZE_WARNING_LIMIT = 1024;
const ASSETS_INLINE_LIMIT = 4096;
@@ -205,6 +206,65 @@ const imageOptimizationPlugin = {
export default defineConfig(
async ({ command, mode }: { command: string; mode: string }) => {
if (command === 'serve' && mode === 'recovery') {
console.log('Preparing Recovery WebUI with mock server');
const recoveryMockUrl = pathToFileURL(
path.resolve(import.meta.dirname, '../mock-api/recoveryMock.js')
).href;
const { default: recoveryMock } = (await import(
/* @vite-ignore */ recoveryMockUrl
)) as { default: () => PluginOption };
return {
plugins: [...createBasePlugins(true, false), recoveryMock()],
resolve: {
alias: RESOLVE_ALIASES,
extensions: RESOLVE_EXTENSIONS,
tsconfigPaths: true
},
server: {
open: '/recovery.html',
port: 3001
}
};
}
if (mode === 'recovery') {
console.log('Preparing for Recovery WebUI, optimized build');
return {
plugins: [...createBasePlugins(false, true), imageOptimizationPlugin],
resolve: {
alias: RESOLVE_ALIASES,
extensions: RESOLVE_EXTENSIONS,
tsconfigPaths: true
},
build: {
...createBaseBuildConfig(),
outDir: RECOVERY_OUT_DIR,
emptyOutDir: true,
minify: 'terser' as const,
terserOptions: createProductionTerserOptions(),
rollupOptions: {
input: path.resolve(import.meta.dirname, 'recovery.html'),
checks: {
pluginTimings: false
},
treeshake: {
moduleSideEffects: false,
propertyReadSideEffects: false as const,
unknownGlobalSideEffects: false
},
output: {
chunkFileNames: 'assets/[name]-[hash].js',
entryFileNames: 'assets/[name]-[hash].js',
assetFileNames: 'assets/[name]-[hash].[ext]',
manualChunks,
sourcemap: false
}
}
}
};
}
if (command === 'serve') {
console.log(`Preparing for standalone build with server, mode=${mode}`);
const mockServerUrl = pathToFileURL(
+236
View File
@@ -0,0 +1,236 @@
#include "OtaUpdater.h"
#include <Update.h>
#include <esp_app_format.h>
#include <esp_image_format.h>
#include <cstddef>
static String getFilenameExtension(const String & filename) {
const auto pos = filename.lastIndexOf('.');
if (pos != -1) {
return filename.substring(static_cast<unsigned int>(pos) + 1);
}
return {};
}
OtaUpdater::FileType OtaUpdater::classify(const String & filename, size_t filesize, size_t min_firmware_size) {
const String extension = getFilenameExtension(filename);
if (extension == "bin" && filename.endsWith("littlefs.bin")) {
return FileType::FILESYSTEM;
}
if (extension == "bin" && 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<char, 33> & out) {
size_t i = 0;
while (i < len && (data[i] == ' ' || data[i] == '\t' || data[i] == '\r' || data[i] == '\n')) {
++i;
}
if (len - i < 32) {
return false;
}
for (size_t n = 0; n < 32; n++) {
const char c = static_cast<char>(data[i + n]);
const bool hex = (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F');
if (!hex) {
return false;
}
out[n] = (c >= 'A' && c <= 'F') ? static_cast<char>(c - 'A' + 'a') : c;
}
out[32] = '\0';
return true;
}
bool OtaUpdater::confirmRunningApp() {
#ifdef CONFIG_APP_ROLLBACK_ENABLE
esp_ota_img_states_t state;
if (esp_ota_get_state_partition(esp_ota_get_running_partition(), &state) == ESP_OK && state == ESP_OTA_IMG_PENDING_VERIFY) {
return esp_ota_mark_app_valid_cancel_rollback() == ESP_OK;
}
#endif
return false;
}
bool OtaUpdater::setMd5(const uint8_t * data, size_t len) {
return parseMd5Digest(data, len, _md5);
}
void OtaUpdater::clearMd5() {
_md5.front() = '\0';
_md5_applied = false;
}
bool OtaUpdater::hasMd5() const {
return strlen(_md5.data()) == _md5.size() - 1;
}
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<uint8_t *>(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();
}
+76
View File
@@ -0,0 +1,76 @@
#ifndef OtaUpdater_h
#define OtaUpdater_h
#include <Arduino.h>
#include <esp_ota_ops.h>
#include <array>
#ifdef OTA_UPDATER_ANY_PARTITION
#include <MD5Builder.h>
#endif
// Validates and streams firmware (.bin), LittleFS images (*littlefs.bin) and MD5 digests (.md5) into flash.
// Shared between the EMS-ESP application and the recovery application.
class OtaUpdater {
public:
enum class FileType : uint8_t { UNSUPPORTED, FIRMWARE, FILESYSTEM, JSON, MD5 };
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<char, 33> & out);
// With bootloader rollback a new image boots as pending verification until confirmed, and the
// IDF refuses to start another firmware update until then. Returns true if it was pending.
static bool confirmRunningApp();
bool setMd5(const uint8_t * data, size_t len);
void clearMd5();
bool hasMd5() const;
bool md5Applied() const {
return _md5_applied;
}
const char * md5() const {
return _md5.data();
}
// filesize is the HTTP content length, which is slightly bigger than the image.
// Without a target the image goes to the next OTA partition after the running one. Writing to a
// specific partition needs OTA_UPDATER_ANY_PARTITION, which adds ~1.7KB.
bool beginFirmware(size_t filesize, const esp_partition_t * target = nullptr, bool set_boot = true);
bool beginFilesystem();
bool write(const uint8_t * data, size_t len);
bool end(); // on success the new firmware becomes the boot partition, unless set_boot was false
void abort();
size_t progress() const;
const char * errorString() const;
private:
std::array<char, 33> _md5{};
bool _md5_applied = false;
const char * _error = "";
#ifdef OTA_UPDATER_ANY_PARTITION
// 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
+1 -1
View File
@@ -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"
}
+61 -61
View File
@@ -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'
+214
View File
@@ -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);
});
}
};
}
+10
View File
@@ -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');
+64 -1
View File
@@ -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
+5 -1
View File
@@ -1351,4 +1351,8 @@ zyxwvutsrqponmlkjihgfedcba
öffnen
česky
živanović
neopixel
neopixel
firmwares
recognises
POWERON
crashtest
+840
View File
@@ -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 <Arduino.h>
#include <ArduinoJson.h>
#include <AsyncJson.h>
#include <DNSServer.h>
#include <ESPAsyncWebServer.h>
#include <ESPmDNS.h>
#include <LittleFS.h>
#include <OtaUpdater.h>
#include <Preferences.h>
#include <WiFi.h>
#if CONFIG_IDF_TARGET_ESP32
#include <ETH.h>
#endif
#include <esp_image_format.h>
#include <esp_ota_ops.h>
#include <esp_partition.h>
#include <esp_random.h>
#include <vector>
#include "WWWData.h" // generated by `pnpm build-recovery` in ./interface
#include "recovery_version.h"
#ifndef FACTORY_AP_CHANNEL
#define FACTORY_AP_CHANNEL 1
#endif
static constexpr size_t MIN_FIRMWARE_SIZE = 512 * 1024;
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<AdminUser> admins;
};
struct PartitionInfo {
String label;
bool factory;
uint32_t address;
uint32_t size;
bool valid;
uint32_t image_size;
String version;
uint32_t install_date; // UTC epoch, 0 if unknown
String ota_state; // bootloader rollback state, empty if the partition has never been selected
};
static AsyncWebServer server(80);
static DNSServer dnsServer;
static Settings settings;
static OtaUpdater ota;
static std::vector<PartitionInfo> partitions;
static uint32_t restart_at = 0;
static String session_token;
static String ap_ssid;
static String recovery_reason; // set by EMS-ESP when it switched to the recovery
static const char * eth_state = ""; // "", "disabled", "failed" or "started"
static uint32_t wifi_join_at = 0; // when to try WiFi if Ethernet has no address, 0 if not waiting
static bool wifi_started = false;
static bool ethConnected();
// upload state, only one upload at a time
static int upload_error = 0;
static bool upload_done = false;
static String upload_target;
// ----------------------------------------------------------------------------
// 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<JsonArray>()) {
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<JsonObject>();
root["recovery_version"] = RECOVERY_VERSION;
root["authenticated"] = isAuthorized(request);
root["hostname"] = settings.hostname;
root["chip"] = ESP.getChipModel();
root["flash_size"] = ESP.getFlashChipSize();
root["psram_size"] = ESP.getPsramSize();
root["free_heap"] = ESP.getFreeHeap();
root["mac"] = WiFi.macAddress();
root["uptime"] = millis() / 1000;
root["reason"] = recovery_reason;
root["reset_reason"] = resetReasonName(esp_reset_reason());
root["ap_ssid"] = ap_ssid;
root["ap_ip"] = WiFi.softAPIP().toString();
root["ap_clients"] = WiFi.softAPgetStationNum();
root["wifi_ssid"] = settings.wifi_ssid;
root["wifi_connected"] = WiFi.isConnected();
if (WiFi.isConnected()) {
root["wifi_ip"] = WiFi.localIP().toString();
root["wifi_rssi"] = WiFi.RSSI();
}
root["wifi_started"] = wifi_started;
root["static_ip"] = settings.static_ip;
// only on boards with Ethernet configured
if (eth_state[0] != '\0') {
#if CONFIG_IDF_TARGET_ESP32
if (ethConnected()) {
root["eth_state"] = "connected";
root["eth_ip"] = ETH.localIP().toString();
} else if (strcmp(eth_state, "started") == 0) {
root["eth_state"] = ETH.linkUp() ? "connecting" : "no_link";
} else
#endif
{
root["eth_state"] = eth_state;
}
}
const esp_partition_t * boot = esp_ota_get_boot_partition();
const esp_partition_t * running = esp_ota_get_running_partition();
const esp_partition_t * target = defaultUploadTarget();
root["running"] = running ? running->label : "";
root["boot"] = boot ? boot->label : "";
root["upload_target"] = target ? target->label : "";
JsonArray parts = root["partitions"].to<JsonArray>();
for (const auto & info : partitions) {
JsonObject p = parts.add<JsonObject>();
p["label"] = info.label;
p["factory"] = info.factory;
p["address"] = info.address;
p["size"] = info.size;
p["valid"] = info.valid;
p["image_size"] = info.image_size;
p["version"] = info.version;
p["install_date"] = info.install_date;
p["ota_state"] = info.ota_state;
}
response->setLength();
request->send(response);
}
static void handleSignIn(AsyncWebServerRequest * request, JsonVariant & json) {
const String username = json["username"] | "";
const String password = json["password"] | "";
for (const auto & admin : settings.admins) {
if (admin.username == username && admin.password == password) {
session_token = generateToken();
auto * response = new AsyncJsonResponse();
JsonObject root = response->getRoot().to<JsonObject>();
root["access_token"] = session_token;
response->setLength();
request->send(response);
LOG("User %s signed in", username.c_str());
return;
}
}
LOG("Failed sign in for user %s", username.c_str());
request->send(401);
}
static void handleSetBoot(AsyncWebServerRequest * request, JsonVariant & json) {
if (!isAuthorized(request)) {
request->send(401);
return;
}
const String label = json["partition"] | "";
const esp_partition_t * part = findAppPartition(label.c_str());
if (part == nullptr) {
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<unsigned>(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<unsigned>(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<unsigned>(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<JsonObject>();
root["md5_ok"] = true;
response->setLength();
request->send(response);
} else {
request->send(200);
}
ota.clearMd5();
return;
}
if (ota.hasMd5()) {
auto * response = new AsyncJsonResponse();
JsonObject root = response->getRoot().to<JsonObject>();
root["md5"] = ota.md5();
response->setLength();
request->send(response);
return;
}
request->send(500);
}
// ----------------------------------------------------------------------------
// setup & loop
// ----------------------------------------------------------------------------
static void joinWiFi() {
wifi_join_at = 0;
if (settings.wifi_ssid.isEmpty()) {
return;
}
WiFi.mode(WIFI_AP_STA);
if (settings.static_ip) {
WiFi.config(settings.local_ip, settings.gateway_ip, settings.subnet_mask, settings.dns_ip_1, settings.dns_ip_2);
}
WiFi.begin(settings.wifi_ssid.c_str(), settings.wifi_password.c_str());
wifi_started = true;
LOG("Connecting to WiFi %s%s", settings.wifi_ssid.c_str(), settings.static_ip ? (" with IP " + settings.local_ip.toString()).c_str() : "");
}
// same bring-up as Network::startEthernet() in src/core/network.cpp
static bool startEthernet() {
#if CONFIG_IDF_TARGET_ESP32
if (settings.phy_type == 0) {
return false;
}
const auto clock = static_cast<eth_clock_mode_t>(settings.eth_clock_mode);
if (ESP.getPsramSize() && (clock == ETH_CLOCK_GPIO16_OUT || clock == ETH_CLOCK_GPIO17_OUT)) {
LOG("Ethernet clock mode %d conflicts with PSRAM, Ethernet disabled", settings.eth_clock_mode);
eth_state = "disabled";
return false;
}
// reset the PHY, it doesn't always start after a warm boot
if (settings.eth_power != -1) {
pinMode(settings.eth_power, OUTPUT);
digitalWrite(settings.eth_power, LOW);
delay(500);
digitalWrite(settings.eth_power, HIGH);
}
if (settings.eth_10mbit) {
ETH.setAutoNegotiation(false);
ETH.setLinkSpeed(10);
ETH.setFullDuplex(false);
}
const eth_phy_type_t type = settings.phy_type == 1 ? ETH_PHY_LAN8720 : settings.phy_type == 2 ? ETH_PHY_TLK110 : ETH_PHY_RTL8201;
if (!ETH.begin(type, settings.eth_phy_addr, 23, 18, settings.eth_power, clock)) {
LOG("Ethernet failed to start, no PHY found");
eth_state = "failed";
return false;
}
if (settings.static_ip) {
ETH.config(settings.local_ip, settings.gateway_ip, settings.subnet_mask, settings.dns_ip_1, settings.dns_ip_2);
}
eth_state = "started";
LOG("Ethernet started%s", settings.static_ip ? (" with IP " + settings.local_ip.toString()).c_str() : "");
return true;
#else
return false;
#endif
}
static bool ethConnected() {
#if CONFIG_IDF_TARGET_ESP32
return strcmp(eth_state, "started") == 0 && ETH.hasIP();
#else
return false;
#endif
}
static void startNetwork() {
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);
}
+1
View File
@@ -0,0 +1 @@
#define RECOVERY_VERSION "1.0.0"
+59
View File
@@ -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")
+8 -6
View File
@@ -11,14 +11,16 @@ OUTPUT_DIR = Path("build")
def bin_copy(source, target, env):
"""Optimized firmware renaming and copying function."""
# Get the application version from emsesp_version.h
version_file = Path('./src/emsesp_version.h')
# Get the application version, from emsesp_version.h unless overridden in the env
fw_name = env.GetProjectOption("custom_fw_name", "EMS-ESP")
version_file = Path(env.GetProjectOption("custom_version_file", "./src/emsesp_version.h"))
version_define = env.GetProjectOption("custom_version_define", "EMSESP_APP_VERSION")
if not version_file.exists():
print("Error: emsesp_version.h not found!")
print(f"Error: {version_file} not found!")
return
app_version = None
version_pattern = re.compile(r'^#define EMSESP_APP_VERSION\s+"(\S+)"')
version_pattern = re.compile(rf'^#define {version_define}\s+"(\S+)"')
with version_file.open('r') as f:
for line in f:
@@ -28,7 +30,7 @@ def bin_copy(source, target, env):
break
if not app_version:
print("Error: Could not find EMSESP_APP_VERSION in emsesp_version.h!")
print(f"Error: Could not find {version_define} in {version_file}!")
return
# Get the chip type, in uppercase
@@ -61,7 +63,7 @@ def bin_copy(source, target, env):
# Convert . to _ so Windows doesn't complain
# Format is EMS-ESP-<version>-<mcu>-<flash> with + at the end if it has PSRAM
variant = f"EMS-ESP-{app_version.replace('.', '_')}-{mcu}-{flash_mem}{'+' if psram else ''}"
variant = f"{fw_name}-{app_version.replace('.', '_')}-{mcu}-{flash_mem}{'+' if psram else ''}"
# Create output directories
firmware_dir = OUTPUT_DIR / "firmware"
+69
View File
@@ -0,0 +1,69 @@
# Writes the project name (custom_fw_name) and version (custom_version_file/custom_version_define) into the
# esp_app_desc_t of firmware.bin, which the prebuilt Arduino libraries leave empty. The application can then
# identify the image with esp_ota_get_partition_description(), or from the first bytes of an upload.
# Must run before rename_fw.py so the renamed copy and its MD5 include the change.
import hashlib
import re
import struct
from pathlib import Path
Import("env")
IMAGE_HEADER_LEN = 24
SEGMENT_HEADER_LEN = 8
APP_DESC_OFFSET = IMAGE_HEADER_LEN + SEGMENT_HEADER_LEN
APP_DESC_MAGIC = 0xABCD5432
HASH_APPENDED_OFFSET = 23
def read_version(env):
version_file = Path(env.GetProjectOption("custom_version_file", "./src/emsesp_version.h"))
version_define = env.GetProjectOption("custom_version_define", "EMSESP_APP_VERSION")
pattern = re.compile(rf'^#define {version_define}\s+"(\S+)"')
for line in version_file.read_text().splitlines():
match = pattern.match(line)
if match:
return match.group(1)
raise ValueError(f"{version_define} not found in {version_file}")
def put_string(image, offset, value):
encoded = value.encode()[:31]
image[offset:offset + 32] = encoded + b"\0" * (32 - len(encoded))
def set_app_desc(source, target, env):
path = Path(target[0].get_abspath())
image = bytearray(path.read_bytes())
(magic,) = struct.unpack_from("<I", image, APP_DESC_OFFSET)
if magic != APP_DESC_MAGIC:
print(f"Error: no app description found in {path}")
env.Exit(1)
name = env.GetProjectOption("custom_fw_name", "EMS-ESP")
version = read_version(env)
put_string(image, APP_DESC_OFFSET + 16, version)
put_string(image, APP_DESC_OFFSET + 48, name)
# the description lives in the first segment, so the image checksum and SHA256 need updating
checksum = 0xEF
pos = IMAGE_HEADER_LEN
for _ in range(image[1]):
_, length = struct.unpack_from("<II", image, pos)
pos += SEGMENT_HEADER_LEN
for b in image[pos:pos + length]:
checksum ^= b
pos += length
pos += 15 - (pos % 16)
image[pos] = checksum
pos += 1
if image[HASH_APPENDED_OFFSET] == 1:
image[pos:pos + 32] = hashlib.sha256(image[:pos]).digest()
path.write_bytes(image)
print(f"App description set to {name} {version}")
env.AddPostAction("$BUILD_DIR/${PROGNAME}.bin", set_app_desc)
+11 -2
View File
@@ -6,9 +6,18 @@
# abort on first error so a failed lint/build isn't silently swallowed
set -e
# pnpm refuses to self-update when it's a Corepack shim; Corepack pins the version via packageManager instead
update_pnpm() {
if readlink "$(command -v pnpm)" | grep -q corepack; then
corepack use pnpm@latest
else
pnpm self-update
fi
}
cd interface
rm -rf node_modules
pnpm self-update
update_pnpm
pnpm update
pnpm install
pnpm format
@@ -17,7 +26,7 @@ pnpm lint
cd ../mock-api
rm -rf node_modules
pnpm self-update
update_pnpm
pnpm update
pnpm install
pnpm format
+28
View File
@@ -0,0 +1,28 @@
# Makes `pio run -t upload` write the app into the factory partition instead of ota_0.
#
# Set `custom_app_offset` to the factory partition offset. The otadata image (boot_app0.bin) that
# PlatformIO normally writes selects ota_0, so it's swapped for a blank one, which makes the
# bootloader start the factory app.
from pathlib import Path
Import("env")
OTADATA_SIZE = 0x2000
offset = env.GetProjectOption("custom_app_offset", "")
if offset:
# ESP32_APP_OFFSET itself is recalculated from the partition table later in the build
env.Replace(UPLOADCMD=env.get("UPLOADCMD", "").replace("$ESP32_APP_OFFSET", offset))
blank_otadata = Path(env.subst("$BUILD_DIR")) / "otadata_blank.bin"
blank_otadata.parent.mkdir(parents=True, exist_ok=True)
blank_otadata.write_bytes(b"\xff" * OTADATA_SIZE)
def swap(path):
return str(blank_otadata) if Path(env.subst(str(path))).name == "boot_app0.bin" else path
# the platform has already expanded FLASH_EXTRA_IMAGES into UPLOADERFLAGS, so patch both
env.Replace(FLASH_EXTRA_IMAGES=[(o, swap(p)) for o, p in env.get("FLASH_EXTRA_IMAGES", [])])
env.Replace(UPLOADERFLAGS=[swap(flag) for flag in env.get("UPLOADERFLAGS", [])])
print(f"Upload: app at {offset}, otadata blanked")
+79 -96
View File
@@ -2,44 +2,13 @@
#include <emsesp.h>
#include <esp_app_format.h>
#include <esp_ota_ops.h>
static String getFilenameExtension(const String & filename) {
const auto pos = filename.lastIndexOf('.');
if (pos != -1) {
return filename.substring(static_cast<unsigned int>(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<char, 33> & out) {
size_t i = 0;
while (i < len && (data[i] == ' ' || data[i] == '\t' || data[i] == '\r' || data[i] == '\n')) {
++i;
}
if (len - i < 32) {
return false;
}
for (size_t n = 0; n < 32; n++) {
const char c = static_cast<char>(data[i + n]);
const bool hex = (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F');
if (!hex) {
return false;
}
out[n] = (c >= 'A' && c <= 'F') ? static_cast<char>(c - 'A' + 'a') : c;
}
out[32] = '\0';
return true;
}
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<unsigned>(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<unsigned>(Update.progress()),
static_cast<unsigned>(_ota.progress()),
static_cast<unsigned>(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;
}
+6 -8
View File
@@ -6,11 +6,9 @@
#include <Arduino.h>
#include <ESPAsyncWebServer.h>
#include <LittleFS.h>
#include <Update.h>
#include <OtaUpdater.h>
#include <WiFi.h>
#include <array>
#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<char, 33> _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);
+2
View File
@@ -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();
+2
View File
@@ -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:
+20
View File
@@ -18,12 +18,32 @@
#include "emsesp.h"
#ifdef EMSESP_CRASH_TEST
#include <esp_ota_ops.h>
#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() {
+117
View File
@@ -27,6 +27,7 @@
#include <esp_mac.h>
#include "esp_efuse.h"
#include <nvs.h>
#include <Update.h>
#endif
#include <map>
@@ -43,10 +44,24 @@
#define ENABLE_SMTP
#include <ESP_SSLClient.h>
#include <ReadyMail.h>
#include <OtaUpdater.h>
// 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");
+13
View File
@@ -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);
+3
View File
@@ -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;