crashmode

This commit is contained in:
proddy
2026-10-07 23:00:14 +02:00
parent 6411b6ffbd
commit 965d93661a
19 changed files with 600 additions and 273 deletions
+14 -15
View File
@@ -8,24 +8,23 @@ This guide covers testing on hardware of:
- the fallback to the recovery after repeated crashes; - the fallback to the recovery after repeated crashes;
- Ethernet and static IP support in the recovery. - 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 ## 0. Preparation
Build everything: Build everything:
```bash ```bash
pio run -e s3_16M_P -e crashtest_s3_16M_P -e recovery_s3_16M_P pio run -e s_16M_P -e crashtest_s_16M_P -e recovery_s_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/`: The firmware files are written to `build/firmware/`:
| File | What it is | | File | What it is |
| --- | --- | | --- | --- |
| `EMS-ESP-<version>-ESP32S3-16MB+.bin` | EMS-ESP | | `EMS-ESP-<version>-ESP32-16MB+.bin` | EMS-ESP |
| `EMS-ESP-CrashTest-<version>-ESP32S3-16MB+.bin` | crash test build | | `EMS-ESP-CrashTest-<version>-ESP32-16MB+.bin` | crash test build |
| `EMS-ESP-Recovery-<version>-ESP32S3-16MB+.bin` | the recovery | | `EMS-ESP-Recovery-<version>-ESP32-16MB+.bin` | the recovery |
Each has a matching `.md5` file. 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. 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>. 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. 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. 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: 2. In the WebUI, open **Settings → Version**. Check that:
- the **Recovery** row shows "not installed"; - the **Recovery** row shows "not installed";
- the firmware partition list doesn't include `boot`. - 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. - the install logged.
2. Repeat, uploading the `.md5` first and then the `.bin`. The MD5 check should pass. 2. Repeat, uploading the `.md5` first and then the `.bin`. The MD5 check should pass.
3. Negative tests: 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. - 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. 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; - `[recovery] EMS-ESP Recovery v…` is logged;
- LittleFS is mounted; - LittleFS is mounted;
- the access point has started; - 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. 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. 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: 5. On the dashboard, check that:
- there's **no** red reason box, because the restart was requested; - there's **no** red reason box, because the restart was requested;
- `Recovery (boot)` has the "running" chip; - `Recovery (boot)` has the "running" chip;
- the installed firmware shows the correct version; - 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. 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. 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) ## 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. 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. 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. 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: 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. 2. Repeat steps 2 to 4. It should keep crashing past 5, and needs to be reflashed over serial.
## 7. Network in the recovery ## 7. Network in the recovery
@@ -136,7 +135,7 @@ Optional, without a recovery installed:
| Test | Setup | Expected 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" | | 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 | | 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 | | 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 | | 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 ## 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.
+104
View File
@@ -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`.
+1 -1
View File
@@ -57,7 +57,7 @@
"terser": "^5.51.2", "terser": "^5.51.2",
"typescript": "^6.0.3", "typescript": "^6.0.3",
"typescript-eslint": "^8.71.1", "typescript-eslint": "^8.71.1",
"vite": "^8.3.2", "vite": "^8.3.3",
"vite-plugin-imagemin": "^0.6.1" "vite-plugin-imagemin": "^0.6.1"
}, },
"packageManager": "pnpm@12.9.1" "packageManager": "pnpm@12.9.1"
+21 -21
View File
@@ -214,7 +214,7 @@ importers:
version: 10.0.1(eslint@10.12.0(supports-color@10.2.2)) version: 10.0.1(eslint@10.12.0(supports-color@10.2.2))
'@preact/preset-vite': '@preact/preset-vite':
specifier: ^2.10.6 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': '@trivago/prettier-plugin-sort-imports':
specifier: ^6.0.2 specifier: ^6.0.2
version: 6.0.2(prettier@3.9.9)(supports-color@10.2.2) version: 6.0.2(prettier@3.9.9)(supports-color@10.2.2)
@@ -258,11 +258,11 @@ importers:
specifier: ^8.71.1 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) version: 8.71.1(eslint@10.12.0(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)
vite: vite:
specifier: ^8.3.2 specifier: ^8.3.3
version: 8.3.2(@types/node@26.6.4)(terser@5.51.2) version: 8.3.3(@types/node@26.6.4)(terser@5.51.2)
vite-plugin-imagemin: vite-plugin-imagemin:
specifier: ^0.6.1 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: packages:
@@ -1338,8 +1338,8 @@ packages:
duplexer3@0.1.5: duplexer3@0.1.5:
resolution: {integrity: sha512-1A8za6ws41LQgv9HrE/66jyC5yuSjQ3L/KOpFtoBilsAK2iA2wuS5rTt1OCzIvtS2V7nVmedsUU+DGRcjBmOYA==} resolution: {integrity: sha512-1A8za6ws41LQgv9HrE/66jyC5yuSjQ3L/KOpFtoBilsAK2iA2wuS5rTt1OCzIvtS2V7nVmedsUU+DGRcjBmOYA==}
electron-to-chromium@1.5.444: electron-to-chromium@1.5.445:
resolution: {integrity: sha512-5ss/uJfoDYDHT0lfJzT6FbcskIzROIOPf0BbbFkGcvDzoJU7i//9GDrwwIHQVmIsrAGiF3ihpADBRIsrEFt1rQ==} resolution: {integrity: sha512-iX7o1nF+qNb3Bv5mLYgqfgF9Znh9e/8MKXdmbb8Gv2Q47OKIzTwEPD1oda1HrgEwYatTLJj0wQjHvIFt8Db53Q==}
emoji-regex@10.6.0: emoji-regex@10.6.0:
resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==}
@@ -3057,8 +3057,8 @@ packages:
peerDependencies: peerDependencies:
vite: 5.x || 6.x || 7.x || 8.x vite: 5.x || 6.x || 7.x || 8.x
vite@8.3.2: vite@8.3.3:
resolution: {integrity: sha512-SQr1x6W5vVSbROg7vsyXIaxK9b0G7zsT68acdWWRmnBUsgDieLCRG+Rep9WdZgcposvv/GSnr4GUUBqB3vXq6w==} resolution: {integrity: sha512-cTAldKPImjg6c+gk48U19POPn3GCBzZwpdsN8ZMEEcbpes+6/wvfqUd0C2y3qYY4wsj8PwgFwrZ/1jBPVttMSg==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true hasBin: true
peerDependencies: peerDependencies:
@@ -3591,19 +3591,19 @@ snapshots:
'@popperjs/core@2.11.8': {} '@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: dependencies:
'@babel/core': 7.29.7(supports-color@10.2.2) '@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': 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) '@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 '@rollup/pluginutils': 5.4.0
babel-plugin-transform-hook-names: 1.0.2(@babel/core@7.29.7(supports-color@10.2.2)) 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) debug: 4.4.3(supports-color@10.2.2)
magic-string: 0.30.21 magic-string: 0.30.21
picocolors: 1.1.1 picocolors: 1.1.1
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.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))
zimmerframe: 1.1.5 zimmerframe: 1.1.5
transitivePeerDependencies: transitivePeerDependencies:
- preact - preact
@@ -3620,7 +3620,7 @@ snapshots:
'@prefresh/utils@1.2.1': {} '@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: dependencies:
'@babel/core': 7.29.7(supports-color@10.2.2) '@babel/core': 7.29.7(supports-color@10.2.2)
'@prefresh/babel-plugin': 0.5.4(@babel/core@7.29.7(supports-color@10.2.2)) '@prefresh/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 '@prefresh/utils': 1.2.1
'@rollup/pluginutils': 4.2.1 '@rollup/pluginutils': 4.2.1
preact: 11.0.0 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: transitivePeerDependencies:
- supports-color - supports-color
@@ -4005,7 +4005,7 @@ snapshots:
dependencies: dependencies:
baseline-browser-mapping: 2.11.27 baseline-browser-mapping: 2.11.27
caniuse-lite: 1.0.30001814 caniuse-lite: 1.0.30001814
electron-to-chromium: 1.5.444 electron-to-chromium: 1.5.445
node-releases: 2.0.57 node-releases: 2.0.57
update-browserslist-db: 1.3.3(browserslist@4.29.3) update-browserslist-db: 1.3.3(browserslist@4.29.3)
@@ -4370,7 +4370,7 @@ snapshots:
duplexer3@0.1.5: {} duplexer3@0.1.5: {}
electron-to-chromium@1.5.444: {} electron-to-chromium@1.5.445: {}
emoji-regex@10.6.0: {} emoji-regex@10.6.0: {}
@@ -5982,7 +5982,7 @@ snapshots:
spdx-correct: 3.2.0 spdx-correct: 3.2.0
spdx-expression-parse: 3.0.1 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: dependencies:
'@types/imagemin': 7.0.1 '@types/imagemin': 7.0.1
'@types/imagemin-gifsicle': 7.0.4 '@types/imagemin-gifsicle': 7.0.4
@@ -6007,11 +6007,11 @@ snapshots:
imagemin-webp: 6.1.0 imagemin-webp: 6.1.0
jpegtran-bin: 6.0.1 jpegtran-bin: 6.0.1
pathe: 0.2.0 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: transitivePeerDependencies:
- supports-color - 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: dependencies:
kolorist: 1.8.0 kolorist: 1.8.0
magic-string: 0.30.21 magic-string: 0.30.21
@@ -6019,9 +6019,9 @@ snapshots:
simple-code-frame: 1.3.0 simple-code-frame: 1.3.0
source-map: 0.7.6 source-map: 0.7.6
stack-trace: 1.0.0 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: dependencies:
lightningcss: 1.33.0 lightningcss: 1.33.0
picomatch: 4.0.7 picomatch: 4.0.7
+2
View File
@@ -1,5 +1,6 @@
import { memo, useContext } from 'react'; import { memo, useContext } from 'react';
import LogoutIcon from '@mui/icons-material/Logout';
import PersonIcon from '@mui/icons-material/Person'; import PersonIcon from '@mui/icons-material/Person';
import { import {
Avatar, Avatar,
@@ -53,6 +54,7 @@ const UserProfileComponent = () => {
</Box> </Box>
<Divider /> <Divider />
<Button <Button
startIcon={<LogoutIcon />}
sx={{ mt: 2 }} sx={{ mt: 2 }}
variant="outlined" variant="outlined"
color="primary" color="primary"
@@ -2,6 +2,7 @@ import { useContext, useEffect, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
import PowerSettingsNewIcon from '@mui/icons-material/PowerSettingsNew'; import PowerSettingsNewIcon from '@mui/icons-material/PowerSettingsNew';
import SendIcon from '@mui/icons-material/Send';
import WarningIcon from '@mui/icons-material/Warning'; import WarningIcon from '@mui/icons-material/Warning';
import { import {
Box, Box,
@@ -500,6 +501,7 @@ const ApplicationSettings = () => {
</Grid> </Grid>
<Grid> <Grid>
<Button <Button
startIcon={<SendIcon />}
sx={{ mt: 3 }} sx={{ mt: 3 }}
variant="outlined" variant="outlined"
color="primary" color="primary"
+149 -88
View File
@@ -16,7 +16,6 @@ import {
DialogActions, DialogActions,
DialogContent, DialogContent,
DialogTitle, DialogTitle,
Grid,
IconButton, IconButton,
Table, Table,
TableBody, TableBody,
@@ -369,14 +368,18 @@ const InstallPartitionDialog = memo(
onClose: () => void; onClose: () => void;
onInstall: (partition: string) => void; onInstall: (partition: string) => void;
}) => { }) => {
const isRecovery = partition === 'boot';
return ( return (
<Dialog sx={dialogStyle} open={openInstallPartitionDialog} onClose={onClose}> <Dialog sx={dialogStyle} open={openInstallPartitionDialog} onClose={onClose}>
<DialogTitle> <DialogTitle>
{LL.INSTALL()} {LL.STORED_VERSIONS()} {isRecovery ? LL.RECOVERY() : `${LL.INSTALL()} ${LL.STORED_VERSIONS()}`}
</DialogTitle> </DialogTitle>
<DialogContent dividers> <DialogContent dividers>
<Typography sx={{ mb: 2 }}> <Typography sx={{ mb: 2 }}>
{LL.INSTALL_VERSION(LL.INSTALL(), version)} {isRecovery
? 'This will boot in recovery mode'
: LL.INSTALL_VERSION(LL.INSTALL(), version)}
</Typography> </Typography>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
@@ -390,12 +393,14 @@ const InstallPartitionDialog = memo(
</Button> </Button>
<Button <Button
startIcon={<WarningIcon color="warning" />} startIcon={
isRecovery ? <PowerSettingsNewIcon /> : <WarningIcon color="warning" />
}
variant="outlined" variant="outlined"
onClick={() => onInstall(partition)} onClick={() => onInstall(partition)}
color="primary" color={isRecovery ? 'success' : 'primary'}
> >
{LL.INSTALL()} {isRecovery ? LL.RESTART() : LL.INSTALL()}
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>
@@ -665,77 +670,69 @@ const Version = () => {
{LL.THIS_VERSION()} {LL.THIS_VERSION()}
</Typography> </Typography>
<Grid <Box
container
direction="row"
sx={{ sx={{
justifyContent: 'flex-start', display: 'grid',
alignItems: 'baseline' gridTemplateColumns: 'auto 1fr',
columnGap: 3,
rowGap: 0.75,
alignItems: 'center'
}} }}
> >
<Grid size={{ xs: 4, md: 2 }}>
<Typography color="secondary">{LL.VERSION()}</Typography> <Typography color="secondary">{LL.VERSION()}</Typography>
</Grid> <Box
<Grid size={{ xs: 8, md: 10 }}> sx={{
<Typography> display: 'flex',
alignItems: 'center',
minWidth: 0
}}
>
<Typography component="span">
{data.emsesp_version} {data.emsesp_version}
{data.build_flags && ( {data.build_flags && (
<Typography variant="caption"> <Typography component="span" variant="caption">
&nbsp; &#40;{data.build_flags}&#41; &nbsp;({data.build_flags})
</Typography> </Typography>
)} )}
</Typography>
<IconButton <IconButton
size="small"
onClick={() => setPartitionVersionInfo(data.partition)} onClick={() => setPartitionVersionInfo(data.partition)}
aria-label={LL.FIRMWARE_VERSION_INFO()} aria-label={LL.FIRMWARE_VERSION_INFO()}
sx={{ p: 0.5 }}
> >
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} /> <InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
</IconButton> </IconButton>
</Typography> </Box>
</Grid>
<Grid size={{ xs: 4, md: 2 }}>
<Typography color="secondary">{LL.PLATFORM()}</Typography> <Typography color="secondary">{LL.PLATFORM()}</Typography>
</Grid> <Box
<Grid size={{ xs: 8, md: 10 }}> sx={{
<Typography> display: 'flex',
{platform} alignItems: 'center',
<Typography variant="caption"> minWidth: 0
&nbsp; &#40; }}
>
<Typography component="span">{platform}</Typography>
<Typography
component="span"
variant="caption"
sx={{
display: 'inline-flex',
alignItems: 'center',
ml: 0.75
}}
>
(
{data.psram ? ( {data.psram ? (
<CheckIcon <CheckIcon color="success" sx={{ fontSize: 16, mx: 0.25 }} />
color="success"
sx={{
fontSize: '1.5em',
verticalAlign: 'middle'
}}
/>
) : ( ) : (
<CloseIcon <CloseIcon color="error" sx={{ fontSize: 16, mx: 0.25 }} />
color="error"
sx={{
fontSize: '1.5em',
verticalAlign: 'middle'
}}
/>
)} )}
PSRAM&#41; PSRAM)
</Typography> </Typography>
</Typography> </Box>
</Grid> </Box>
{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 ? ( {internetLive ? (
<> <>
@@ -743,34 +740,54 @@ const Version = () => {
{LL.AVAILABLE_VERSION()} {LL.AVAILABLE_VERSION()}
</Typography> </Typography>
<Grid <Box
container
direction="row"
rowSpacing={1}
sx={{ sx={{
justifyContent: 'flex-start', display: 'grid',
alignItems: 'baseline' 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 }}> {otherPartitions.length > 0 && (
<Typography color="secondary">{LL.STORED_VERSIONS()}</Typography> <>
</Grid> <Typography
<Grid size={{ xs: 8, md: 10 }}> color="secondary"
sx={{ alignSelf: 'start', pt: 0.5 }}
>
{LL.STORED_VERSIONS()}
</Typography>
<Box
sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}
>
{otherPartitions.map((partition) => ( {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} {partition.version}
</Typography>
<IconButton <IconButton
size="small"
onClick={() => onClick={() =>
setPartitionVersionInfo(partition.partition) setPartitionVersionInfo(partition.partition)
} }
aria-label={LL.FIRMWARE_VERSION_INFO()} aria-label={LL.FIRMWARE_VERSION_INFO()}
sx={{ p: 0.5 }}
> >
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} /> <InfoOutlinedIcon
color="primary"
sx={{ fontSize: 18 }}
/>
</IconButton> </IconButton>
<Button <Button
sx={{ ml: 0 }}
variant="outlined" variant="outlined"
size="small" size="small"
onClick={() => onClick={() =>
@@ -783,43 +800,87 @@ const Version = () => {
> >
{LL.INSTALL()} {LL.INSTALL()}
</Button> </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> <Typography color="secondary">{LL.STABLE()}</Typography>
</Grid> <Box
<Grid size={{ xs: 8, md: 10 }}> sx={{
<Typography> display: 'flex',
{latestVersion?.version} alignItems: 'center',
flexWrap: 'wrap',
minWidth: 0
}}
>
<Typography component="span">{latestVersion?.version}</Typography>
<IconButton <IconButton
size="small"
onClick={() => setShowVersionInfo(1)} onClick={() => setShowVersionInfo(1)}
aria-label={LL.FIRMWARE_VERSION_INFO()} aria-label={LL.FIRMWARE_VERSION_INFO()}
sx={{ p: 0.5 }}
> >
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} /> <InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
</IconButton> </IconButton>
{showButtons(false)} {showButtons(false)}
</Typography> </Box>
</Grid>
<Grid size={{ xs: 4, md: 2 }}>
<Typography color="secondary">{LL.DEVELOPMENT()}</Typography> <Typography color="secondary">{LL.DEVELOPMENT()}</Typography>
</Grid> <Box
<Grid size={{ xs: 8, md: 10 }}> sx={{
<Typography> display: 'flex',
{latestDevVersion?.version} alignItems: 'center',
flexWrap: 'wrap',
minWidth: 0
}}
>
<Typography component="span">{latestDevVersion?.version}</Typography>
<IconButton <IconButton
size="small"
onClick={() => setShowVersionInfo(2)} onClick={() => setShowVersionInfo(2)}
aria-label={LL.FIRMWARE_VERSION_INFO()} aria-label={LL.FIRMWARE_VERSION_INFO()}
sx={{ p: 0.5 }}
> >
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} /> <InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
</IconButton> </IconButton>
{showButtons(true)} {showButtons(true)}
</Typography> </Box>
</Grid> </Box>
</Grid>
</> </>
) : ( ) : (
<Typography sx={{ mt: 2 }} color="warning"> <Typography sx={{ mt: 2 }} color="warning">
-1
View File
@@ -51,7 +51,6 @@ export interface SystemStatus {
free_caps: number; free_caps: number;
model: string; model: string;
board: string; board: string;
has_loader: boolean;
recovery_version?: string; recovery_version?: string;
has_partition: boolean; has_partition: boolean;
partitions: { partitions: {
+72 -18
View File
@@ -3,6 +3,10 @@
#include <Update.h> #include <Update.h>
#include <esp_app_format.h> #include <esp_app_format.h>
#include <esp_image_format.h> #include <esp_image_format.h>
#ifdef OTA_UPDATER_ANY_PARTITION
#include <esp_heap_caps.h>
#include <esp_partition.h>
#endif
#include <cstddef> #include <cstddef>
@@ -19,9 +23,15 @@ OtaUpdater::FileType OtaUpdater::classify(const String & filename, size_t filesi
if (extension == "bin" && filename.endsWith("littlefs.bin")) { if (extension == "bin" && filename.endsWith("littlefs.bin")) {
return FileType::FILESYSTEM; 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; return FileType::FIRMWARE;
} }
}
if (extension == "json") { if (extension == "json") {
return FileType::JSON; return FileType::JSON;
} }
@@ -114,6 +124,19 @@ bool OtaUpdater::hasMd5() const {
return strlen(_md5.data()) == _md5.size() - 1; 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) { bool OtaUpdater::beginFirmware(size_t filesize, const esp_partition_t * target, bool set_boot) {
_md5_applied = false; _md5_applied = false;
_error = ""; _error = "";
@@ -126,13 +149,14 @@ bool OtaUpdater::beginFirmware(size_t filesize, const esp_partition_t * target,
_error = "Firmware does not fit in the partition"; _error = "Firmware does not fit in the partition";
return false; return false;
} }
const esp_err_t err = esp_ota_begin(target, OTA_WITH_SEQUENTIAL_WRITES, &_handle); _dram = static_cast<uint8_t *>(heap_caps_malloc(DRAM_CHUNK, MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT));
if (err != ESP_OK) { if (_dram == nullptr) {
_error = esp_err_to_name(err); _error = "No internal RAM for OTA buffer";
return false; return false;
} }
_target = target; _target = target;
_written = 0; _written = 0;
_erased = 0;
_set_boot = set_boot; _set_boot = set_boot;
if (hasMd5()) { if (hasMd5()) {
_md5Builder.begin(); _md5Builder.begin();
@@ -166,15 +190,39 @@ bool OtaUpdater::beginFilesystem() {
bool OtaUpdater::write(const uint8_t * data, size_t len) { bool OtaUpdater::write(const uint8_t * data, size_t len) {
#ifdef OTA_UPDATER_ANY_PARTITION #ifdef OTA_UPDATER_ANY_PARTITION
if (_target != nullptr) { 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) { 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; return false;
} }
if (_md5_applied) { if (_md5_applied) {
_md5Builder.add(data, len); _md5Builder.add(_dram, n);
}
_written += n;
data += n;
len -= n;
} }
_written += len;
return true; return true;
} }
#endif #endif
@@ -185,26 +233,31 @@ bool OtaUpdater::end() {
#ifdef OTA_UPDATER_ANY_PARTITION #ifdef OTA_UPDATER_ANY_PARTITION
if (_target != nullptr) { if (_target != nullptr) {
const esp_partition_t * target = _target; const esp_partition_t * target = _target;
_target = nullptr; const bool set_boot = _set_boot;
releaseTarget();
if (_md5_applied) { if (_md5_applied) {
_md5Builder.calculate(); _md5Builder.calculate();
if (strcmp(_md5Builder.toString().c_str(), _md5.data()) != 0) { if (strcmp(_md5Builder.toString().c_str(), _md5.data()) != 0) {
esp_ota_abort(_handle);
_error = "MD5 check failed"; _error = "MD5 check failed";
return false; return false;
} }
} }
// esp_ota_end() validates the image esp_image_metadata_t meta = {};
esp_err_t err = esp_ota_end(_handle); esp_partition_pos_t part_pos = {.offset = target->address, .size = target->size};
if (err == ESP_OK && _set_boot) { esp_err_t err = esp_image_verify(ESP_IMAGE_VERIFY_SILENT, &part_pos, &meta);
err = esp_ota_set_boot_partition(target);
}
if (err != ESP_OK) { if (err != ESP_OK) {
_error = esp_err_to_name(err); setError(err);
return false; return false;
} }
if (set_boot) {
err = esp_ota_set_boot_partition(target);
if (err != ESP_OK) {
setError(err);
return false;
}
}
return true; return true;
} }
#endif #endif
@@ -214,12 +267,13 @@ bool OtaUpdater::end() {
void OtaUpdater::abort() { void OtaUpdater::abort() {
#ifdef OTA_UPDATER_ANY_PARTITION #ifdef OTA_UPDATER_ANY_PARTITION
if (_target != nullptr) { if (_target != nullptr) {
esp_ota_abort(_handle); releaseTarget();
_target = nullptr;
return; return;
} }
#endif #endif
if (Update.isRunning()) {
Update.abort(); Update.abort();
}
} }
size_t OtaUpdater::progress() const { size_t OtaUpdater::progress() const {
+16 -2
View File
@@ -16,11 +16,16 @@ class OtaUpdater {
public: public:
enum class FileType : uint8_t { UNSUPPORTED, FIRMWARE, FILESYSTEM, JSON, MD5 }; 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); 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 // 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"; 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 // 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); static bool isCompatibleFirmware(const uint8_t * data, size_t len);
@@ -64,12 +69,21 @@ class OtaUpdater {
const char * _error = ""; const char * _error = "";
#ifdef OTA_UPDATER_ANY_PARTITION #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; const esp_partition_t * _target = nullptr;
esp_ota_handle_t _handle = 0;
size_t _written = 0; size_t _written = 0;
size_t _erased = 0;
bool _set_boot = true; bool _set_boot = true;
MD5Builder _md5Builder; 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 #endif
}; };
+5 -5
View File
@@ -116,8 +116,8 @@ let system_status = {
psram: true, psram: true,
psram_size: 8189, psram_size: 8189,
free_psram: 8166, 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, has_partition: true,
partitions: [ partitions: [
{ {
@@ -152,12 +152,12 @@ let system_status = {
let DEV_VERSION_IS_UPGRADEABLE: boolean; let DEV_VERSION_IS_UPGRADEABLE: boolean;
let STABLE_VERSION_IS_UPGRADEABLE: boolean; let STABLE_VERSION_IS_UPGRADEABLE: boolean;
let THIS_VERSION: string; 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'; let LATEST_DEV_VERSION = '3.9.0-dev.1';
// scenarios for testing versioning // scenarios for testing versioning
let version_test = 0; // on latest stable, or switch to dev // 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 = 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 = 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 = 3; // upgrade dev to latest, or switch to stable
// let version_test = 4; // downgrade to an older dev, or switch back to stable // let version_test = 4; // downgrade to an older dev, or switch back to stable
+2 -2
View File
@@ -177,8 +177,8 @@ build_flags =
; 1 crashes on every start, so the bootloader rolls back to the previous firmware ; 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 ; 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 ; it switches to the recovery firmware. Without one it keeps crashing until reflashed over serial
[env:crashtest_s3_16M_P] [env:crashtest_s_16M_P]
extends = env:s3_16M_P extends = env:s_16M_P
build_flags = build_flags =
${espressif32_base_16M.build_flags} ${espressif32_base_16M.build_flags}
-D EMSESP_CRASH_TEST=1 -D EMSESP_CRASH_TEST=1
+45 -15
View File
@@ -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 ESPTOOL=~/.platformio/penv/bin/esptool
PORT=/dev/ttyUSB0 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 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 $ESPTOOL --chip esp32 --port $PORT --after no-reset erase-flash
# create the filesystem # create the filesystem, in case it's needed
~/.platformio/penv/bin/python build_littlefs.py data $BUILD/littlefs.bin ~/.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 # bootloader, partitions, otadata, recovery, app, filesystem; then boot
$ESPTOOL --chip esp32 --port $PORT --baud 460800 \ $ESPTOOL --chip esp32 --port $PORT --baud $BAUD \
--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 \
--before default-reset --after hard-reset \ --before default-reset --after hard-reset \
write-flash -z --flash-mode dio --flash-freq 80m --flash-size detect \ write-flash -z --flash-mode dio --flash-freq 80m --flash-size detect \
0x1000 $BUILD/bootloader.bin \ 0x1000 $BUILD_BIN/bootloader.bin \
0x8000 $BUILD/partitions.bin \ 0x8000 $BUILD_BIN/partitions.bin \
0xe000 $BOOT_APP0 \ 0xe000 $BOOT_APP0 \
0x490000 $BUILD/firmware.bin 0x10000 $RECOVERY_BIN/firmware.bin \
0x490000 $BUILD_BIN/firmware.bin \
0xdf0000 $BUILD_BIN/littlefs.bin
+26 -14
View File
@@ -8,7 +8,8 @@ UploadFileService::UploadFileService(AsyncWebServer * server, SecurityManager *
: _securityManager(securityManager) : _securityManager(securityManager)
, _is_firmware(false) , _is_firmware(false)
, _is_filesystem(false) , _is_filesystem(false)
, _is_recovery(false) { , _is_recovery(false)
, _response_sent(false) {
server->on( server->on(
UPLOAD_FILE_PATH, UPLOAD_FILE_PATH,
HTTP_POST, HTTP_POST,
@@ -34,6 +35,7 @@ void UploadFileService::handleUpload(AsyncWebServerRequest * request, const Stri
_is_firmware = false; _is_firmware = false;
_is_filesystem = false; _is_filesystem = false;
_is_recovery = false; _is_recovery = false;
_response_sent = false;
switch (OtaUpdater::classify(filename, filesize, emsesp::System::MIN_FIRMWARE_SIZE)) { switch (OtaUpdater::classify(filename, filesize, emsesp::System::MIN_FIRMWARE_SIZE)) {
case OtaUpdater::FileType::FILESYSTEM: 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 request->onDisconnect([this] { handleDisconnect(); }); // success, let's make sure we end the update if the client hangs up
} else { } else {
emsesp::EMSESP::logger().err("OTA begin failed: %s", _ota.errorString());
handleError(request, 507); // failed to begin, send an error response Insufficient Storage handleError(request, 507); // failed to begin, send an error response Insufficient Storage
return; return;
} }
@@ -147,6 +150,19 @@ void UploadFileService::handleUpload(AsyncWebServerRequest * request, const Stri
} }
void UploadFileService::uploadComplete(AsyncWebServerRequest * request) { 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"); emsesp::EMSESP::logger().info("Upload successful");
// did we just complete uploading a json file? // did we just complete uploading a json file?
@@ -217,26 +233,25 @@ void UploadFileService::uploadComplete(AsyncWebServerRequest * request) {
} }
void UploadFileService::handleError(AsyncWebServerRequest * request, int code) { 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 we have had an error already, do nothing
if (request->_tempObject) { if (request->_tempObject) {
return; 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 emsesp::EMSESP::logger().info("Upload error: %d", code);
AsyncWebServerResponse * response = request->beginResponse(code); emsesp::EMSESP::system_.uart_init(); // re-enable UART
request->send(response); _ota.abort();
// check for invalid extension and immediately kill the connection, which will throw an error // 406: the client sent a file we won't accept. Close now so the browser fails fast.
// that is caught by the web code. Unfortunately the http error code is not sent to the client on fast network connections // Other codes wait for uploadComplete() to send, so we don't destroy the request mid-stream.
if (code == 406) { if (code == 406) {
request->send(request->beginResponse(code));
_response_sent = true;
request->client()->close(); request->client()->close();
_is_firmware = false; _is_firmware = false;
_is_filesystem = false; _is_filesystem = false;
_is_recovery = false; _is_recovery = false;
_ota.abort();
} }
// if we aborted a filesystem upload, remount LittleFS so the device keeps working // 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::logger().info("Upload finished");
emsesp::EMSESP::system_.uart_init(); // re-enable UART emsesp::EMSESP::system_.uart_init(); // re-enable UART
#ifdef EMSESP_HAS_RECOVERY
if (_is_recovery) {
// the client hung up before the upload completed
_ota.abort(); _ota.abort();
if (_is_recovery || _is_firmware || _is_filesystem) {
emsesp::EMSESP::system_.systemStatus(emsesp::SYSTEM_STATUS::SYSTEM_STATUS_NORMAL); emsesp::EMSESP::system_.systemStatus(emsesp::SYSTEM_STATUS::SYSTEM_STATUS_NORMAL);
} }
#endif
_is_firmware = false; _is_firmware = false;
_is_filesystem = false; _is_filesystem = false;
+1
View File
@@ -22,6 +22,7 @@ class UploadFileService {
bool _is_firmware; bool _is_firmware;
bool _is_filesystem; bool _is_filesystem;
bool _is_recovery; // firmware for the factory partition, which doesn't change the boot partition bool _is_recovery; // firmware for the factory partition, which doesn't change the boot partition
bool _response_sent;
OtaUpdater _ota; OtaUpdater _ota;
void handleUpload(AsyncWebServerRequest * request, const String & filename, size_t index, uint8_t * data, size_t len, bool final); void handleUpload(AsyncWebServerRequest * request, const String & filename, size_t index, uint8_t * data, size_t len, bool final);
+55 -6
View File
@@ -573,8 +573,12 @@ void System::recovery_installed() {
if (factory != nullptr) { if (factory != nullptr) {
char c[20]; char c[20];
snprintf(c, sizeof(c), "d_%s", factory->label); snprintf(c, sizeof(c), "d_%s", factory->label);
if (EMSESP::nvs_.isKey(factory->label)) {
EMSESP::nvs_.remove(factory->label); EMSESP::nvs_.remove(factory->label);
}
if (EMSESP::nvs_.isKey(c)) {
EMSESP::nvs_.remove(c); EMSESP::nvs_.remove(c);
}
partition_info_.erase(factory->label); // on the AsyncTCP task, same as the web handlers reading it 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()); 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; 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 // initiate the restart
EMSESP::system_.systemStatus(SYSTEM_STATUS::SYSTEM_STATUS_RESTART_REQUESTED); EMSESP::system_.systemStatus(SYSTEM_STATUS::SYSTEM_STATUS_RESTART_REQUESTED);
return true; return true;
@@ -1323,6 +1333,16 @@ void System::show_system(uuid::console::Shell & shell) {
shell.printfln(" [total %d]", available.size()); shell.printfln(" [total %d]", available.size());
// List all partitions and their version info // List all partitions and their version info
shell.println(" Partitions:"); 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_) { for (const auto & partition : partition_info_) {
if (partition.second.version.empty()) { if (partition.second.version.empty()) {
continue; // no version, empty string 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)); strftime(time_string, sizeof(time_string), "%FT%T", localtime(&d));
installed = std::string(", installed on ") + time_string; installed = std::string(", installed on ") + time_string;
} }
shell.printfln(" %s: v%s (%d KB%s) %s", const bool is_running = strcmp(running_label, partition.first.c_str()) == 0;
partition.first.c_str(), const bool is_boot = strcmp(boot_label, partition.first.c_str()) == 0;
partition.second.version.c_str(), if (is_boot) {
partition.second.size, boot_listed = true;
installed.c_str(),
(strcmp(esp_ota_get_running_partition()->label, partition.first.c_str()) == 0) ? "** active **" : "");
} }
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 // List all NVS values
#ifndef EMSESP_STANDALONE #ifndef EMSESP_STANDALONE
shell.println(" NVS values:"); shell.println(" NVS values:");
+2 -1
View File
@@ -375,7 +375,8 @@ class System {
static constexpr uint8_t HEALTHCHECK_NO_NETWORK = (1 << 1); // 2 static constexpr uint8_t HEALTHCHECK_NO_NETWORK = (1 << 1); // 2
static constexpr uint8_t HEALTHCHECK_RESET = (1 << 7); // 128 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; static constexpr size_t MIN_FIRMWARE_SIZE = 1677721;
private: private:
+1 -1
View File
@@ -1 +1 @@
#define EMSESP_APP_VERSION "3.9.0-dev.16" #define EMSESP_APP_VERSION "3.9.0-dev.17"
+2 -3
View File
@@ -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 root["temperature"] = (int)Helpers::transformNumFloat(EMSESP::system_.temperature(), 0, EMSESP::system_.fahrenheit() ? 2 : 0); // only 2 decimal places
#endif #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); 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) { if (partition != NULL) {
root["recovery_version"] = EMSESP::system_.recovery_version(); // empty if not installed root["recovery_version"] = EMSESP::system_.recovery_version();
} }
partition = esp_ota_get_next_update_partition(nullptr); partition = esp_ota_get_next_update_partition(nullptr);
if (partition) { if (partition) {