mirror of
https://github.com/emsesp/EMS-ESP32.git
synced 2026-10-28 11:14:58 +00:00
add recovery firmware with OTA updater and recovery web interface
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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,6 +1,7 @@
|
||||
node_modules/
|
||||
build/
|
||||
dist/
|
||||
dist-recovery/
|
||||
src/i18n/*
|
||||
|
||||
.prettierrc
|
||||
|
||||
@@ -18,6 +18,7 @@ export default defineConfig(
|
||||
{
|
||||
ignores: [
|
||||
'dist/*',
|
||||
'dist-recovery/*',
|
||||
'*.mjs',
|
||||
'build/*',
|
||||
'*.js',
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
Generated
+141
-141
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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}…
|
||||
</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;
|
||||
@@ -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' }}>
|
||||
|
|
||||
</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;
|
||||
@@ -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…
|
||||
</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;
|
||||
@@ -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;
|
||||
@@ -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() };
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -52,6 +52,7 @@ export interface SystemStatus {
|
||||
model: string;
|
||||
board: string;
|
||||
has_loader: boolean;
|
||||
recovery_version?: string;
|
||||
has_partition: boolean;
|
||||
partitions: {
|
||||
partition: string;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
Generated
+61
-61
@@ -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'
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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
@@ -1351,4 +1351,8 @@ zyxwvutsrqponmlkjihgfedcba
|
||||
öffnen
|
||||
česky
|
||||
živanović
|
||||
neopixel
|
||||
neopixel
|
||||
firmwares
|
||||
recognises
|
||||
POWERON
|
||||
crashtest
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
#define RECOVERY_VERSION "1.0.0"
|
||||
@@ -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")
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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")
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user