mirror of
https://github.com/emsesp/EMS-ESP32.git
synced 2026-10-28 11:14:58 +00:00
crashmode
This commit is contained in:
+14
-15
@@ -8,24 +8,23 @@ This guide covers testing on hardware of:
|
||||
- the fallback to the recovery after repeated crashes;
|
||||
- Ethernet and static IP support in the recovery.
|
||||
|
||||
It's written for an ESP32-S3 16MB board, since the crash test build is only defined for `s3_16M_P`. Ethernet testing needs an E32V2. Keep a USB serial cable connected with a serial monitor open throughout, as most of the checks are log lines.
|
||||
It's written for an ESP32 16MB board with PSRAM (`s_16M_P`, e.g. a BBQKees E32V2). Other 16MB boards (`s_16M`, `s3_16M_P`) follow the same steps with their matching PlatformIO envs; Ethernet is E32V2 only. Keep a USB serial cable connected with a serial monitor open throughout, as most of the checks are log lines.
|
||||
|
||||
## 0. Preparation
|
||||
|
||||
Build everything:
|
||||
|
||||
```bash
|
||||
pio run -e s3_16M_P -e crashtest_s3_16M_P -e recovery_s3_16M_P
|
||||
pio run -e s_16M -e s_16M_P -e s_4M -e recovery_s_16M -e recovery_s_16M_P
|
||||
pio run -e s_16M_P -e crashtest_s_16M_P -e recovery_s_16M_P
|
||||
```
|
||||
|
||||
The firmware files are written to `build/firmware/`:
|
||||
|
||||
| File | What it is |
|
||||
| --- | --- |
|
||||
| `EMS-ESP-<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 |
|
||||
| `EMS-ESP-<version>-ESP32-16MB+.bin` | EMS-ESP |
|
||||
| `EMS-ESP-CrashTest-<version>-ESP32-16MB+.bin` | crash test build |
|
||||
| `EMS-ESP-Recovery-<version>-ESP32-16MB+.bin` | the recovery |
|
||||
|
||||
Each has a matching `.md5` file.
|
||||
|
||||
@@ -33,13 +32,13 @@ Then:
|
||||
|
||||
1. Run the unit tests with `pio run -e native-test -t exec`. All tests should pass.
|
||||
2. Optionally, check the recovery UI against mock data first: `cd interface && pnpm recovery`, then open <http://localhost:3001>.
|
||||
3. Open the serial monitor with `pio device monitor -e s3_16M_P`. Recovery log lines start with `[recovery]`.
|
||||
3. Open the serial monitor with `pio device monitor -e s_16M_P`. Recovery log lines start with `[recovery]`.
|
||||
|
||||
The crash counter is kept in RTC memory. It survives crashes and software restarts but not a power cycle, so don't unplug the board during the crash tests.
|
||||
|
||||
## 1. Baseline: EMS-ESP over serial, no recovery
|
||||
|
||||
1. Flash with `pio run -e s3_16M_P -t upload`. This also writes the current bootloader, which has rollback enabled. Boards flashed with an older bootloader don't roll back until they've been flashed over serial once.
|
||||
1. Flash with `pio run -e s_16M_P -t upload`. This also writes the current bootloader, which has rollback enabled. Boards flashed with an older bootloader don't roll back until they've been flashed over serial once.
|
||||
2. In the WebUI, open **Settings → Version**. Check that:
|
||||
- the **Recovery** row shows "not installed";
|
||||
- the firmware partition list doesn't include `boot`.
|
||||
@@ -54,7 +53,7 @@ The crash counter is kept in RTC memory. It survives crashes and software restar
|
||||
- the install logged.
|
||||
2. Repeat, uploading the `.md5` first and then the `.bin`. The MD5 check should pass.
|
||||
3. Negative tests:
|
||||
- Upload the recovery built for another chip, e.g. the ESP32 file on an S3. It should be rejected, and nothing should be written.
|
||||
- Upload the recovery built for another chip, e.g. the ESP32-S3 file on this ESP32. It should be rejected, and nothing should be written.
|
||||
- Start a recovery upload and close the browser tab halfway through. EMS-ESP should keep running and the Recovery row should show "not installed". Reinstall it properly afterwards.
|
||||
4. Restart EMS-ESP normally. It should boot EMS-ESP again, not the recovery, because installing the recovery doesn't change the boot partition.
|
||||
|
||||
@@ -67,14 +66,14 @@ The crash counter is kept in RTC memory. It survives crashes and software restar
|
||||
- `[recovery] EMS-ESP Recovery v…` is logged;
|
||||
- LittleFS is mounted;
|
||||
- the access point has started;
|
||||
- WiFi connects.
|
||||
- Ethernet or WiFi connects.
|
||||
3. Join the access point. Its name is the AP SSID plus `-recovery` (default `ems-esp-recovery`), and it uses the AP password (default `ems-esp-neo`). Open <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.
|
||||
- the System section shows the chip, flash, MAC, access point, and the active network (Ethernet or WiFi) with its IP.
|
||||
6. Click **Restart**. It should come back into the recovery, still without a reason box, since the reason is only shown once.
|
||||
7. Click **Start** on the EMS-ESP slot. EMS-ESP should boot, and the Version page should still show the recovery version.
|
||||
|
||||
@@ -114,7 +113,7 @@ If step 3 ends in a crash loop instead of a rollback, the bootloader on the boar
|
||||
|
||||
## 6. Crash-loop fallback to the recovery (crash test mode 2)
|
||||
|
||||
1. In `platformio.ini`, change `-D EMSESP_CRASH_TEST=1` to `=2`, then run `pio run -e crashtest_s3_16M_P`.
|
||||
1. In `platformio.ini`, change `-D EMSESP_CRASH_TEST=1` to `=2`, then run `pio run -e crashtest_s_16M_P`.
|
||||
2. Upload the crash test build from the WebUI. It runs normally.
|
||||
3. Wait for "New firmware confirmed as working", about 2 minutes after boot.
|
||||
4. Restart it from the WebUI. It should crash 5 times in a row, logging the "N in a row" count each time.
|
||||
@@ -128,7 +127,7 @@ If step 3 ends in a crash loop instead of a rollback, the bootloader on the boar
|
||||
|
||||
Optional, without a recovery installed:
|
||||
|
||||
1. Erase the boot partition with `esptool --chip esp32s3 erase-region 0x10000 0x480000`. Older esptool versions use `erase_region`.
|
||||
1. Erase the boot partition with `esptool --chip esp32 erase-region 0x10000 0x480000`. Older esptool versions use `erase_region`.
|
||||
2. Repeat steps 2 to 4. It should keep crashing past 5, and needs to be reflashed over serial.
|
||||
|
||||
## 7. Network in the recovery
|
||||
@@ -136,7 +135,7 @@ Optional, without a recovery installed:
|
||||
| Test | Setup | Expected in the recovery |
|
||||
| --- | --- | --- |
|
||||
| WiFi with static IP | Enable a static IP in the EMS-ESP Network settings, then `restart boot` | Same IP as EMS-ESP, and the WiFi row ends with ", static IP" |
|
||||
| Ethernet (E32V2) | Cable plugged in, `recovery_s_16M_P` installed, then `restart boot` | Log shows "Ethernet started" and "Ethernet connected, IP …". The Ethernet row shows "connected", and WiFi shows "not used, Ethernet is connected" |
|
||||
| Ethernet | Cable plugged in, then `restart boot` | Log shows "Ethernet started" and "Ethernet connected, IP …". The Ethernet row shows "connected", and WiFi shows "not used, Ethernet is connected" |
|
||||
| Ethernet with static IP | As above, with a static IP set | Ethernet uses the static IP and WiFi isn't started |
|
||||
| No cable | Unplugged, then `restart boot` | Ethernet shows "no cable connected" and WiFi shows "waiting for Ethernet". After about 15 seconds the log shows "No Ethernet connection" and WiFi connects |
|
||||
| Cable plugged in later | Plug in after WiFi has joined | Ethernet gets an IP as well, and WiFi stays connected |
|
||||
@@ -153,4 +152,4 @@ Optional, without a recovery installed:
|
||||
|
||||
## Reset to a clean state
|
||||
|
||||
`pio run -e s3_16M_P -t upload` restores EMS-ESP at any point. It leaves the recovery in `boot` and the settings untouched.
|
||||
`pio run -e s_16M_P -t upload` restores EMS-ESP at any point. It leaves the recovery in `boot` and the settings untouched.
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
# EMS-ESP Recovery Firmware
|
||||
|
||||
Small standalone app that lives in the factory (`boot`) partition on **16MB** boards. If EMS-ESP will not start, the recovery still can: it brings up an access point (and the saved WiFi/Ethernet if it can) and serves a cut-down WebUI to upload a new EMS-ESP image into `app0`/`app1` or choose which slot to boot.
|
||||
|
||||
4MB boards have no factory partition and no recovery.
|
||||
|
||||
Hardware test steps are in [Recovery-Testing.md](Recovery-Testing.md).
|
||||
|
||||
## Why it exists
|
||||
|
||||
ESP-IDF already rolls a failed OTA back to the **other** OTA slot (`app0` ↔ `app1`). That does not help when:
|
||||
|
||||
- both OTA slots are empty, corrupt, or crash on start
|
||||
- a serial flash left otadata pointing at an empty slot
|
||||
- the user needs a known-good way back in without a USB cable
|
||||
|
||||
Recovery is a third, smaller image that EMS-ESP does not overwrite during a normal upgrade. Installing it does **not** change which app boots.
|
||||
|
||||
## Partitions (16MB)
|
||||
|
||||
From `partitions/esp32_partition_16M.csv`:
|
||||
|
||||
| Label | Subtype | Role |
|
||||
| --- | --- | --- |
|
||||
| `boot` | factory | Recovery image |
|
||||
| `app0` | ota_0 | EMS-ESP |
|
||||
| `app1` | ota_1 | EMS-ESP (the other OTA slot) |
|
||||
| `otadata` | ota | Which OTA slot the bootloader starts |
|
||||
|
||||
The bootloader reads **otadata**, not “whatever was flashed last”:
|
||||
|
||||
1. Blank otadata (`0xFF`, e.g. after `erase-flash` or a recovery serial upload) → start **factory** (`boot`).
|
||||
2. Valid otadata → start that OTA slot (`app0` or `app1`).
|
||||
3. If that image is invalid, it walks **down** toward factory, then **up** through remaining OTA slots. A valid recovery in `boot` is therefore preferred over a valid image sitting only in `app1` while otadata still points at empty `app0`.
|
||||
|
||||
PlatformIO’s `boot_app0.bin` always selects `app0`. The recovery upload script (`scripts/upload_factory.py`) replaces it with a blank otadata so a serial flash of recovery actually boots recovery.
|
||||
|
||||
A normal serial flash of EMS-ESP goes to `app0` and writes `boot_app0.bin`, so the board starts EMS-ESP. Recovery, if already in `boot`, is left in place.
|
||||
|
||||
## How recovery is installed
|
||||
|
||||
From a running EMS-ESP (**Settings → Version**, or the upload drop zone):
|
||||
|
||||
- Drop `EMS-ESP-Recovery-*.bin` (optional `.md5` first).
|
||||
- EMS-ESP recognises it from the app description (`project_name` = `EMS-ESP-Recovery`) and writes it to the factory partition **without** calling `esp_ota_set_boot_partition`.
|
||||
- EMS-ESP keeps running. The Version page shows the recovery version once it is in flash.
|
||||
|
||||
From serial, recovery must be written at **`0x10000`**, not at `0x1000` (that is the bootloader; the recovery `.bin` overlaps the partition table). See `scripts/do_fresh_install.sh`.
|
||||
|
||||
The recovery app itself **refuses** to install a recovery image. It only writes EMS-ESP into `app0`/`app1`.
|
||||
|
||||
## How EMS-ESP boots into recovery
|
||||
|
||||
Only 16MB builds with `-DEMSESP_HAS_RECOVERY` and a valid recovery image in `boot`.
|
||||
|
||||
| How | What happens |
|
||||
| --- | --- |
|
||||
| **Settings → Version** | Developer mode shows a Recovery row. If a version is installed, **Restart** confirms, then `setPartition("boot")`. |
|
||||
| **Console** (serial or telnet, admin) | `restart boot` → `system_restart("boot")`. |
|
||||
| **Button** | Long press ~3 s (not the ~10 s factory reset). |
|
||||
| **Crash loop** | 5 panics or watchdog resets in a row (RTC counter; survives software restart, not a power cycle). |
|
||||
|
||||
The first three record NVS `rec_reason=request`. The crash path records `crash`. Recovery reads that once and clears it, so a user-requested entry does not show the red crash banner.
|
||||
|
||||
These do **not** enter recovery:
|
||||
|
||||
- Uploading the recovery `.bin` (install only).
|
||||
- `/api/system/restart` or MQTT `system/restart` (ignore any partition argument; reboot the current app).
|
||||
- Bootloader rollback of a pending OTA (goes to the previous **OTA** slot).
|
||||
|
||||
## What recovery does
|
||||
|
||||
- Access point `ems-esp-recovery` (AP SSID plus `-recovery`), plus the saved LAN (Ethernet preferred, else WiFi, including a static IP if one is set).
|
||||
- Sign-in with an EMS-ESP admin account (settings are read from LittleFS, never written).
|
||||
- List `boot` / `app0` / `app1`, show which is running, which will boot next, and rollback state.
|
||||
- Upload an EMS-ESP `.bin` into an OTA slot (empty slot, or the older of the two if both are filled) and boot it.
|
||||
- **Start** a chosen OTA slot without uploading.
|
||||
|
||||
After a WebUI/OTA install from recovery, the new EMS-ESP image is pending verification. After ~2 minutes of uptime it is confirmed so the bootloader will not roll it back.
|
||||
|
||||
## Rough boot flow
|
||||
|
||||
```
|
||||
reset
|
||||
→ ESP-IDF bootloader
|
||||
otadata blank? → factory/boot (recovery)
|
||||
otadata → app0/1 → that slot if the image is valid
|
||||
selected image bad → try factory, then the other OTA slot
|
||||
→ recovery or EMS-ESP
|
||||
|
||||
EMS-ESP, if it starts:
|
||||
crash counter (RTC)
|
||||
5 crashes in a row + recovery present → set boot=factory, restart
|
||||
healthy for 2 minutes → confirm OTA image, clear crash counter
|
||||
```
|
||||
|
||||
## Builds
|
||||
|
||||
| Env | Image |
|
||||
| --- | --- |
|
||||
| `recovery_s_16M` / `recovery_s_16M_P` / `recovery_s3_16M_P` | Recovery, flashed into `boot` at `0x10000` |
|
||||
| `s_16M` / `s_16M_P` / `s3_16M_P` | EMS-ESP (`app0` on serial upload) |
|
||||
|
||||
Firmware files land in `build/firmware/` as `EMS-ESP-Recovery-<ver>-<chip>-16MB[+].bin`.
|
||||
@@ -57,7 +57,7 @@
|
||||
"terser": "^5.51.2",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.71.1",
|
||||
"vite": "^8.3.2",
|
||||
"vite": "^8.3.3",
|
||||
"vite-plugin-imagemin": "^0.6.1"
|
||||
},
|
||||
"packageManager": "pnpm@12.9.1"
|
||||
|
||||
Generated
+21
-21
@@ -214,7 +214,7 @@ importers:
|
||||
version: 10.0.1(eslint@10.12.0(supports-color@10.2.2))
|
||||
'@preact/preset-vite':
|
||||
specifier: ^2.10.6
|
||||
version: 2.10.6(@babel/core@7.29.7(supports-color@10.2.2))(preact@11.0.0)(supports-color@10.2.2)(vite@8.3.2(@types/node@26.6.4)(terser@5.51.2))
|
||||
version: 2.10.6(@babel/core@7.29.7(supports-color@10.2.2))(preact@11.0.0)(supports-color@10.2.2)(vite@8.3.3(@types/node@26.6.4)(terser@5.51.2))
|
||||
'@trivago/prettier-plugin-sort-imports':
|
||||
specifier: ^6.0.2
|
||||
version: 6.0.2(prettier@3.9.9)(supports-color@10.2.2)
|
||||
@@ -258,11 +258,11 @@ importers:
|
||||
specifier: ^8.71.1
|
||||
version: 8.71.1(eslint@10.12.0(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)
|
||||
vite:
|
||||
specifier: ^8.3.2
|
||||
version: 8.3.2(@types/node@26.6.4)(terser@5.51.2)
|
||||
specifier: ^8.3.3
|
||||
version: 8.3.3(@types/node@26.6.4)(terser@5.51.2)
|
||||
vite-plugin-imagemin:
|
||||
specifier: ^0.6.1
|
||||
version: 0.6.1(supports-color@10.2.2)(vite@8.3.2(@types/node@26.6.4)(terser@5.51.2))
|
||||
version: 0.6.1(supports-color@10.2.2)(vite@8.3.3(@types/node@26.6.4)(terser@5.51.2))
|
||||
|
||||
packages:
|
||||
|
||||
@@ -1338,8 +1338,8 @@ packages:
|
||||
duplexer3@0.1.5:
|
||||
resolution: {integrity: sha512-1A8za6ws41LQgv9HrE/66jyC5yuSjQ3L/KOpFtoBilsAK2iA2wuS5rTt1OCzIvtS2V7nVmedsUU+DGRcjBmOYA==}
|
||||
|
||||
electron-to-chromium@1.5.444:
|
||||
resolution: {integrity: sha512-5ss/uJfoDYDHT0lfJzT6FbcskIzROIOPf0BbbFkGcvDzoJU7i//9GDrwwIHQVmIsrAGiF3ihpADBRIsrEFt1rQ==}
|
||||
electron-to-chromium@1.5.445:
|
||||
resolution: {integrity: sha512-iX7o1nF+qNb3Bv5mLYgqfgF9Znh9e/8MKXdmbb8Gv2Q47OKIzTwEPD1oda1HrgEwYatTLJj0wQjHvIFt8Db53Q==}
|
||||
|
||||
emoji-regex@10.6.0:
|
||||
resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==}
|
||||
@@ -3057,8 +3057,8 @@ packages:
|
||||
peerDependencies:
|
||||
vite: 5.x || 6.x || 7.x || 8.x
|
||||
|
||||
vite@8.3.2:
|
||||
resolution: {integrity: sha512-SQr1x6W5vVSbROg7vsyXIaxK9b0G7zsT68acdWWRmnBUsgDieLCRG+Rep9WdZgcposvv/GSnr4GUUBqB3vXq6w==}
|
||||
vite@8.3.3:
|
||||
resolution: {integrity: sha512-cTAldKPImjg6c+gk48U19POPn3GCBzZwpdsN8ZMEEcbpes+6/wvfqUd0C2y3qYY4wsj8PwgFwrZ/1jBPVttMSg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
@@ -3591,19 +3591,19 @@ snapshots:
|
||||
|
||||
'@popperjs/core@2.11.8': {}
|
||||
|
||||
'@preact/preset-vite@2.10.6(@babel/core@7.29.7(supports-color@10.2.2))(preact@11.0.0)(supports-color@10.2.2)(vite@8.3.2(@types/node@26.6.4)(terser@5.51.2))':
|
||||
'@preact/preset-vite@2.10.6(@babel/core@7.29.7(supports-color@10.2.2))(preact@11.0.0)(supports-color@10.2.2)(vite@8.3.3(@types/node@26.6.4)(terser@5.51.2))':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.7(supports-color@10.2.2)
|
||||
'@babel/plugin-transform-react-jsx': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)
|
||||
'@babel/plugin-transform-react-jsx-development': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)
|
||||
'@prefresh/vite': 2.4.12(preact@11.0.0)(supports-color@10.2.2)(vite@8.3.2(@types/node@26.6.4)(terser@5.51.2))
|
||||
'@prefresh/vite': 2.4.12(preact@11.0.0)(supports-color@10.2.2)(vite@8.3.3(@types/node@26.6.4)(terser@5.51.2))
|
||||
'@rollup/pluginutils': 5.4.0
|
||||
babel-plugin-transform-hook-names: 1.0.2(@babel/core@7.29.7(supports-color@10.2.2))
|
||||
debug: 4.4.3(supports-color@10.2.2)
|
||||
magic-string: 0.30.21
|
||||
picocolors: 1.1.1
|
||||
vite: 8.3.2(@types/node@26.6.4)(terser@5.51.2)
|
||||
vite-prerender-plugin: 0.5.14(vite@8.3.2(@types/node@26.6.4)(terser@5.51.2))
|
||||
vite: 8.3.3(@types/node@26.6.4)(terser@5.51.2)
|
||||
vite-prerender-plugin: 0.5.14(vite@8.3.3(@types/node@26.6.4)(terser@5.51.2))
|
||||
zimmerframe: 1.1.5
|
||||
transitivePeerDependencies:
|
||||
- preact
|
||||
@@ -3620,7 +3620,7 @@ snapshots:
|
||||
|
||||
'@prefresh/utils@1.2.1': {}
|
||||
|
||||
'@prefresh/vite@2.4.12(preact@11.0.0)(supports-color@10.2.2)(vite@8.3.2(@types/node@26.6.4)(terser@5.51.2))':
|
||||
'@prefresh/vite@2.4.12(preact@11.0.0)(supports-color@10.2.2)(vite@8.3.3(@types/node@26.6.4)(terser@5.51.2))':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.7(supports-color@10.2.2)
|
||||
'@prefresh/babel-plugin': 0.5.4(@babel/core@7.29.7(supports-color@10.2.2))
|
||||
@@ -3628,7 +3628,7 @@ snapshots:
|
||||
'@prefresh/utils': 1.2.1
|
||||
'@rollup/pluginutils': 4.2.1
|
||||
preact: 11.0.0
|
||||
vite: 8.3.2(@types/node@26.6.4)(terser@5.51.2)
|
||||
vite: 8.3.3(@types/node@26.6.4)(terser@5.51.2)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -4005,7 +4005,7 @@ snapshots:
|
||||
dependencies:
|
||||
baseline-browser-mapping: 2.11.27
|
||||
caniuse-lite: 1.0.30001814
|
||||
electron-to-chromium: 1.5.444
|
||||
electron-to-chromium: 1.5.445
|
||||
node-releases: 2.0.57
|
||||
update-browserslist-db: 1.3.3(browserslist@4.29.3)
|
||||
|
||||
@@ -4370,7 +4370,7 @@ snapshots:
|
||||
|
||||
duplexer3@0.1.5: {}
|
||||
|
||||
electron-to-chromium@1.5.444: {}
|
||||
electron-to-chromium@1.5.445: {}
|
||||
|
||||
emoji-regex@10.6.0: {}
|
||||
|
||||
@@ -5982,7 +5982,7 @@ snapshots:
|
||||
spdx-correct: 3.2.0
|
||||
spdx-expression-parse: 3.0.1
|
||||
|
||||
vite-plugin-imagemin@0.6.1(supports-color@10.2.2)(vite@8.3.2(@types/node@26.6.4)(terser@5.51.2)):
|
||||
vite-plugin-imagemin@0.6.1(supports-color@10.2.2)(vite@8.3.3(@types/node@26.6.4)(terser@5.51.2)):
|
||||
dependencies:
|
||||
'@types/imagemin': 7.0.1
|
||||
'@types/imagemin-gifsicle': 7.0.4
|
||||
@@ -6007,11 +6007,11 @@ snapshots:
|
||||
imagemin-webp: 6.1.0
|
||||
jpegtran-bin: 6.0.1
|
||||
pathe: 0.2.0
|
||||
vite: 8.3.2(@types/node@26.6.4)(terser@5.51.2)
|
||||
vite: 8.3.3(@types/node@26.6.4)(terser@5.51.2)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
vite-prerender-plugin@0.5.14(vite@8.3.2(@types/node@26.6.4)(terser@5.51.2)):
|
||||
vite-prerender-plugin@0.5.14(vite@8.3.3(@types/node@26.6.4)(terser@5.51.2)):
|
||||
dependencies:
|
||||
kolorist: 1.8.0
|
||||
magic-string: 0.30.21
|
||||
@@ -6019,9 +6019,9 @@ snapshots:
|
||||
simple-code-frame: 1.3.0
|
||||
source-map: 0.7.6
|
||||
stack-trace: 1.0.0
|
||||
vite: 8.3.2(@types/node@26.6.4)(terser@5.51.2)
|
||||
vite: 8.3.3(@types/node@26.6.4)(terser@5.51.2)
|
||||
|
||||
vite@8.3.2(@types/node@26.6.4)(terser@5.51.2):
|
||||
vite@8.3.3(@types/node@26.6.4)(terser@5.51.2):
|
||||
dependencies:
|
||||
lightningcss: 1.33.0
|
||||
picomatch: 4.0.7
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { memo, useContext } from 'react';
|
||||
|
||||
import LogoutIcon from '@mui/icons-material/Logout';
|
||||
import PersonIcon from '@mui/icons-material/Person';
|
||||
import {
|
||||
Avatar,
|
||||
@@ -53,6 +54,7 @@ const UserProfileComponent = () => {
|
||||
</Box>
|
||||
<Divider />
|
||||
<Button
|
||||
startIcon={<LogoutIcon />}
|
||||
sx={{ mt: 2 }}
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useContext, useEffect, useState } from 'react';
|
||||
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import PowerSettingsNewIcon from '@mui/icons-material/PowerSettingsNew';
|
||||
import SendIcon from '@mui/icons-material/Send';
|
||||
import WarningIcon from '@mui/icons-material/Warning';
|
||||
import {
|
||||
Box,
|
||||
@@ -500,6 +501,7 @@ const ApplicationSettings = () => {
|
||||
</Grid>
|
||||
<Grid>
|
||||
<Button
|
||||
startIcon={<SendIcon />}
|
||||
sx={{ mt: 3 }}
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Grid,
|
||||
IconButton,
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -369,14 +368,18 @@ const InstallPartitionDialog = memo(
|
||||
onClose: () => void;
|
||||
onInstall: (partition: string) => void;
|
||||
}) => {
|
||||
const isRecovery = partition === 'boot';
|
||||
|
||||
return (
|
||||
<Dialog sx={dialogStyle} open={openInstallPartitionDialog} onClose={onClose}>
|
||||
<DialogTitle>
|
||||
{LL.INSTALL()} {LL.STORED_VERSIONS()}
|
||||
{isRecovery ? LL.RECOVERY() : `${LL.INSTALL()} ${LL.STORED_VERSIONS()}`}
|
||||
</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<Typography sx={{ mb: 2 }}>
|
||||
{LL.INSTALL_VERSION(LL.INSTALL(), version)}
|
||||
{isRecovery
|
||||
? 'This will boot in recovery mode'
|
||||
: LL.INSTALL_VERSION(LL.INSTALL(), version)}
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
@@ -390,12 +393,14 @@ const InstallPartitionDialog = memo(
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
startIcon={<WarningIcon color="warning" />}
|
||||
startIcon={
|
||||
isRecovery ? <PowerSettingsNewIcon /> : <WarningIcon color="warning" />
|
||||
}
|
||||
variant="outlined"
|
||||
onClick={() => onInstall(partition)}
|
||||
color="primary"
|
||||
color={isRecovery ? 'success' : 'primary'}
|
||||
>
|
||||
{LL.INSTALL()}
|
||||
{isRecovery ? LL.RESTART() : LL.INSTALL()}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
@@ -665,77 +670,69 @@ const Version = () => {
|
||||
{LL.THIS_VERSION()}
|
||||
</Typography>
|
||||
|
||||
<Grid
|
||||
container
|
||||
direction="row"
|
||||
<Box
|
||||
sx={{
|
||||
justifyContent: 'flex-start',
|
||||
alignItems: 'baseline'
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'auto 1fr',
|
||||
columnGap: 3,
|
||||
rowGap: 0.75,
|
||||
alignItems: 'center'
|
||||
}}
|
||||
>
|
||||
<Grid size={{ xs: 4, md: 2 }}>
|
||||
<Typography color="secondary">{LL.VERSION()}</Typography>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 8, md: 10 }}>
|
||||
<Typography>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
minWidth: 0
|
||||
}}
|
||||
>
|
||||
<Typography component="span">
|
||||
{data.emsesp_version}
|
||||
{data.build_flags && (
|
||||
<Typography variant="caption">
|
||||
({data.build_flags})
|
||||
<Typography component="span" variant="caption">
|
||||
({data.build_flags})
|
||||
</Typography>
|
||||
)}
|
||||
</Typography>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setPartitionVersionInfo(data.partition)}
|
||||
aria-label={LL.FIRMWARE_VERSION_INFO()}
|
||||
sx={{ p: 0.5 }}
|
||||
>
|
||||
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Box>
|
||||
|
||||
<Grid size={{ xs: 4, md: 2 }}>
|
||||
<Typography color="secondary">{LL.PLATFORM()}</Typography>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 8, md: 10 }}>
|
||||
<Typography>
|
||||
{platform}
|
||||
<Typography variant="caption">
|
||||
(
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
minWidth: 0
|
||||
}}
|
||||
>
|
||||
<Typography component="span">{platform}</Typography>
|
||||
<Typography
|
||||
component="span"
|
||||
variant="caption"
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
ml: 0.75
|
||||
}}
|
||||
>
|
||||
(
|
||||
{data.psram ? (
|
||||
<CheckIcon
|
||||
color="success"
|
||||
sx={{
|
||||
fontSize: '1.5em',
|
||||
verticalAlign: 'middle'
|
||||
}}
|
||||
/>
|
||||
<CheckIcon color="success" sx={{ fontSize: 16, mx: 0.25 }} />
|
||||
) : (
|
||||
<CloseIcon
|
||||
color="error"
|
||||
sx={{
|
||||
fontSize: '1.5em',
|
||||
verticalAlign: 'middle'
|
||||
}}
|
||||
/>
|
||||
<CloseIcon color="error" sx={{ fontSize: 16, mx: 0.25 }} />
|
||||
)}
|
||||
PSRAM)
|
||||
PSRAM)
|
||||
</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>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{internetLive ? (
|
||||
<>
|
||||
@@ -743,34 +740,54 @@ const Version = () => {
|
||||
{LL.AVAILABLE_VERSION()}
|
||||
</Typography>
|
||||
|
||||
<Grid
|
||||
container
|
||||
direction="row"
|
||||
rowSpacing={1}
|
||||
<Box
|
||||
sx={{
|
||||
justifyContent: 'flex-start',
|
||||
alignItems: 'baseline'
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'auto 1fr',
|
||||
columnGap: 3,
|
||||
rowGap: 0.75,
|
||||
alignItems: 'center'
|
||||
}}
|
||||
>
|
||||
{otherPartitions.length > 0 && data.developer_mode && (
|
||||
{data.developer_mode && (
|
||||
<>
|
||||
<Grid size={{ xs: 4, md: 2 }}>
|
||||
<Typography color="secondary">{LL.STORED_VERSIONS()}</Typography>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 8, md: 10 }}>
|
||||
{otherPartitions.length > 0 && (
|
||||
<>
|
||||
<Typography
|
||||
color="secondary"
|
||||
sx={{ alignSelf: 'start', pt: 0.5 }}
|
||||
>
|
||||
{LL.STORED_VERSIONS()}
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}
|
||||
>
|
||||
{otherPartitions.map((partition) => (
|
||||
<Typography key={partition.partition} sx={{ mb: 1 }}>
|
||||
<Box
|
||||
key={partition.partition}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap'
|
||||
}}
|
||||
>
|
||||
<Typography component="span">
|
||||
{partition.version}
|
||||
</Typography>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() =>
|
||||
setPartitionVersionInfo(partition.partition)
|
||||
}
|
||||
aria-label={LL.FIRMWARE_VERSION_INFO()}
|
||||
sx={{ p: 0.5 }}
|
||||
>
|
||||
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
|
||||
<InfoOutlinedIcon
|
||||
color="primary"
|
||||
sx={{ fontSize: 18 }}
|
||||
/>
|
||||
</IconButton>
|
||||
<Button
|
||||
sx={{ ml: 0 }}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={() =>
|
||||
@@ -783,43 +800,87 @@ const Version = () => {
|
||||
>
|
||||
{LL.INSTALL()}
|
||||
</Button>
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
{data.recovery_version !== undefined && (
|
||||
<>
|
||||
<Typography color="secondary">{LL.RECOVERY()}</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap'
|
||||
}}
|
||||
>
|
||||
<Typography component="span">
|
||||
{data.recovery_version || LL.RECOVERY_NOT_INSTALLED()}
|
||||
</Typography>
|
||||
{data.recovery_version && me.admin && (
|
||||
<Button
|
||||
sx={{ ml: 1 }}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
const version = data.recovery_version;
|
||||
if (version) {
|
||||
showPartitionDialog(version, 'boot', '');
|
||||
}
|
||||
}}
|
||||
>
|
||||
Boot
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
<Box sx={{ gridColumn: '1 / -1', height: 12 }} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Grid size={{ xs: 4, md: 2 }}>
|
||||
<Typography color="secondary">{LL.STABLE()}</Typography>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 8, md: 10 }}>
|
||||
<Typography>
|
||||
{latestVersion?.version}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
minWidth: 0
|
||||
}}
|
||||
>
|
||||
<Typography component="span">{latestVersion?.version}</Typography>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setShowVersionInfo(1)}
|
||||
aria-label={LL.FIRMWARE_VERSION_INFO()}
|
||||
sx={{ p: 0.5 }}
|
||||
>
|
||||
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
{showButtons(false)}
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Box>
|
||||
|
||||
<Grid size={{ xs: 4, md: 2 }}>
|
||||
<Typography color="secondary">{LL.DEVELOPMENT()}</Typography>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 8, md: 10 }}>
|
||||
<Typography>
|
||||
{latestDevVersion?.version}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
minWidth: 0
|
||||
}}
|
||||
>
|
||||
<Typography component="span">{latestDevVersion?.version}</Typography>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setShowVersionInfo(2)}
|
||||
aria-label={LL.FIRMWARE_VERSION_INFO()}
|
||||
sx={{ p: 0.5 }}
|
||||
>
|
||||
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
{showButtons(true)}
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
) : (
|
||||
<Typography sx={{ mt: 2 }} color="warning">
|
||||
|
||||
@@ -51,7 +51,6 @@ export interface SystemStatus {
|
||||
free_caps: number;
|
||||
model: string;
|
||||
board: string;
|
||||
has_loader: boolean;
|
||||
recovery_version?: string;
|
||||
has_partition: boolean;
|
||||
partitions: {
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
#include <Update.h>
|
||||
#include <esp_app_format.h>
|
||||
#include <esp_image_format.h>
|
||||
#ifdef OTA_UPDATER_ANY_PARTITION
|
||||
#include <esp_heap_caps.h>
|
||||
#include <esp_partition.h>
|
||||
#endif
|
||||
|
||||
#include <cstddef>
|
||||
|
||||
@@ -19,9 +23,15 @@ OtaUpdater::FileType OtaUpdater::classify(const String & filename, size_t filesi
|
||||
if (extension == "bin" && filename.endsWith("littlefs.bin")) {
|
||||
return FileType::FILESYSTEM;
|
||||
}
|
||||
if (extension == "bin" && filesize >= min_firmware_size) {
|
||||
if (extension == "bin") {
|
||||
String lower = filename;
|
||||
lower.toLowerCase();
|
||||
const size_t min_size =
|
||||
lower.indexOf("recovery") >= 0 ? MIN_RECOVERY_FIRMWARE_SIZE : min_firmware_size;
|
||||
if (filesize >= min_size) {
|
||||
return FileType::FIRMWARE;
|
||||
}
|
||||
}
|
||||
if (extension == "json") {
|
||||
return FileType::JSON;
|
||||
}
|
||||
@@ -114,6 +124,19 @@ bool OtaUpdater::hasMd5() const {
|
||||
return strlen(_md5.data()) == _md5.size() - 1;
|
||||
}
|
||||
|
||||
#ifdef OTA_UPDATER_ANY_PARTITION
|
||||
void OtaUpdater::setError(esp_err_t err) {
|
||||
snprintf(_error_buf, sizeof(_error_buf), "%s (0x%x)", esp_err_to_name(err), static_cast<unsigned>(err));
|
||||
_error = _error_buf;
|
||||
}
|
||||
|
||||
void OtaUpdater::releaseTarget() {
|
||||
_target = nullptr;
|
||||
heap_caps_free(_dram);
|
||||
_dram = nullptr;
|
||||
}
|
||||
#endif
|
||||
|
||||
bool OtaUpdater::beginFirmware(size_t filesize, const esp_partition_t * target, bool set_boot) {
|
||||
_md5_applied = false;
|
||||
_error = "";
|
||||
@@ -126,13 +149,14 @@ bool OtaUpdater::beginFirmware(size_t filesize, const esp_partition_t * target,
|
||||
_error = "Firmware does not fit in the partition";
|
||||
return false;
|
||||
}
|
||||
const esp_err_t err = esp_ota_begin(target, OTA_WITH_SEQUENTIAL_WRITES, &_handle);
|
||||
if (err != ESP_OK) {
|
||||
_error = esp_err_to_name(err);
|
||||
_dram = static_cast<uint8_t *>(heap_caps_malloc(DRAM_CHUNK, MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT));
|
||||
if (_dram == nullptr) {
|
||||
_error = "No internal RAM for OTA buffer";
|
||||
return false;
|
||||
}
|
||||
_target = target;
|
||||
_written = 0;
|
||||
_erased = 0;
|
||||
_set_boot = set_boot;
|
||||
if (hasMd5()) {
|
||||
_md5Builder.begin();
|
||||
@@ -166,15 +190,39 @@ bool OtaUpdater::beginFilesystem() {
|
||||
bool OtaUpdater::write(const uint8_t * data, size_t len) {
|
||||
#ifdef OTA_UPDATER_ANY_PARTITION
|
||||
if (_target != nullptr) {
|
||||
const esp_err_t err = esp_ota_write(_handle, data, len);
|
||||
while (len) {
|
||||
const size_t n = len < DRAM_CHUNK ? len : DRAM_CHUNK;
|
||||
memcpy(_dram, data, n);
|
||||
|
||||
const size_t need = _written + n;
|
||||
if (need > _erased) {
|
||||
size_t erase_to = (need + FLASH_SECTOR - 1) / FLASH_SECTOR * FLASH_SECTOR;
|
||||
if (erase_to > _target->size) {
|
||||
erase_to = _target->size;
|
||||
}
|
||||
const size_t erase_len = erase_to - _erased;
|
||||
if (erase_len > 0) {
|
||||
const esp_err_t err = esp_partition_erase_range(_target, _erased, erase_len);
|
||||
if (err != ESP_OK) {
|
||||
_error = esp_err_to_name(err);
|
||||
setError(err);
|
||||
return false;
|
||||
}
|
||||
_erased = erase_to;
|
||||
}
|
||||
}
|
||||
|
||||
const esp_err_t err = esp_partition_write(_target, _written, _dram, n);
|
||||
if (err != ESP_OK) {
|
||||
setError(err);
|
||||
return false;
|
||||
}
|
||||
if (_md5_applied) {
|
||||
_md5Builder.add(data, len);
|
||||
_md5Builder.add(_dram, n);
|
||||
}
|
||||
_written += n;
|
||||
data += n;
|
||||
len -= n;
|
||||
}
|
||||
_written += len;
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
@@ -185,26 +233,31 @@ bool OtaUpdater::end() {
|
||||
#ifdef OTA_UPDATER_ANY_PARTITION
|
||||
if (_target != nullptr) {
|
||||
const esp_partition_t * target = _target;
|
||||
_target = nullptr;
|
||||
const bool set_boot = _set_boot;
|
||||
releaseTarget();
|
||||
|
||||
if (_md5_applied) {
|
||||
_md5Builder.calculate();
|
||||
if (strcmp(_md5Builder.toString().c_str(), _md5.data()) != 0) {
|
||||
esp_ota_abort(_handle);
|
||||
_error = "MD5 check failed";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// esp_ota_end() validates the image
|
||||
esp_err_t err = esp_ota_end(_handle);
|
||||
if (err == ESP_OK && _set_boot) {
|
||||
err = esp_ota_set_boot_partition(target);
|
||||
}
|
||||
esp_image_metadata_t meta = {};
|
||||
esp_partition_pos_t part_pos = {.offset = target->address, .size = target->size};
|
||||
esp_err_t err = esp_image_verify(ESP_IMAGE_VERIFY_SILENT, &part_pos, &meta);
|
||||
if (err != ESP_OK) {
|
||||
_error = esp_err_to_name(err);
|
||||
setError(err);
|
||||
return false;
|
||||
}
|
||||
if (set_boot) {
|
||||
err = esp_ota_set_boot_partition(target);
|
||||
if (err != ESP_OK) {
|
||||
setError(err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
@@ -214,12 +267,13 @@ bool OtaUpdater::end() {
|
||||
void OtaUpdater::abort() {
|
||||
#ifdef OTA_UPDATER_ANY_PARTITION
|
||||
if (_target != nullptr) {
|
||||
esp_ota_abort(_handle);
|
||||
_target = nullptr;
|
||||
releaseTarget();
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
if (Update.isRunning()) {
|
||||
Update.abort();
|
||||
}
|
||||
}
|
||||
|
||||
size_t OtaUpdater::progress() const {
|
||||
|
||||
@@ -16,11 +16,16 @@ class OtaUpdater {
|
||||
public:
|
||||
enum class FileType : uint8_t { UNSUPPORTED, FIRMWARE, FILESYSTEM, JSON, MD5 };
|
||||
|
||||
// min_firmware_size is for the main EMS-ESP image. Filenames containing "recovery"
|
||||
// (case-insensitive) use MIN_RECOVERY_FIRMWARE_SIZE instead.
|
||||
static FileType classify(const String & filename, size_t filesize, size_t min_firmware_size);
|
||||
|
||||
// project name in the app description of the recovery firmware, set by custom_fw_name in platformio.ini
|
||||
static constexpr char RECOVERY_PROJECT_NAME[] = "EMS-ESP-Recovery";
|
||||
|
||||
// recovery firmware is much smaller than the main image (~512 KB–1 MB)
|
||||
static constexpr size_t MIN_RECOVERY_FIRMWARE_SIZE = 512 * 1024;
|
||||
|
||||
// checks the ESP image magic byte and that the chip id matches the chip we're running on
|
||||
static bool isCompatibleFirmware(const uint8_t * data, size_t len);
|
||||
|
||||
@@ -64,12 +69,21 @@ class OtaUpdater {
|
||||
const char * _error = "";
|
||||
|
||||
#ifdef OTA_UPDATER_ANY_PARTITION
|
||||
// set when writing to an explicit target partition with the IDF OTA API instead of Update
|
||||
// explicit target (factory/boot or an OTA slot) is written with the partition API;
|
||||
// esp_ota_begin() returns ESP_ERR_INVALID_STATE (0x102) on the factory partition
|
||||
const esp_partition_t * _target = nullptr;
|
||||
esp_ota_handle_t _handle = 0;
|
||||
size_t _written = 0;
|
||||
size_t _erased = 0;
|
||||
bool _set_boot = true;
|
||||
MD5Builder _md5Builder;
|
||||
// AsyncTCP may hand us PSRAM pointers; ESP32 flash DMA needs internal RAM
|
||||
uint8_t * _dram = nullptr;
|
||||
static constexpr size_t DRAM_CHUNK = 1024;
|
||||
static constexpr size_t FLASH_SECTOR = 4096;
|
||||
char _error_buf[48]{};
|
||||
|
||||
void setError(esp_err_t err);
|
||||
void releaseTarget();
|
||||
#endif
|
||||
};
|
||||
|
||||
|
||||
@@ -116,8 +116,8 @@ let system_status = {
|
||||
psram: true,
|
||||
psram_size: 8189,
|
||||
free_psram: 8166,
|
||||
has_loader: true,
|
||||
recovery_version: '', // upload an EMS-ESP-Recovery-*.bin to set it
|
||||
// recovery_version: '', // upload an EMS-ESP-Recovery-*.bin to set it
|
||||
recovery_version: '1.0.0',
|
||||
has_partition: true,
|
||||
partitions: [
|
||||
{
|
||||
@@ -152,12 +152,12 @@ let system_status = {
|
||||
let DEV_VERSION_IS_UPGRADEABLE: boolean;
|
||||
let STABLE_VERSION_IS_UPGRADEABLE: boolean;
|
||||
let THIS_VERSION: string;
|
||||
let LATEST_STABLE_VERSION = '3.8.4';
|
||||
let LATEST_STABLE_VERSION = '3.8.6';
|
||||
let LATEST_DEV_VERSION = '3.9.0-dev.1';
|
||||
|
||||
// scenarios for testing versioning
|
||||
let version_test = 0; // on latest stable, or switch to dev
|
||||
// let version_test = 1; // on latest dev, or switch back to stable
|
||||
// let version_test = 0; // on latest stable, or switch to dev
|
||||
let version_test = 1; // on latest dev, or switch back to stable
|
||||
// let version_test = 2; // upgrade an older stable to latest stable or switch to latest dev
|
||||
// let version_test = 3; // upgrade dev to latest, or switch to stable
|
||||
// let version_test = 4; // downgrade to an older dev, or switch back to stable
|
||||
|
||||
+2
-2
@@ -177,8 +177,8 @@ build_flags =
|
||||
; 1 crashes on every start, so the bootloader rolls back to the previous firmware
|
||||
; 2 runs normally until it's confirmed after 2 minutes, then crashes on every start. After 5 crashes in a row
|
||||
; it switches to the recovery firmware. Without one it keeps crashing until reflashed over serial
|
||||
[env:crashtest_s3_16M_P]
|
||||
extends = env:s3_16M_P
|
||||
[env:crashtest_s_16M_P]
|
||||
extends = env:s_16M_P
|
||||
build_flags =
|
||||
${espressif32_base_16M.build_flags}
|
||||
-D EMSESP_CRASH_TEST=1
|
||||
|
||||
+45
-15
@@ -1,26 +1,56 @@
|
||||
#!/usr/bin/env bash
|
||||
# Full fresh install of EMS-ESP on an ESP32 16MB board (s_16M_P).
|
||||
#
|
||||
# Run from the scripts folder:
|
||||
# ./do_fresh_install.sh
|
||||
#
|
||||
# Flash map (partitions/esp32_partition_16M.csv):
|
||||
# 0x1000 bootloader
|
||||
# 0x8000 partition table
|
||||
# 0xe000 otadata (boot_app0.bin selects app0)
|
||||
# 0x10000 recovery (factory / boot)
|
||||
# 0x490000 EMS-ESP (app0)
|
||||
# 0xdf0000 LittleFS
|
||||
#
|
||||
# Because otadata points at app0, the device boots EMS-ESP. Recovery stays
|
||||
# installed in factory for fallback. Do not write recovery at 0x1000 — that
|
||||
# is the bootloader, and the image overlaps the partition table at 0x8000.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ESPTOOL=~/.platformio/penv/bin/esptool
|
||||
PORT=/dev/ttyUSB0
|
||||
BUILD=../.pio/build/s_16M_P
|
||||
BAUD=${BAUD:-2000000}
|
||||
|
||||
BUILD_BIN=../.pio/build/s_16M_P
|
||||
RECOVERY_BIN=../.pio/build/recovery_s_16M_P
|
||||
BOOT_APP0=~/.platformio/packages/framework-arduinoespressif32/tools/partitions/boot_app0.bin
|
||||
|
||||
# erase
|
||||
for f in \
|
||||
"$BUILD_BIN/bootloader.bin" \
|
||||
"$BUILD_BIN/partitions.bin" \
|
||||
"$BUILD_BIN/firmware.bin" \
|
||||
"$RECOVERY_BIN/firmware.bin" \
|
||||
"$BOOT_APP0"; do
|
||||
if [[ ! -f $f ]]; then
|
||||
echo "Missing $f — build s_16M_P and recovery_s_16M_P first" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# full erase, stay in download mode
|
||||
$ESPTOOL --chip esp32 --port $PORT --after no-reset erase-flash
|
||||
|
||||
# create the filesystem
|
||||
~/.platformio/penv/bin/python build_littlefs.py data $BUILD/littlefs.bin
|
||||
# create the filesystem, in case it's needed
|
||||
~/.platformio/penv/bin/python build_littlefs.py data $BUILD_BIN/littlefs.bin
|
||||
|
||||
# upload filesystem (spiffs partition @ 0xdf0000), leave halted so it doesn't boot yet
|
||||
$ESPTOOL --chip esp32 --port $PORT --baud 460800 \
|
||||
--before default-reset --after no-reset \
|
||||
write-flash -z --flash-mode dio --flash-freq 80m --flash-size detect \
|
||||
0xdf0000 $BUILD/littlefs.bin
|
||||
|
||||
# flash bootloader + partition table + app (app0 @ 0x490000), then boot
|
||||
$ESPTOOL --chip esp32 --port $PORT --baud 460800 \
|
||||
# bootloader, partitions, otadata, recovery, app, filesystem; then boot
|
||||
$ESPTOOL --chip esp32 --port $PORT --baud $BAUD \
|
||||
--before default-reset --after hard-reset \
|
||||
write-flash -z --flash-mode dio --flash-freq 80m --flash-size detect \
|
||||
0x1000 $BUILD/bootloader.bin \
|
||||
0x8000 $BUILD/partitions.bin \
|
||||
0x1000 $BUILD_BIN/bootloader.bin \
|
||||
0x8000 $BUILD_BIN/partitions.bin \
|
||||
0xe000 $BOOT_APP0 \
|
||||
0x490000 $BUILD/firmware.bin
|
||||
0x10000 $RECOVERY_BIN/firmware.bin \
|
||||
0x490000 $BUILD_BIN/firmware.bin \
|
||||
0xdf0000 $BUILD_BIN/littlefs.bin
|
||||
|
||||
@@ -8,7 +8,8 @@ UploadFileService::UploadFileService(AsyncWebServer * server, SecurityManager *
|
||||
: _securityManager(securityManager)
|
||||
, _is_firmware(false)
|
||||
, _is_filesystem(false)
|
||||
, _is_recovery(false) {
|
||||
, _is_recovery(false)
|
||||
, _response_sent(false) {
|
||||
server->on(
|
||||
UPLOAD_FILE_PATH,
|
||||
HTTP_POST,
|
||||
@@ -34,6 +35,7 @@ void UploadFileService::handleUpload(AsyncWebServerRequest * request, const Stri
|
||||
_is_firmware = false;
|
||||
_is_filesystem = false;
|
||||
_is_recovery = false;
|
||||
_response_sent = false;
|
||||
|
||||
switch (OtaUpdater::classify(filename, filesize, emsesp::System::MIN_FIRMWARE_SIZE)) {
|
||||
case OtaUpdater::FileType::FILESYSTEM:
|
||||
@@ -96,6 +98,7 @@ void UploadFileService::handleUpload(AsyncWebServerRequest * request, const Stri
|
||||
}
|
||||
request->onDisconnect([this] { handleDisconnect(); }); // success, let's make sure we end the update if the client hangs up
|
||||
} else {
|
||||
emsesp::EMSESP::logger().err("OTA begin failed: %s", _ota.errorString());
|
||||
handleError(request, 507); // failed to begin, send an error response Insufficient Storage
|
||||
return;
|
||||
}
|
||||
@@ -147,6 +150,19 @@ void UploadFileService::handleUpload(AsyncWebServerRequest * request, const Stri
|
||||
}
|
||||
|
||||
void UploadFileService::uploadComplete(AsyncWebServerRequest * request) {
|
||||
if (request->_tempObject) {
|
||||
const int code = static_cast<int>(reinterpret_cast<intptr_t>(request->_tempObject));
|
||||
if (!_response_sent && code >= 400) {
|
||||
request->send(request->beginResponse(code));
|
||||
_response_sent = true;
|
||||
}
|
||||
_is_firmware = false;
|
||||
_is_filesystem = false;
|
||||
_is_recovery = false;
|
||||
emsesp::EMSESP::system_.systemStatus(emsesp::SYSTEM_STATUS::SYSTEM_STATUS_NORMAL);
|
||||
return;
|
||||
}
|
||||
|
||||
emsesp::EMSESP::logger().info("Upload successful");
|
||||
|
||||
// did we just complete uploading a json file?
|
||||
@@ -217,26 +233,25 @@ void UploadFileService::uploadComplete(AsyncWebServerRequest * request) {
|
||||
}
|
||||
|
||||
void UploadFileService::handleError(AsyncWebServerRequest * request, int code) {
|
||||
emsesp::EMSESP::logger().info("Upload error: %d", code);
|
||||
emsesp::EMSESP::system_.uart_init(); // re-enable UART
|
||||
|
||||
// if we have had an error already, do nothing
|
||||
if (request->_tempObject) {
|
||||
return;
|
||||
}
|
||||
request->_tempObject = reinterpret_cast<void *>(static_cast<intptr_t>(code)); // stop later chunks from retrying the write
|
||||
|
||||
// send the error code to the client and record the error code in the temp object
|
||||
AsyncWebServerResponse * response = request->beginResponse(code);
|
||||
request->send(response);
|
||||
emsesp::EMSESP::logger().info("Upload error: %d", code);
|
||||
emsesp::EMSESP::system_.uart_init(); // re-enable UART
|
||||
_ota.abort();
|
||||
|
||||
// check for invalid extension and immediately kill the connection, which will throw an error
|
||||
// that is caught by the web code. Unfortunately the http error code is not sent to the client on fast network connections
|
||||
// 406: the client sent a file we won't accept. Close now so the browser fails fast.
|
||||
// Other codes wait for uploadComplete() to send, so we don't destroy the request mid-stream.
|
||||
if (code == 406) {
|
||||
request->send(request->beginResponse(code));
|
||||
_response_sent = true;
|
||||
request->client()->close();
|
||||
_is_firmware = false;
|
||||
_is_filesystem = false;
|
||||
_is_recovery = false;
|
||||
_ota.abort();
|
||||
}
|
||||
|
||||
// if we aborted a filesystem upload, remount LittleFS so the device keeps working
|
||||
@@ -249,13 +264,10 @@ void UploadFileService::handleDisconnect() {
|
||||
emsesp::EMSESP::logger().info("Upload finished");
|
||||
emsesp::EMSESP::system_.uart_init(); // re-enable UART
|
||||
|
||||
#ifdef EMSESP_HAS_RECOVERY
|
||||
if (_is_recovery) {
|
||||
// the client hung up before the upload completed
|
||||
_ota.abort();
|
||||
if (_is_recovery || _is_firmware || _is_filesystem) {
|
||||
emsesp::EMSESP::system_.systemStatus(emsesp::SYSTEM_STATUS::SYSTEM_STATUS_NORMAL);
|
||||
}
|
||||
#endif
|
||||
|
||||
_is_firmware = false;
|
||||
_is_filesystem = false;
|
||||
|
||||
@@ -22,6 +22,7 @@ class UploadFileService {
|
||||
bool _is_firmware;
|
||||
bool _is_filesystem;
|
||||
bool _is_recovery; // firmware for the factory partition, which doesn't change the boot partition
|
||||
bool _response_sent;
|
||||
OtaUpdater _ota;
|
||||
|
||||
void handleUpload(AsyncWebServerRequest * request, const String & filename, size_t index, uint8_t * data, size_t len, bool final);
|
||||
|
||||
+55
-6
@@ -573,8 +573,12 @@ void System::recovery_installed() {
|
||||
if (factory != nullptr) {
|
||||
char c[20];
|
||||
snprintf(c, sizeof(c), "d_%s", factory->label);
|
||||
if (EMSESP::nvs_.isKey(factory->label)) {
|
||||
EMSESP::nvs_.remove(factory->label);
|
||||
}
|
||||
if (EMSESP::nvs_.isKey(c)) {
|
||||
EMSESP::nvs_.remove(c);
|
||||
}
|
||||
partition_info_.erase(factory->label); // on the AsyncTCP task, same as the web handlers reading it
|
||||
}
|
||||
LOG_INFO("Recovery firmware v%s installed", recovery_version().c_str());
|
||||
@@ -639,6 +643,12 @@ bool System::set_partition([[maybe_unused]] const char * partitionname) {
|
||||
return false;
|
||||
}
|
||||
|
||||
#ifdef EMSESP_HAS_RECOVERY
|
||||
if (partition->subtype == ESP_PARTITION_SUBTYPE_APP_FACTORY) {
|
||||
EMSESP::nvs_.putString(EMSESP_NVS_RECOVERY_REASON, "request");
|
||||
}
|
||||
#endif
|
||||
|
||||
// initiate the restart
|
||||
EMSESP::system_.systemStatus(SYSTEM_STATUS::SYSTEM_STATUS_RESTART_REQUESTED);
|
||||
return true;
|
||||
@@ -1323,6 +1333,16 @@ void System::show_system(uuid::console::Shell & shell) {
|
||||
shell.printfln(" [total %d]", available.size());
|
||||
// List all partitions and their version info
|
||||
shell.println(" Partitions:");
|
||||
#ifndef EMSESP_STANDALONE
|
||||
const esp_partition_t * running = esp_ota_get_running_partition();
|
||||
const esp_partition_t * boot = esp_ota_get_boot_partition();
|
||||
const char * running_label = running ? running->label : "";
|
||||
const char * boot_label = boot ? boot->label : "";
|
||||
#else
|
||||
const char * running_label = "";
|
||||
const char * boot_label = "";
|
||||
#endif
|
||||
bool boot_listed = false;
|
||||
for (const auto & partition : partition_info_) {
|
||||
if (partition.second.version.empty()) {
|
||||
continue; // no version, empty string
|
||||
@@ -1334,13 +1354,42 @@ void System::show_system(uuid::console::Shell & shell) {
|
||||
strftime(time_string, sizeof(time_string), "%FT%T", localtime(&d));
|
||||
installed = std::string(", installed on ") + time_string;
|
||||
}
|
||||
shell.printfln(" %s: v%s (%d KB%s) %s",
|
||||
partition.first.c_str(),
|
||||
partition.second.version.c_str(),
|
||||
partition.second.size,
|
||||
installed.c_str(),
|
||||
(strcmp(esp_ota_get_running_partition()->label, partition.first.c_str()) == 0) ? "** active **" : "");
|
||||
const bool is_running = strcmp(running_label, partition.first.c_str()) == 0;
|
||||
const bool is_boot = strcmp(boot_label, partition.first.c_str()) == 0;
|
||||
if (is_boot) {
|
||||
boot_listed = true;
|
||||
}
|
||||
const char * mark = is_running && is_boot ? "** active, boot **" : is_running ? "** active **" : is_boot ? "** boot **" : "";
|
||||
shell.printfln(" %s: v%s (%d KB%s) %s", partition.first.c_str(), partition.second.version.c_str(), partition.second.size, installed.c_str(), mark);
|
||||
}
|
||||
#ifndef EMSESP_STANDALONE
|
||||
#ifdef EMSESP_HAS_RECOVERY
|
||||
// 16MB boards: factory ("boot") holds the recovery app and is omitted from partition_info_
|
||||
{
|
||||
const esp_partition_t * factory = esp_partition_find_first(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_APP_FACTORY, nullptr);
|
||||
esp_app_desc_t desc;
|
||||
if (OtaUpdater::isRecoveryPartition(factory, &desc)) {
|
||||
const bool is_running = running && factory == running;
|
||||
const bool is_boot = boot && factory == boot;
|
||||
if (is_boot) {
|
||||
boot_listed = true;
|
||||
}
|
||||
int size_kb = 0;
|
||||
esp_image_metadata_t meta = {};
|
||||
esp_partition_pos_t part_pos = {.offset = factory->address, .size = factory->size};
|
||||
if (esp_image_verify(ESP_IMAGE_VERIFY_SILENT, &part_pos, &meta) == ESP_OK) {
|
||||
size_kb = static_cast<int>(meta.image_len / 1024);
|
||||
}
|
||||
const char * mark = is_running && is_boot ? "** active, boot **" : is_running ? "** active **" : is_boot ? "** boot **" : "";
|
||||
shell.printfln(" %s: Recovery v%s (%d KB) %s", factory->label, desc.version, size_kb, mark);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
// recovery/factory is omitted from partition_info_, still show it if it's the boot target
|
||||
if (!boot_listed && boot_label[0] != '\0') {
|
||||
shell.printfln(" %s: ** boot **", boot_label);
|
||||
}
|
||||
#endif
|
||||
// List all NVS values
|
||||
#ifndef EMSESP_STANDALONE
|
||||
shell.println(" NVS values:");
|
||||
|
||||
+2
-1
@@ -375,7 +375,8 @@ class System {
|
||||
static constexpr uint8_t HEALTHCHECK_NO_NETWORK = (1 << 1); // 2
|
||||
static constexpr uint8_t HEALTHCHECK_RESET = (1 << 7); // 128
|
||||
|
||||
// smallest .bin accepted as a firmware image, for both URL and web uploads - 1.6 MB
|
||||
// smallest .bin accepted as a main firmware image, for both URL and web uploads - 1.6 MB
|
||||
// recovery firmware uses OtaUpdater::MIN_RECOVERY_FIRMWARE_SIZE when the filename contains "recovery"
|
||||
static constexpr size_t MIN_FIRMWARE_SIZE = 1677721;
|
||||
|
||||
private:
|
||||
|
||||
@@ -1 +1 @@
|
||||
#define EMSESP_APP_VERSION "3.9.0-dev.16"
|
||||
#define EMSESP_APP_VERSION "3.9.0-dev.17"
|
||||
|
||||
@@ -134,11 +134,10 @@ void WebStatusService::systemStatus(AsyncWebServerRequest * request) {
|
||||
root["temperature"] = (int)Helpers::transformNumFloat(EMSESP::system_.temperature(), 0, EMSESP::system_.fahrenheit() ? 2 : 0); // only 2 decimal places
|
||||
#endif
|
||||
|
||||
// check for a factory partition first
|
||||
// factory partition is only on 16MB boards; recovery_version is empty until a recovery .bin is installed
|
||||
const esp_partition_t * partition = esp_partition_find_first(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_APP_FACTORY, nullptr);
|
||||
root["has_loader"] = partition != NULL && partition != esp_ota_get_running_partition();
|
||||
if (partition != NULL) {
|
||||
root["recovery_version"] = EMSESP::system_.recovery_version(); // empty if not installed
|
||||
root["recovery_version"] = EMSESP::system_.recovery_version();
|
||||
}
|
||||
partition = esp_ota_get_next_update_partition(nullptr);
|
||||
if (partition) {
|
||||
|
||||
Reference in New Issue
Block a user