diff --git a/.github/workflows/dev_release.yml b/.github/workflows/dev_release.yml index f518634e4..20e7b480d 100644 --- a/.github/workflows/dev_release.yml +++ b/.github/workflows/dev_release.yml @@ -71,6 +71,10 @@ jobs: run: | platformio run + - name: Build the recovery firmware for the 16MB boards + run: | + platformio run -e recovery_s_16M -e recovery_s_16M_P -e recovery_s3_16M_P + - name: Commit the generated files uses: stefanzweifel/git-auto-commit-action@v7 with: diff --git a/.github/workflows/stable_release.yml b/.github/workflows/stable_release.yml index f1f3b7dfe..c96af1acb 100644 --- a/.github/workflows/stable_release.yml +++ b/.github/workflows/stable_release.yml @@ -60,6 +60,10 @@ jobs: run: | platformio run + - name: Build the recovery firmware for the 16MB boards + run: | + platformio run -e recovery_s_16M -e recovery_s_16M_P -e recovery_s3_16M_P + - name: Create GitHub Release uses: emsesp/action-automatic-releases@v1.0.1 with: diff --git a/.github/workflows/test_release.yml b/.github/workflows/test_release.yml index ff961b26a..cc46c6a2c 100644 --- a/.github/workflows/test_release.yml +++ b/.github/workflows/test_release.yml @@ -61,6 +61,10 @@ jobs: run: | platformio run + - name: Build the recovery firmware for the 16MB boards + run: | + platformio run -e recovery_s_16M -e recovery_s_16M_P -e recovery_s3_16M_P + - name: Create GitHub Release id: 'automatic_releases' uses: emsesp/action-automatic-releases@v1.0.1 diff --git a/.gitignore b/.gitignore index 4039aeca4..28a1b7355 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,8 @@ analyse.html interface/vite.config.ts.timestamp* *.local src/ESP32React/WWWData.h +recovery/WWWData.h +/interface/dist-recovery # i18n generated files interface/src/i18n/i18n-react.tsx diff --git a/CHANGELOG_LATEST.md b/CHANGELOG_LATEST.md index 9b2aa95cc..55501ee4c 100644 --- a/CHANGELOG_LATEST.md +++ b/CHANGELOG_LATEST.md @@ -20,6 +20,11 @@ This release is based on the latest Espressif/Arduino core version 3. It brings - new Application Setting "Automatically check for firmware updates", off by default. When enabled EMS-ESP asks emsesp.org for the latest version once a day on its own, otherwise it only asks when the WebUI wants to show it, so an unattended system never contacts emsesp.org - Automatically check and convert older 3.8.x filesystem to the new 3.9.0 format - heatpump pool: `poolon` (switch, 0x48A), `hppoolon` (pool heating active, 0x488), MP100 `poolshuntset` (0x5C4) and `poolrequest` (0x5BA), shorter pool names +- EMS-ESP Recovery, a small firmware for the factory (`boot`) partition on 16MB boards. It starts a `-recovery` access point and connects to the network with the saved EMS-ESP settings (Ethernet if configured, otherwise WiFi, using the static IP if one is set), and has a WebUI to sign in with an existing admin user, upload a new firmware into either OTA partition, or start one of the installed firmwares. It only reads the EMS-ESP settings, never changes them. Released as `EMS-ESP-Recovery---16MB[+].bin`, and installed by uploading it in the EMS-ESP WebUI like a normal firmware, or flashed with `pio run -e recovery_s3_16M_P -t upload` +- firmware rollback: a newly installed firmware is only kept once it has run for 2 minutes. If it crashes or restarts before then, the device goes back to the previous firmware, or to the Recovery if there is none +- on 16MB boards with the Recovery installed, EMS-ESP switches to it after crashing 5 times in a row. The Recovery shows why it was started and which firmware failed to start +- the Version page shows the installed Recovery version on 16MB boards +- crash test builds to try out the rollback and recovery, see `[env:crashtest_s3_16M_P]` in `platformio.ini` - heat pump `dhw/disinfectduration`, the time allowed to reach the thermal disinfection temperature (0xEA offset 15, 120-240 min) [#3278](https://github.com/emsesp/EMS-ESP32/issues/3278) ## Fixed diff --git a/interface/.prettierignore b/interface/.prettierignore index 920369742..0bc827e2a 100644 --- a/interface/.prettierignore +++ b/interface/.prettierignore @@ -1,6 +1,7 @@ node_modules/ build/ dist/ +dist-recovery/ src/i18n/* .prettierrc diff --git a/interface/eslint.config.js b/interface/eslint.config.js index bacff3965..42b5c483f 100644 --- a/interface/eslint.config.js +++ b/interface/eslint.config.js @@ -18,6 +18,7 @@ export default defineConfig( { ignores: [ 'dist/*', + 'dist-recovery/*', '*.mjs', 'build/*', '*.js', diff --git a/interface/package.json b/interface/package.json index 95c0e9e8b..31905b4ab 100644 --- a/interface/package.json +++ b/interface/package.json @@ -18,6 +18,8 @@ "standalone": "concurrently -c \"auto\" \"typesafe-i18n\" \"pnpm:mock-rest\" \"vite dev\"", "typesafe-i18n": "typesafe-i18n --no-watch", "build-webUI": "typesafe-i18n --no-watch && vite build && node progmem-generator.js", + "recovery": "vite dev --mode recovery", + "build-recovery": "vite build --mode recovery && node progmem-generator.js --src ./dist-recovery --out ../recovery/WWWData.h --index recovery.html", "format": "prettier -l -w '**/*.{ts,tsx,js,css,json,md}'", "lint": "eslint . --fix", "standalone-devcontainer": "concurrently -c \"auto\" \"typesafe-i18n\" \"pnpm:mock-rest\" \"vite --host\"" @@ -26,12 +28,12 @@ "@alova/adapter-xhr": "2.3.3", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", - "@mui/icons-material": "^9.4.0", - "@mui/material": "^9.4.0", + "@mui/icons-material": "^9.5.0", + "@mui/material": "^9.5.0", "@table-library/react-table-library": "4.1.15", "alova": "^3.5.5", "jwt-decode": "^4.0.0", - "preact": "^11.0.0", + "preact": "^11.0.1", "react": "^19.3.0", "react-dom": "^19.3.0", "react-icons": "^5.7.0", @@ -45,7 +47,7 @@ "@types/node": "^26.6.4", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", - "concurrently": "^10.0.5", + "concurrently": "^10.0.6", "eslint": "^10.12.0", "eslint-config-prettier": "^10.1.8", "etag": "^1.8.1", @@ -55,7 +57,7 @@ "terser": "^5.51.2", "typescript": "^6.0.3", "typescript-eslint": "^8.71.1", - "vite": "^8.3.3", + "vite": "^8.3.4", "vite-plugin-imagemin": "^0.6.1" }, "packageManager": "pnpm@12.10.1" diff --git a/interface/pnpm-lock.yaml b/interface/pnpm-lock.yaml index 91d2b5913..d4020ce46 100644 --- a/interface/pnpm-lock.yaml +++ b/interface/pnpm-lock.yaml @@ -176,11 +176,11 @@ importers: specifier: ^11.14.1 version: 11.14.1(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2) '@mui/icons-material': - specifier: ^9.4.0 - version: 9.4.0(@mui/material@9.4.0(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(@types/react@19.3.0)(react@19.3.0) + specifier: ^9.5.0 + version: 9.5.0(@mui/material@9.5.0(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(@types/react@19.3.0)(react@19.3.0) '@mui/material': - specifier: ^9.4.0 - version: 9.4.0(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + specifier: ^9.5.0 + version: 9.5.0(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@table-library/react-table-library': specifier: 4.1.15 version: 4.1.15(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(react-dom@19.3.0(react@19.3.0))(react@19.3.0) @@ -191,8 +191,8 @@ importers: specifier: ^4.0.0 version: 4.0.0 preact: - specifier: ^11.0.0 - version: 11.0.0 + specifier: ^11.0.1 + version: 11.0.1 react: specifier: ^19.3.0 version: 19.3.0 @@ -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.3(@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.1)(supports-color@10.2.2)(vite@8.3.4(@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) @@ -228,8 +228,8 @@ importers: specifier: ^19.3.0 version: 19.3.0(@types/react@19.3.0) concurrently: - specifier: ^10.0.5 - version: 10.0.5 + specifier: ^10.0.6 + version: 10.0.6 eslint: specifier: ^10.12.0 version: 10.12.0(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.3 - version: 8.3.3(@types/node@26.6.4)(terser@5.51.2) + specifier: ^8.3.4 + version: 8.3.4(@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.3(@types/node@26.6.4)(terser@5.51.2)) + version: 0.6.1(supports-color@10.2.2)(vite@8.3.4(@types/node@26.6.4)(terser@5.51.2)) packages: @@ -359,6 +359,9 @@ packages: resolution: {integrity: sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==} engines: {node: '>=6.9.0'} + '@babel/runtime@8.0.7': + resolution: {integrity: sha512-k0hagDDq/6w4v+0Sp4wFvIz/yswH5O7Va6aRTnD7N62Tnborp4X+YB+H3XUcWVSR4aE47WPEXiIIZchBs0GmYg==} + '@babel/template@7.29.7': resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} engines: {node: '>=6.9.0'} @@ -524,27 +527,27 @@ packages: '@keyv/serialize@1.1.1': resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==} - '@mui/core-downloads-tracker@9.4.0': - resolution: {integrity: sha512-d+gIYM1raJP87yrVQcVg+Q3vycVMArMkgu/TjJLi2vTvO9FHKWNRR1xs9nnN4eLpM9nGxxm02UMvc9BrmcUKvQ==} + '@mui/core-downloads-tracker@9.5.0': + resolution: {integrity: sha512-PErEpl3Lp+1ZqWYKI7GQU8XSiciAFmLbUAZ7IoaUL598HLy1UAFuEUMEJu8yAS1v8vkXYzWprMVZzky4UwXoOA==} - '@mui/icons-material@9.4.0': - resolution: {integrity: sha512-5PVgBYtLOXTk6u0YUAjoV9GoTUQDbeZ8g+pus2h0GphLT/rpmftRnB5D/Kw6QCAovB7EyBX7UxKdZqPBBAHUKw==} + '@mui/icons-material@9.5.0': + resolution: {integrity: sha512-dahtVaqnRUIBgaZ4WC9EdP+LgJ5d2rslASYIX+nDFG6+ssUiM11jQ+Gd1wH8vhUGoNA9ES40MYics5BI8zJFXQ==} engines: {node: '>=14.0.0'} peerDependencies: - '@mui/material': ^9.4.0 + '@mui/material': ^9.5.0 '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 react: ^17.0.0 || ^18.0.0 || ^19.0.0 peerDependenciesMeta: '@types/react': optional: true - '@mui/material@9.4.0': - resolution: {integrity: sha512-6KKUIvkQ2r/s48s9VZV9JEZ1W2dRa7RndVsF+Ipx4CYxDOQeozeRsS5r9NiheFf8A5hpbqJJIGoX7hjg5X4XUw==} + '@mui/material@9.5.0': + resolution: {integrity: sha512-QgqvdVjZoKxP8E8SBxYY6LrdV+kw00moXweZQWBbMMdifPKZAGfUaaXhGKIca6fjKnCL6HJIe/AWWCpqNvyHVw==} engines: {node: '>=14.0.0'} peerDependencies: '@emotion/react': ^11.5.0 '@emotion/styled': ^11.3.0 - '@mui/material-pigment-css': ^9.4.0 + '@mui/material-pigment-css': ^9.5.0 '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 react: ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -558,8 +561,8 @@ packages: '@types/react': optional: true - '@mui/private-theming@9.4.0': - resolution: {integrity: sha512-qQpX/2XPGDz5F2OIWzo4kkorURlDeXGKytRQudXCdLS5TuwKsMKaZyx77LZEVSB73c7o3UrLWwQ/goRuHBdtjw==} + '@mui/private-theming@9.5.0': + resolution: {integrity: sha512-UHZKySlr4ZJeA5HEhe/Tq1fhsR5573ViKYn7JV80dCVJtgqRQIplRSer1DHPg2Yi22llX7QRhgM0Vtrx4kZHSA==} engines: {node: '>=14.0.0'} peerDependencies: '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -568,8 +571,8 @@ packages: '@types/react': optional: true - '@mui/styled-engine@9.4.0': - resolution: {integrity: sha512-SlqVEYqnyEgXMFW4JuIDMelyhKzoWBJ85v2mNCQd0XtU0uk7U9E1zaJZ06XqodhA8oJrb+4Q+3E/JlwBe4jWkw==} + '@mui/styled-engine@9.5.0': + resolution: {integrity: sha512-IX1ceHnZ0bJgzJRWO/Ayl4ME1rNmpWVYwV28yb089UAMR17rJkV1qfr7ApsWQXXPi1oi+2JapJrWQAiAnTlJHQ==} engines: {node: '>=14.0.0'} peerDependencies: '@emotion/react': ^11.4.1 @@ -581,8 +584,8 @@ packages: '@emotion/styled': optional: true - '@mui/system@9.4.0': - resolution: {integrity: sha512-AZz9z13oGS1J0BYeAFHTbV7PegCtPiE/uMEIi7TJ1kTx3Vkt+jUpLyXF/2uV8B6Y79iTh98kVeOH9fX3x3zXXg==} + '@mui/system@9.5.0': + resolution: {integrity: sha512-OZuAwvoiaaHpvn0a540VH1DUwwmojJubGfNmO2g+nb2ybNDP4/gF7t7aA6AMduELSuJ5yxwlzyFvUO3CfLv1Yg==} engines: {node: '>=14.0.0'} peerDependencies: '@emotion/react': ^11.5.0 @@ -597,16 +600,16 @@ packages: '@types/react': optional: true - '@mui/types@9.4.0': - resolution: {integrity: sha512-13DH0Oniua4WmGqeYbQAZqmiN7r4nfd0zwIoGJ5QeJwyIHgEmf+LkRw/jV1HZb6AiUoHX8Oxf4xgalq3vYHcIw==} + '@mui/types@9.5.0': + resolution: {integrity: sha512-O0S6xS80qEQqW2CYNrd4GlBEQ1xpMOHZ5b4zO/DaLu6NZmtthM6smEg0Gz/u9gX5vL+8NB3EiBIxmxSCcG590Q==} peerDependencies: '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 peerDependenciesMeta: '@types/react': optional: true - '@mui/utils@9.4.0': - resolution: {integrity: sha512-WkzY8uYtqUDyGKPShrRQRBomfMQHddlsyKu/gRRESYO24tDPD2z0xRE8CBoUsWqpBa+YrFs9KixiS7kQRjsPAQ==} + '@mui/utils@9.5.0': + resolution: {integrity: sha512-8m0LfSgE2pj58znY3kZ+anwRKbnBkcu/7AJPA8L/77wEjN0QnpX4/zRAccyplp1VEUnMsUBADeTRin5Mkf7epA==} engines: {node: '>=14.0.0'} peerDependencies: '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -1004,8 +1007,8 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - baseline-browser-mapping@2.11.27: - resolution: {integrity: sha512-ElY12DaROGuan+lMmZ8Cvo/ZUbXPe7Enc/9VU/b1T3Kp4dwytRcNdR8DoSJN5SNJT/CuvcCA0DHDVmMOCePdRQ==} + baseline-browser-mapping@2.11.28: + resolution: {integrity: sha512-br0LOmjJqr6Rt4AEkadbcNf67Zpo7EPwKsWbjnRrJIa1QYFHQBqM4C7F2WTbV8ahTEcg1Eo+Y0aWjjORInYFyA==} engines: {node: '>=6.0.0'} hasBin: true @@ -1157,8 +1160,8 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - concurrently@10.0.5: - resolution: {integrity: sha512-JaP/CoftUrCcAFW/g//RbgEGwlelnEae6cfBLgH6ZdO6s8jPkn6p9SB9u6pdVxYXoiSnFqseOlHfrEfF82TVOg==} + concurrently@10.0.6: + resolution: {integrity: sha512-qgICXXFp2/3nAebRLmxcINKEXM1mLK3ij/y31P02VTs/KIkddIeC05n73DxOXsXMB9LFSqNU1/na3lDlOhcbIw==} engines: {node: '>=22'} hasBin: true @@ -1338,8 +1341,8 @@ packages: duplexer3@0.1.5: resolution: {integrity: sha512-1A8za6ws41LQgv9HrE/66jyC5yuSjQ3L/KOpFtoBilsAK2iA2wuS5rTt1OCzIvtS2V7nVmedsUU+DGRcjBmOYA==} - electron-to-chromium@1.5.449: - resolution: {integrity: sha512-dKeYk/3SFQz+nXi5whp3OuJzeWABEG4zCtkWJ9+Vby1E1iFM2y0gElRtQvNXRPJTsj8WAF4HMP7hvP790QtJ9g==} + electron-to-chromium@1.5.452: + resolution: {integrity: sha512-cVvk1YBSj6571VmV6hdJTqj94Y79xSu8cj6Y9t6ae4Y4431z8/7EJFvu3rVp90xhfuv9BpPKUmaLfTo4+FOGBg==} emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -2318,8 +2321,8 @@ packages: node-html-parser@6.1.13: resolution: {integrity: sha512-qIsTMOY4C/dAa5Q5vsobRpOOvPfC4pB61UVW2uSwZNUp0QU/jCekTal1vMmbO0DgdHeLUJpv/ARmDqErVxA3Sg==} - node-releases@2.0.57: - resolution: {integrity: sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==} + node-releases@2.0.58: + resolution: {integrity: sha512-BdiKAgbfRlvfMq/XzO/AF9NPmaA8a+YpLAyAFTVFBj3gARrAIfWvNq7Q7NE4se3KgFu0KkrvQMxTS0XVnsGzHg==} engines: {node: '>=18'} normalize-package-data@2.5.0: @@ -2531,8 +2534,8 @@ packages: resolution: {integrity: sha512-C+y9x90UElAddDZmV4qOx9W53B61PO7cIqWz2dQsWlwswuq4mr8NEwytdGKboYbQlGZ3awrkTeNvcZiZNHnQ8A==} engines: {node: '>=20'} - preact@11.0.0: - resolution: {integrity: sha512-LRTLKuSe6VYPhvBDjwFaX+nuZnpsw6IN2GSboisAvhGsncylBmrbDCERhgKDn6yP2mAwbGvsB5OVyTe33M8gBg==} + preact@11.0.1: + resolution: {integrity: sha512-TNNwEq++jYNHy747LziCjPRVgEwnLSOIOAw/oYcjI3Tdnubx2EXtt76I0an8PATvUWtx/DX1kFIg1eFN+O/fkw==} peerDependencies: preact-render-to-string: '>=6.7.0' peerDependenciesMeta: @@ -2768,8 +2771,8 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} - shell-quote@1.9.0: - resolution: {integrity: sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==} + shell-quote@1.12.0: + resolution: {integrity: sha512-PcByqNyT/38F2kDNi006HAMRJaULuBzq/FOsw3qdZvX/GA9W/jamDaRskgHjubHiftXK5sIFxLNkvrXUwcof6Q==} engines: {node: '>= 0.4'} signal-exit@3.0.7: @@ -3015,8 +3018,8 @@ packages: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} - update-browserslist-db@1.3.3: - resolution: {integrity: sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==} + update-browserslist-db@1.3.4: + resolution: {integrity: sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==} hasBin: true peerDependencies: browserslist: '>= 4.21.0' @@ -3057,8 +3060,8 @@ packages: peerDependencies: vite: 5.x || 6.x || 7.x || 8.x - vite@8.3.3: - resolution: {integrity: sha512-cTAldKPImjg6c+gk48U19POPn3GCBzZwpdsN8ZMEEcbpes+6/wvfqUd0C2y3qYY4wsj8PwgFwrZ/1jBPVttMSg==} + vite@8.3.4: + resolution: {integrity: sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -3285,6 +3288,8 @@ snapshots: '@babel/runtime@7.29.10': {} + '@babel/runtime@8.0.7': {} + '@babel/template@7.29.7': dependencies: '@babel/code-frame': 7.29.7 @@ -3488,23 +3493,23 @@ snapshots: '@keyv/serialize@1.1.1': {} - '@mui/core-downloads-tracker@9.4.0': {} + '@mui/core-downloads-tracker@9.5.0': {} - '@mui/icons-material@9.4.0(@mui/material@9.4.0(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(@types/react@19.3.0)(react@19.3.0)': + '@mui/icons-material@9.5.0(@mui/material@9.5.0(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(@types/react@19.3.0)(react@19.3.0)': dependencies: - '@babel/runtime': 7.29.10 - '@mui/material': 9.4.0(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@babel/runtime': 8.0.7 + '@mui/material': 9.5.0(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) react: 19.3.0 optionalDependencies: '@types/react': 19.3.0 - '@mui/material@9.4.0(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + '@mui/material@9.5.0(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: - '@babel/runtime': 7.29.10 - '@mui/core-downloads-tracker': 9.4.0 - '@mui/system': 9.4.0(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@types/react@19.3.0)(react@19.3.0) - '@mui/types': 9.4.0(@types/react@19.3.0) - '@mui/utils': 9.4.0(@types/react@19.3.0)(react@19.3.0) + '@babel/runtime': 8.0.7 + '@mui/core-downloads-tracker': 9.5.0 + '@mui/system': 9.5.0(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@types/react@19.3.0)(react@19.3.0) + '@mui/types': 9.5.0(@types/react@19.3.0) + '@mui/utils': 9.5.0(@types/react@19.3.0)(react@19.3.0) '@popperjs/core': 2.11.8 '@types/react-transition-group': 4.4.12(@types/react@19.3.0) clsx: 2.1.1 @@ -3519,18 +3524,18 @@ snapshots: '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2) '@types/react': 19.3.0 - '@mui/private-theming@9.4.0(@types/react@19.3.0)(react@19.3.0)': + '@mui/private-theming@9.5.0(@types/react@19.3.0)(react@19.3.0)': dependencies: - '@babel/runtime': 7.29.10 - '@mui/utils': 9.4.0(@types/react@19.3.0)(react@19.3.0) + '@babel/runtime': 8.0.7 + '@mui/utils': 9.5.0(@types/react@19.3.0)(react@19.3.0) prop-types: 15.8.1 react: 19.3.0 optionalDependencies: '@types/react': 19.3.0 - '@mui/styled-engine@9.4.0(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(react@19.3.0)': + '@mui/styled-engine@9.5.0(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(react@19.3.0)': dependencies: - '@babel/runtime': 7.29.10 + '@babel/runtime': 8.0.7 '@emotion/cache': 11.14.0 '@emotion/serialize': 1.3.3 '@emotion/sheet': 1.4.0 @@ -3541,13 +3546,13 @@ snapshots: '@emotion/react': 11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2) '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2) - '@mui/system@9.4.0(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@types/react@19.3.0)(react@19.3.0)': + '@mui/system@9.5.0(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@types/react@19.3.0)(react@19.3.0)': dependencies: - '@babel/runtime': 7.29.10 - '@mui/private-theming': 9.4.0(@types/react@19.3.0)(react@19.3.0) - '@mui/styled-engine': 9.4.0(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(react@19.3.0) - '@mui/types': 9.4.0(@types/react@19.3.0) - '@mui/utils': 9.4.0(@types/react@19.3.0)(react@19.3.0) + '@babel/runtime': 8.0.7 + '@mui/private-theming': 9.5.0(@types/react@19.3.0)(react@19.3.0) + '@mui/styled-engine': 9.5.0(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(react@19.3.0) + '@mui/types': 9.5.0(@types/react@19.3.0) + '@mui/utils': 9.5.0(@types/react@19.3.0)(react@19.3.0) clsx: 2.1.1 csstype: 3.2.3 prop-types: 15.8.1 @@ -3557,16 +3562,16 @@ snapshots: '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2))(@types/react@19.3.0)(react@19.3.0)(supports-color@10.2.2) '@types/react': 19.3.0 - '@mui/types@9.4.0(@types/react@19.3.0)': + '@mui/types@9.5.0(@types/react@19.3.0)': dependencies: - '@babel/runtime': 7.29.10 + '@babel/runtime': 8.0.7 optionalDependencies: '@types/react': 19.3.0 - '@mui/utils@9.4.0(@types/react@19.3.0)(react@19.3.0)': + '@mui/utils@9.5.0(@types/react@19.3.0)(react@19.3.0)': dependencies: - '@babel/runtime': 7.29.10 - '@mui/types': 9.4.0(@types/react@19.3.0) + '@babel/runtime': 8.0.7 + '@mui/types': 9.5.0(@types/react@19.3.0) '@types/prop-types': 15.7.15 clsx: 2.1.1 prop-types: 15.8.1 @@ -3591,19 +3596,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.3(@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.1)(supports-color@10.2.2)(vite@8.3.4(@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.3(@types/node@26.6.4)(terser@5.51.2)) + '@prefresh/vite': 2.4.12(preact@11.0.1)(supports-color@10.2.2)(vite@8.3.4(@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.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)) + vite: 8.3.4(@types/node@26.6.4)(terser@5.51.2) + vite-prerender-plugin: 0.5.14(vite@8.3.4(@types/node@26.6.4)(terser@5.51.2)) zimmerframe: 1.1.5 transitivePeerDependencies: - preact @@ -3614,21 +3619,21 @@ snapshots: dependencies: '@babel/core': 7.29.7(supports-color@10.2.2) - '@prefresh/core@1.5.11(preact@11.0.0)': + '@prefresh/core@1.5.11(preact@11.0.1)': dependencies: - preact: 11.0.0 + preact: 11.0.1 '@prefresh/utils@1.2.1': {} - '@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))': + '@prefresh/vite@2.4.12(preact@11.0.1)(supports-color@10.2.2)(vite@8.3.4(@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)) - '@prefresh/core': 1.5.11(preact@11.0.0) + '@prefresh/core': 1.5.11(preact@11.0.1) '@prefresh/utils': 1.2.1 '@rollup/pluginutils': 4.2.1 - preact: 11.0.0 - vite: 8.3.3(@types/node@26.6.4)(terser@5.51.2) + preact: 11.0.1 + vite: 8.3.4(@types/node@26.6.4)(terser@5.51.2) transitivePeerDependencies: - supports-color @@ -3942,7 +3947,7 @@ snapshots: base64-js@1.5.1: {} - baseline-browser-mapping@2.11.27: {} + baseline-browser-mapping@2.11.28: {} bin-build@3.0.0: dependencies: @@ -4003,11 +4008,11 @@ snapshots: browserslist@4.29.3: dependencies: - baseline-browser-mapping: 2.11.27 + baseline-browser-mapping: 2.11.28 caniuse-lite: 1.0.30001815 - electron-to-chromium: 1.5.449 - node-releases: 2.0.57 - update-browserslist-db: 1.3.3(browserslist@4.29.3) + electron-to-chromium: 1.5.452 + node-releases: 2.0.58 + update-browserslist-db: 1.3.4(browserslist@4.29.3) buffer-alloc-unsafe@1.1.0: {} @@ -4125,11 +4130,11 @@ snapshots: concat-map@0.0.1: {} - concurrently@10.0.5: + concurrently@10.0.6: dependencies: chalk: 5.6.2 rxjs: 7.8.2 - shell-quote: 1.9.0 + shell-quote: 1.12.0 supports-color: 10.2.2 tree-kill: 1.2.2 yargs: 18.0.0 @@ -4370,7 +4375,7 @@ snapshots: duplexer3@0.1.5: {} - electron-to-chromium@1.5.449: {} + electron-to-chromium@1.5.452: {} emoji-regex@10.6.0: {} @@ -5307,7 +5312,7 @@ snapshots: css-select: 5.2.2 he: 1.2.0 - node-releases@2.0.57: {} + node-releases@2.0.58: {} normalize-package-data@2.5.0: dependencies: @@ -5501,7 +5506,7 @@ snapshots: powershell-utils@0.2.1: {} - preact@11.0.0: {} + preact@11.0.1: {} prelude-ls@1.2.1: {} @@ -5724,7 +5729,7 @@ snapshots: shebang-regex@3.0.0: {} - shell-quote@1.9.0: {} + shell-quote@1.12.0: {} signal-exit@3.0.7: {} @@ -5953,7 +5958,7 @@ snapshots: universalify@2.0.1: {} - update-browserslist-db@1.3.3(browserslist@4.29.3): + update-browserslist-db@1.3.4(browserslist@4.29.3): dependencies: browserslist: 4.29.3 escalade: 3.2.0 @@ -5982,7 +5987,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.3(@types/node@26.6.4)(terser@5.51.2)): + vite-plugin-imagemin@0.6.1(supports-color@10.2.2)(vite@8.3.4(@types/node@26.6.4)(terser@5.51.2)): dependencies: '@types/imagemin': 7.0.1 '@types/imagemin-gifsicle': 7.0.4 @@ -6007,11 +6012,11 @@ snapshots: imagemin-webp: 6.1.0 jpegtran-bin: 6.0.1 pathe: 0.2.0 - vite: 8.3.3(@types/node@26.6.4)(terser@5.51.2) + vite: 8.3.4(@types/node@26.6.4)(terser@5.51.2) transitivePeerDependencies: - supports-color - vite-prerender-plugin@0.5.14(vite@8.3.3(@types/node@26.6.4)(terser@5.51.2)): + vite-prerender-plugin@0.5.14(vite@8.3.4(@types/node@26.6.4)(terser@5.51.2)): dependencies: kolorist: 1.8.0 magic-string: 0.30.21 @@ -6019,9 +6024,9 @@ snapshots: simple-code-frame: 1.3.0 source-map: 0.7.6 stack-trace: 1.0.0 - vite: 8.3.3(@types/node@26.6.4)(terser@5.51.2) + vite: 8.3.4(@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.4(@types/node@26.6.4)(terser@5.51.2): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 diff --git a/interface/progmem-generator.js b/interface/progmem-generator.js index 211721a2c..74cbae222 100644 --- a/interface/progmem-generator.js +++ b/interface/progmem-generator.js @@ -10,10 +10,17 @@ import mime from 'mime-types'; import { relative, resolve, sep } from 'path'; import zlib from 'zlib'; +// optional args: --src --out --index +const getArg = (name, fallback) => { + const i = process.argv.indexOf(name); + return i !== -1 && process.argv[i + 1] ? process.argv[i + 1] : fallback; +}; + const ARDUINO_INCLUDES = '#include \n\n'; const INDENT = ' '; -const outputPath = '../src/ESP32React/WWWData.h'; -const sourcePath = './dist'; +const outputPath = getArg('--out', '../src/ESP32React/WWWData.h'); +const sourcePath = getArg('--src', './dist'); +const indexFile = getArg('--index', 'index.html'); const bytesPerLine = 20; let totalSize = 0; let bundleStats = { @@ -129,7 +136,10 @@ const writeFile = (relativeFilePath, buffer) => { bundleStats[fileType].compressed += zipBuffer.length; fileInfo.push({ - uri: '/' + relativeFilePath.replace(sep, '/'), + uri: + relativeFilePath === indexFile + ? '/index.html' + : '/' + relativeFilePath.replace(sep, '/'), mimeType, variable, size, diff --git a/interface/recovery.html b/interface/recovery.html new file mode 100644 index 000000000..59ffcf406 --- /dev/null +++ b/interface/recovery.html @@ -0,0 +1,13 @@ + + + + + + + EMS-ESP Recovery + + +
+ + + diff --git a/interface/src/app/main/UserProfile.tsx b/interface/src/app/main/UserProfile.tsx index ebcae0be3..bad7cbab8 100644 --- a/interface/src/app/main/UserProfile.tsx +++ b/interface/src/app/main/UserProfile.tsx @@ -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 = () => { @@ -664,64 +670,69 @@ const Version = () => { {LL.THIS_VERSION()} - - - {LL.VERSION()} - - - + {LL.VERSION()} + + {data.emsesp_version} {data.build_flags && ( - -   ({data.build_flags}) + +  ({data.build_flags}) )} - setPartitionVersionInfo(data.partition)} - aria-label={LL.FIRMWARE_VERSION_INFO()} - > - - - + setPartitionVersionInfo(data.partition)} + aria-label={LL.FIRMWARE_VERSION_INFO()} + sx={{ p: 0.5 }} + > + + + - - {LL.PLATFORM()} - - - - {platform} - -   ( - {data.psram ? ( - - ) : ( - - )} - PSRAM) - + {LL.PLATFORM()} + + {platform} + + ( + {data.psram ? ( + + ) : ( + + )} + PSRAM) - - + + {internetLive ? ( <> @@ -729,83 +740,147 @@ const Version = () => { {LL.AVAILABLE_VERSION()} - - {otherPartitions.length > 0 && data.developer_mode && ( + {data.developer_mode && ( <> - - {LL.STORED_VERSIONS()} - - - {otherPartitions.map((partition) => ( - - {partition.version} - - setPartitionVersionInfo(partition.partition) - } - aria-label={LL.FIRMWARE_VERSION_INFO()} - > - - - + {otherPartitions.length > 0 && ( + <> + + {LL.STORED_VERSIONS()} - ))} - + + {otherPartitions.map((partition) => ( + + + {partition.version} + + + setPartitionVersionInfo(partition.partition) + } + aria-label={LL.FIRMWARE_VERSION_INFO()} + sx={{ p: 0.5 }} + > + + + + + ))} + + + )} + {data.recovery_version !== undefined && ( + <> + {LL.RECOVERY()} + + + {data.recovery_version || LL.RECOVERY_NOT_INSTALLED()} + + {data.recovery_version && me.admin && ( + + )} + + + + )} )} - - {LL.STABLE()} - - - - {latestVersion?.version} - setShowVersionInfo(1)} - aria-label={LL.FIRMWARE_VERSION_INFO()} - > - - - {showButtons(false)} - - + {LL.STABLE()} + + {latestVersion?.version} + setShowVersionInfo(1)} + aria-label={LL.FIRMWARE_VERSION_INFO()} + sx={{ p: 0.5 }} + > + + + {showButtons(false)} + - - {LL.DEVELOPMENT()} - - - - {latestDevVersion?.version} - setShowVersionInfo(2)} - aria-label={LL.FIRMWARE_VERSION_INFO()} - > - - - {showButtons(true)} - - - + {LL.DEVELOPMENT()} + + {latestDevVersion?.version} + setShowVersionInfo(2)} + aria-label={LL.FIRMWARE_VERSION_INFO()} + sx={{ p: 0.5 }} + > + + + {showButtons(true)} + + ) : ( @@ -848,7 +923,11 @@ const Version = () => { {LL.UPLOAD()} - + void loadData()} + /> )} diff --git a/interface/src/components/upload/SingleUpload.tsx b/interface/src/components/upload/SingleUpload.tsx index 781994e28..30a521256 100644 --- a/interface/src/components/upload/SingleUpload.tsx +++ b/interface/src/components/upload/SingleUpload.tsx @@ -14,10 +14,15 @@ import { LinearProgressWithLabel } from './LinearProgressWithLabel'; interface SingleUploadProps { doRestart: () => void; + onRecoveryInstalled?: () => void; text: string; } -const SingleUpload = ({ doRestart, text }: SingleUploadProps) => { +const SingleUpload = ({ + doRestart, + onRecoveryInstalled, + text +}: SingleUploadProps) => { const [md5, setMd5] = useState(); const [file, setFile] = useState(); const { LL } = useI18nContext(); @@ -42,6 +47,12 @@ const SingleUpload = ({ doRestart, text }: SingleUploadProps) => { setMd5((payload as { md5: string }).md5); toast.success(LL.UPLOAD_MD5_RECEIVED()); setFile(undefined); + } else if (payload && typeof payload === 'object' && 'recovery' in payload) { + // written to the factory partition, EMS-ESP keeps running + toast.success(LL.RECOVERY_INSTALLED()); + setMd5(undefined); + setFile(undefined); + onRecoveryInstalled?.(); } else { if (payload && typeof payload === 'object' && 'md5_ok' in payload) { toast.success(LL.UPLOAD_MD5_MATCHED()); diff --git a/interface/src/i18n/cs/index.ts b/interface/src/i18n/cs/index.ts index ec1c474c5..76284dbee 100644 --- a/interface/src/i18n/cs/index.ts +++ b/interface/src/i18n/cs/index.ts @@ -352,7 +352,10 @@ const cs: Translation = { UPLOAD_BACKUP_TEXT: 'Klikněte, abyste vybrali, nebo drop soubor zálohy (.json) sem', UPLOAD_FIRMWARE_TEXT: 'Klikněte, abyste vybrali, nebo drop soubor firmware .bin/.md5 sem', UPLOAD_MD5_RECEIVED: 'Digest přijat, nyní nahrajte firmware .bin', - UPLOAD_MD5_MATCHED: 'MD5 souhlasí, firmware se instaluje...' + UPLOAD_MD5_MATCHED: 'MD5 souhlasí, firmware se instaluje...', + RECOVERY: 'Obnova', + RECOVERY_NOT_INSTALLED: 'Nenainstalováno', + RECOVERY_INSTALLED: 'Firmware pro obnovu nainstalován' }; export default cs; diff --git a/interface/src/i18n/de/index.ts b/interface/src/i18n/de/index.ts index 9254364cc..352de1152 100644 --- a/interface/src/i18n/de/index.ts +++ b/interface/src/i18n/de/index.ts @@ -352,7 +352,10 @@ const de: Translation = { UPLOAD_BACKUP_TEXT: 'Klicken Sie, um eine Sicherungsdatei (.json) auszuwählen, oder drop ein', UPLOAD_FIRMWARE_TEXT: 'Klicken Sie, um eine Firmware-Datei (.bin/.md5) auszuwählen, oder drop ein', UPLOAD_MD5_RECEIVED: 'Prüfsumme empfangen, jetzt die Firmware-Datei (.bin) hochladen', - UPLOAD_MD5_MATCHED: 'MD5 stimmt überein, Firmware wird installiert...' + UPLOAD_MD5_MATCHED: 'MD5 stimmt überein, Firmware wird installiert...', + RECOVERY: 'Wiederherstellung', + RECOVERY_NOT_INSTALLED: 'Nicht installiert', + RECOVERY_INSTALLED: 'Wiederherstellungs-Firmware installiert' }; export default de; diff --git a/interface/src/i18n/en/index.ts b/interface/src/i18n/en/index.ts index 390ced0f2..49c3878b1 100644 --- a/interface/src/i18n/en/index.ts +++ b/interface/src/i18n/en/index.ts @@ -352,7 +352,10 @@ const en: Translation = { UPLOAD_BACKUP_TEXT: 'Click to select, or drop a backup file (.json) here', UPLOAD_FIRMWARE_TEXT: 'Click to select, or drop a firmware .bin/.md5 file here', UPLOAD_MD5_RECEIVED: 'Digest received, now upload the firmware .bin file', - UPLOAD_MD5_MATCHED: 'MD5 matches, installing firmware...' + UPLOAD_MD5_MATCHED: 'MD5 matches, installing firmware...', + RECOVERY: 'Recovery', + RECOVERY_NOT_INSTALLED: 'Not installed', + RECOVERY_INSTALLED: 'Recovery firmware installed' }; export default en; diff --git a/interface/src/i18n/fr/index.ts b/interface/src/i18n/fr/index.ts index e3df37e48..303f23510 100644 --- a/interface/src/i18n/fr/index.ts +++ b/interface/src/i18n/fr/index.ts @@ -352,7 +352,10 @@ const fr: Translation = { UPLOAD_BACKUP_TEXT: 'Cliquez pour sélectionner, ou drop un fichier de sauvegarde (.json) ici', UPLOAD_FIRMWARE_TEXT: 'Cliquez pour sélectionner, ou drop un fichier firmware .bin/.md5 ici', UPLOAD_MD5_RECEIVED: 'Empreinte reçue, téléversez maintenant le fichier firmware .bin', - UPLOAD_MD5_MATCHED: 'MD5 concordant, installation du firmware...' + UPLOAD_MD5_MATCHED: 'MD5 concordant, installation du firmware...', + RECOVERY: 'Récupération', + RECOVERY_NOT_INSTALLED: 'Non installé', + RECOVERY_INSTALLED: 'Firmware de récupération installé' }; export default fr; diff --git a/interface/src/i18n/it/index.ts b/interface/src/i18n/it/index.ts index 75ed58542..39654977c 100644 --- a/interface/src/i18n/it/index.ts +++ b/interface/src/i18n/it/index.ts @@ -352,7 +352,10 @@ const it: Translation = { UPLOAD_BACKUP_TEXT: 'Klik per selezionare, o drop un file di backup (.json) qui', UPLOAD_FIRMWARE_TEXT: 'Klik per selezionare, o drop un file firmware .bin/.md5 qui', UPLOAD_MD5_RECEIVED: 'Digest ricevuto, ora caricare il file firmware .bin', - UPLOAD_MD5_MATCHED: 'MD5 corrispondente, installazione firmware...' + UPLOAD_MD5_MATCHED: 'MD5 corrispondente, installazione firmware...', + RECOVERY: 'Ripristino', + RECOVERY_NOT_INSTALLED: 'Non installato', + RECOVERY_INSTALLED: 'Firmware di ripristino installato' }; export default it; diff --git a/interface/src/i18n/nl/index.ts b/interface/src/i18n/nl/index.ts index 2e9e39d37..15e764226 100644 --- a/interface/src/i18n/nl/index.ts +++ b/interface/src/i18n/nl/index.ts @@ -352,7 +352,10 @@ const nl: Translation = { UPLOAD_BACKUP_TEXT: 'Klik voor selecteren, of drop een back-upbestand (.json) hier', UPLOAD_FIRMWARE_TEXT: 'Klik voor selecteren, of drop een firmware .bin/.md5 bestand hier', UPLOAD_MD5_RECEIVED: 'Digest ontvangen, upload nu het firmware .bin-bestand', - UPLOAD_MD5_MATCHED: 'MD5 komt overeen, firmware wordt geïnstalleerd...' + UPLOAD_MD5_MATCHED: 'MD5 komt overeen, firmware wordt geïnstalleerd...', + RECOVERY: 'Herstel', + RECOVERY_NOT_INSTALLED: 'Niet geïnstalleerd', + RECOVERY_INSTALLED: 'Herstelfirmware geïnstalleerd' }; export default nl; diff --git a/interface/src/i18n/no/index.ts b/interface/src/i18n/no/index.ts index e0824ac2e..788ee326b 100644 --- a/interface/src/i18n/no/index.ts +++ b/interface/src/i18n/no/index.ts @@ -352,7 +352,10 @@ const no: Translation = { UPLOAD_BACKUP_TEXT: 'Klikk for å velge, eller drop en sikkerhetskopi (.json) her', UPLOAD_FIRMWARE_TEXT: 'Klikk for å velge, eller drop en firmware .bin/.md5 her', UPLOAD_MD5_RECEIVED: 'MD5 mottatt, last nå opp firmware .bin-filen', - UPLOAD_MD5_MATCHED: 'MD5 stemmer, firmware installeres...' + UPLOAD_MD5_MATCHED: 'MD5 stemmer, firmware installeres...', + RECOVERY: 'Gjenoppretting', + RECOVERY_NOT_INSTALLED: 'Ikke installert', + RECOVERY_INSTALLED: 'Gjenopprettingsfirmware installert' }; export default no; diff --git a/interface/src/i18n/pl/index.ts b/interface/src/i18n/pl/index.ts index 89ef48b5e..3f6c47e62 100644 --- a/interface/src/i18n/pl/index.ts +++ b/interface/src/i18n/pl/index.ts @@ -352,7 +352,10 @@ const pl: BaseTranslation = { UPLOAD_BACKUP_TEXT: 'Kliknij, aby wybrać, lub drop plik kopii zapasowej (.json) tutaj', UPLOAD_FIRMWARE_TEXT: 'Kliknij, aby wybrać, lub drop plik firmware .bin/.md5 tutaj', UPLOAD_MD5_RECEIVED: 'Suma MD5 odebrana, teraz wgraj plik firmware .bin', - UPLOAD_MD5_MATCHED: 'MD5 zgodne, instalowanie firmware...' + UPLOAD_MD5_MATCHED: 'MD5 zgodne, instalowanie firmware...', + RECOVERY: 'Odzyskiwanie', + RECOVERY_NOT_INSTALLED: 'Nie zainstalowano', + RECOVERY_INSTALLED: 'Zainstalowano firmware odzyskiwania' }; export default pl; diff --git a/interface/src/i18n/sk/index.ts b/interface/src/i18n/sk/index.ts index 1d2940aef..f8a8bcf9e 100644 --- a/interface/src/i18n/sk/index.ts +++ b/interface/src/i18n/sk/index.ts @@ -352,7 +352,10 @@ const sk: Translation = { UPLOAD_BACKUP_TEXT: 'Kliknite, aby ste vybrali, alebo drop súbor zálohy (.json) sem', UPLOAD_FIRMWARE_TEXT: 'Kliknite, aby ste vybrali, alebo drop súbor firmware .bin/.md5 sem', UPLOAD_MD5_RECEIVED: 'Suma MD5 prijatá, teraz nahrajte firmware .bin', - UPLOAD_MD5_MATCHED: 'MD5 súhlasí, firmvér sa inštaluje...' + UPLOAD_MD5_MATCHED: 'MD5 súhlasí, firmvér sa inštaluje...', + RECOVERY: 'Obnova', + RECOVERY_NOT_INSTALLED: 'Nenainštalované', + RECOVERY_INSTALLED: 'Firmvér obnovy nainštalovaný' }; export default sk; diff --git a/interface/src/i18n/sv/index.ts b/interface/src/i18n/sv/index.ts index 384351dab..049c73eda 100644 --- a/interface/src/i18n/sv/index.ts +++ b/interface/src/i18n/sv/index.ts @@ -352,7 +352,10 @@ const sv: Translation = { UPLOAD_BACKUP_TEXT: 'Klicka för att välja, eller drop en säkerhetskopia (.json) här', UPLOAD_FIRMWARE_TEXT: 'Klicka för att välja, eller drop en firmware .bin/.md5 här', UPLOAD_MD5_RECEIVED: 'MD5 mottagen, ladda nu upp firmware .bin-filen', - UPLOAD_MD5_MATCHED: 'MD5 stämmer, firmware installeras...' + UPLOAD_MD5_MATCHED: 'MD5 stämmer, firmware installeras...', + RECOVERY: 'Återställning', + RECOVERY_NOT_INSTALLED: 'Inte installerad', + RECOVERY_INSTALLED: 'Återställningsfirmware installerad' }; export default sv; diff --git a/interface/src/i18n/tr/index.ts b/interface/src/i18n/tr/index.ts index 8449843a7..d95885513 100644 --- a/interface/src/i18n/tr/index.ts +++ b/interface/src/i18n/tr/index.ts @@ -352,7 +352,10 @@ const tr: Translation = { UPLOAD_BACKUP_TEXT: 'Bir yedek dosyası (.json), buraya drop yapın', UPLOAD_FIRMWARE_TEXT: 'Bir firmware .bin dosyası, buraya drop yapın', UPLOAD_MD5_RECEIVED: 'Özet alındı, şimdi firmware .bin dosyasını yükleyin', - UPLOAD_MD5_MATCHED: 'MD5 eşleşti, firmware yükleniyor...' + UPLOAD_MD5_MATCHED: 'MD5 eşleşti, firmware yükleniyor...', + RECOVERY: 'Kurtarma', + RECOVERY_NOT_INSTALLED: 'Yüklü değil', + RECOVERY_INSTALLED: 'Kurtarma firmware yüklendi' }; export default tr; diff --git a/interface/src/recovery/Dashboard.tsx b/interface/src/recovery/Dashboard.tsx new file mode 100644 index 000000000..065a88844 --- /dev/null +++ b/interface/src/recovery/Dashboard.tsx @@ -0,0 +1,431 @@ +import { type ReactNode, useState } from 'react'; + +import CancelIcon from '@mui/icons-material/Cancel'; +import HealthAndSafetyIcon from '@mui/icons-material/HealthAndSafety'; +import LogoutIcon from '@mui/icons-material/Logout'; +import MemoryIcon from '@mui/icons-material/Memory'; +import PlayArrowIcon from '@mui/icons-material/PlayArrow'; +import RestartAltIcon from '@mui/icons-material/RestartAlt'; +import SettingsBackupRestoreIcon from '@mui/icons-material/SettingsBackupRestore'; +import { + Avatar, + Box, + Button, + Chip, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Divider, + List, + ListItem, + ListItemAvatar, + ListItemText, + MenuItem, + TextField, + Typography +} from '@mui/material'; + +import { dialogStyle } from 'CustomTheme'; +import ButtonRow from 'components/ButtonRow'; +import MessageBox from 'components/MessageBox'; +import SectionContent from 'components/SectionContent'; +import { toast } from 'components/toast'; + +import FirmwareUpload from './FirmwareUpload'; +import { HttpError, factoryReset, restart, setBootPartition } from './api'; +import type { RecoveryPartition, RecoveryStatus } from './types'; + +const formatMB = (bytes: number) => `${(bytes / 1048576).toFixed(2)} MB`; + +const formatUptime = (seconds: number) => { + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = seconds % 60; + return `${h}h ${m}m ${s}s`; +}; + +const ETH_STATE_TEXT: Record< + Exclude, + string +> = { + disabled: 'disabled, the clock mode conflicts with PSRAM', + failed: 'failed to start, no PHY found', + no_link: 'no cable connected', + connecting: 'waiting for an IP address' +}; + +const failedToStart = (p: RecoveryPartition) => + p.ota_state === 'aborted' || p.ota_state === 'invalid'; + +// why the device is running the recovery, if it can be worked out +const recoveryReason = (status: RecoveryStatus, failed: RecoveryPartition[]) => { + if (status.reason === 'crash') { + return 'EMS-ESP crashed repeatedly and switched to the recovery.'; + } + if (status.reason === 'request') { + return undefined; // started on purpose from EMS-ESP, e.g. a long press of the button + } + if (failed.length > 0) { + return `The firmware in ${failed.map((p) => p.label).join(' and ')} failed to start.`; + } + if (status.reset_reason === 'panic' || status.reset_reason === 'watchdog') { + return 'EMS-ESP crashed while starting.'; + } + return undefined; +}; + +const SectionTitle = ({ children }: { children: ReactNode }) => ( + + {children} + +); + +const InfoRow = ({ label, value }: { label: string; value: ReactNode }) => ( + + + {label} + + {value} + +); + +interface DashboardProps { + status: RecoveryStatus; + onRestarting: (target: string) => void; + onSignOut: () => void; +} + +const Dashboard = ({ status, onRestarting, onSignOut }: DashboardProps) => { + const [confirmBoot, setConfirmBoot] = useState(); + const [confirmFactoryReset, setConfirmFactoryReset] = useState(false); + const [pickedTarget, setPickedTarget] = useState(); + const uploadTarget = pickedTarget ?? status.upload_target; + + const handleError = (error: unknown) => { + if (error instanceof HttpError && error.status === 401) { + toast.warning('Session expired, please sign in again'); + onSignOut(); + } else { + toast.error((error as Error).message); + } + }; + + const doBoot = async (partition: RecoveryPartition) => { + setConfirmBoot(undefined); + try { + await setBootPartition(partition.label); + onRestarting(partition.label); + } catch (error) { + if (error instanceof HttpError && error.status === 400) { + toast.error(`${partition.label} does not contain a bootable firmware`); + } else { + handleError(error); + } + } + }; + + const doRestart = async (target: string) => { + try { + await restart(); + onRestarting(target); + } catch (error) { + handleError(error); + } + }; + + const doFactoryReset = async () => { + setConfirmFactoryReset(false); + try { + await factoryReset(); + onRestarting(status.boot); + } catch (error) { + handleError(error); + } + }; + + const otaPartitions = status.partitions.filter((p) => !p.factory); + const hasFirmware = otaPartitions.some((p) => p.valid); + const reason = recoveryReason( + status, + otaPartitions.filter((p) => p.valid && failedToStart(p)) + ); + + return ( + <> + + {reason && } + + {hasFirmware + ? 'This device is running the EMS-ESP Recovery. Start one of the installed firmwares, or upload a new one.' + : 'No EMS-ESP firmware is installed. Upload a firmware to continue.'} + + + + + Firmware + + {status.partitions.map((p, index) => ( + + {index > 0 && } + } + variant="outlined" + color="primary" + size="small" + onClick={() => setConfirmBoot(p)} + > + Start + + ) + } + > + + + {p.factory ? : } + + + + {p.factory ? `Recovery (${p.label})` : p.label} + {p.label === status.running && ( + + )} + {p.label === status.boot && p.label !== status.running && ( + + )} + {p.label === uploadTarget && ( + + )} + {!p.valid && ( + + )} + {p.valid && failedToStart(p) && ( + + )} + {p.valid && p.ota_state === 'pending' && ( + + )} + + } + secondary={ + p.valid + ? `${p.version ? 'v' + p.version + ' · ' : ''}${formatMB(p.image_size)} of ${formatMB(p.size)}` + : formatMB(p.size) + } + /> + + + ))} + + + + + Upload Firmware + + The firmware is started automatically after the upload. To enable the MD5 + check, upload the matching .md5 file first. + + setPickedTarget(e.target.value)} + > + {otaPartitions.map((p) => ( + + {p.label} + {p.valid + ? ` (replaces ${p.version ? 'v' + p.version : 'installed firmware'})` + : ' (empty)'} + + ))} + + void doRestart(uploadTarget)} + onUnauthorized={onSignOut} + /> + + + + System + + + + + + + {status.eth_state && ( + + )} + + + + + {!status.disable_reset && ( + + )} + + + + + + setConfirmFactoryReset(false)} + > + Factory Reset + + Are you sure you want to reset EMS-ESP to its factory defaults? + + + + + + + + setConfirmBoot(undefined)} + > + Start Firmware + + Restart and run + {confirmBoot?.version ? ` EMS-ESP v${confirmBoot.version}` : ''} from{' '} + {confirmBoot?.label}? + {confirmBoot && failedToStart(confirmBoot) && ( + + )} + + + + + + + + ); +}; + +export default Dashboard; diff --git a/interface/src/recovery/FirmwareUpload.tsx b/interface/src/recovery/FirmwareUpload.tsx new file mode 100644 index 000000000..b4d3822a6 --- /dev/null +++ b/interface/src/recovery/FirmwareUpload.tsx @@ -0,0 +1,208 @@ +import { type ChangeEvent, type DragEvent, useRef, useState } from 'react'; + +import CancelIcon from '@mui/icons-material/Cancel'; +import CloudUploadIcon from '@mui/icons-material/CloudUpload'; +import UploadIcon from '@mui/icons-material/Upload'; +import { Box, Button, Typography, styled } from '@mui/material'; + +import { toast } from 'components/toast'; +import { LinearProgressWithLabel } from 'components/upload/LinearProgressWithLabel'; + +import { HttpError, type UploadHandle, uploadFile } from './api'; + +const DocumentUploader = styled(Box)<{ active?: boolean }>(({ theme, active }) => ({ + border: `2px dashed ${active ? '#6dc24b' : '#4282fe'}`, + backgroundColor: '#2e3339', + padding: theme.spacing(1.25), + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + borderRadius: theme.spacing(1), + cursor: 'pointer', + minHeight: '120px', + transition: 'border-color 0.2s ease-in-out' +})); + +const uploadErrorMessage = (status: number, chip: string) => { + switch (status) { + case 400: + return 'Invalid target partition'; + case 401: + case 403: + return 'Not authorized, please sign in again'; + case 409: + return 'This is the recovery firmware, it can only be installed from EMS-ESP'; + case 503: + return `This firmware is not built for the ${chip}`; + case 507: + return 'The firmware does not fit in the partition'; + case 500: + return 'Upload failed, flash write error or MD5 mismatch'; + default: + return 'Upload failed. Only EMS-ESP firmware .bin and .md5 files are accepted'; + } +}; + +interface FirmwareUploadProps { + target: string; + chip: string; + onUploaded: () => void; + onUnauthorized: () => void; +} + +const FirmwareUpload = ({ + target, + chip, + onUploaded, + onUnauthorized +}: FirmwareUploadProps) => { + const [file, setFile] = useState(); + const [dragged, setDragged] = useState(false); + const [md5, setMd5] = useState(); + const [progress, setProgress] = useState(); + const uploadRef = useRef(null); + const inputRef = useRef(null); + + const selectFile = (selected?: File) => { + if (!selected) return; + if (/\.(bin|md5)$/i.test(selected.name)) { + setFile(selected); + } else { + toast.warning('Please select an EMS-ESP firmware .bin or .md5 file'); + } + }; + + const onFileChange = (e: ChangeEvent) => { + selectFile(e.target.files?.[0]); + e.target.value = ''; // allow the same file to be selected again + }; + + const onDrop = (e: DragEvent) => { + e.preventDefault(); + setDragged(false); + selectFile(e.dataTransfer.files[0]); + }; + + const startUpload = async () => { + if (!file) return; + setProgress(0); + uploadRef.current = uploadFile(file, target, setProgress); + try { + const result = await uploadRef.current.promise; + if (result.md5) { + setMd5(result.md5); + toast.success('MD5 received, now upload the firmware .bin'); + } else { + if (result.md5_ok) { + toast.success('Firmware MD5 matches'); + } + toast.success(`Firmware written to ${target}`); + onUploaded(); + } + } catch (error) { + const status = error instanceof HttpError ? error.status : 0; + if (status === -1) { + toast.warning('Upload aborted'); + } else { + toast.error(uploadErrorMessage(status, chip)); + if (status === 401 || status === 403) onUnauthorized(); + } + } finally { + uploadRef.current = null; + setProgress(undefined); + setFile(undefined); + } + }; + + if (progress !== undefined) { + return ( + <> + + Uploading {file?.name} to {target}… + + + + + ); + } + + return ( + <> + { + e.preventDefault(); + setDragged(true); + }} + onDragLeave={() => setDragged(false)} + onClick={() => inputRef.current?.click()} + > + + + + {md5 + ? 'MD5 received. Now drop the firmware .bin file here, or click to browse' + : 'Drop an EMS-ESP firmware .bin file here, or click to browse'} + + + + {file && ( + <> + + {file.name} + + + + + + + )} + + {md5 && ( + + {'MD5: ' + md5} + + )} + + ); +}; + +export default FirmwareUpload; diff --git a/interface/src/recovery/RecoveryApp.tsx b/interface/src/recovery/RecoveryApp.tsx new file mode 100644 index 000000000..22fb89372 --- /dev/null +++ b/interface/src/recovery/RecoveryApp.tsx @@ -0,0 +1,132 @@ +import { useCallback, useEffect, useState } from 'react'; + +import RefreshIcon from '@mui/icons-material/Refresh'; +import { + AppBar, + Box, + Button, + CircularProgress, + Toolbar, + Typography +} from '@mui/material'; + +import CustomTheme from 'CustomTheme'; +import MessageBox from 'components/MessageBox'; +import SectionContent from 'components/SectionContent'; +import { Toaster } from 'components/toast'; + +import Dashboard from './Dashboard'; +import RestartMonitor from './RestartMonitor'; +import SignIn from './SignIn'; +import { getToken, readStatus, setToken } from './api'; +import type { RecoveryStatus } from './types'; + +const STATUS_REFRESH_MS = 5000; + +const RecoveryApp = () => { + const [status, setStatus] = useState(); + const [error, setError] = useState(); + const [restartTarget, setRestartTarget] = useState(); + + const loadStatus = useCallback(async () => { + try { + const data = await readStatus(); + if (!data.authenticated && getToken()) { + setToken(null); // stale token, e.g. after the device restarted + } + setStatus(data); + setError(undefined); + } catch (e) { + setError((e as Error).message); + } + }, []); + + useEffect(() => { + void loadStatus(); + }, [loadStatus]); + + useEffect(() => { + if (!status?.authenticated || restartTarget !== undefined) return; + const timer = setInterval(() => void loadStatus(), STATUS_REFRESH_MS); + return () => clearInterval(timer); + }, [status?.authenticated, restartTarget, loadStatus]); + + const signOut = () => { + setToken(null); + void loadStatus(); + }; + + let content; + if (restartTarget !== undefined && status) { + content = ( + + ); + } else if (!status) { + content = ( + + {error ? ( + + + + ) : ( + + + + )} + + ); + } else if (!status.authenticated) { + content = void loadStatus()} />; + } else { + content = ( + <> + + + EMS-ESP + EMS-ESP + +   |   + + Recovery + + + + + + + + ); + } + + return ( + + {content} + + + ); +}; + +export default RecoveryApp; diff --git a/interface/src/recovery/RestartMonitor.tsx b/interface/src/recovery/RestartMonitor.tsx new file mode 100644 index 000000000..9e6e64088 --- /dev/null +++ b/interface/src/recovery/RestartMonitor.tsx @@ -0,0 +1,149 @@ +import { useEffect, useState } from 'react'; + +import RefreshIcon from '@mui/icons-material/Refresh'; +import { Box, Button, LinearProgress, Typography } from '@mui/material'; + +import MessageBox from 'components/MessageBox'; + +import { STATUS_URL } from './api'; + +const POLL_INTERVAL_MS = 1000; +const POLL_TIMEOUT_MS = 3000; +const FIRST_POLL_DELAY_MS = 1500; +// reload anyway if the device never appeared to go down +const NO_DOWNTIME_RELOAD_MS = 20000; +const GIVE_UP_MS = 90000; + +interface RestartMonitorProps { + target: string; + viaAccessPoint: boolean; + hostname: string; +} + +// Waits for the device to go down and come back, then reloads the page. Both the recovery app +// and EMS-ESP answer on STATUS_URL (EMS-ESP serves its WebUI for unknown GETs). +const RestartMonitor = ({ + target, + viaAccessPoint, + hostname +}: RestartMonitorProps) => { + const [gaveUp, setGaveUp] = useState(false); + + useEffect(() => { + const started = Date.now(); + let seenDown = false; + let timer: ReturnType; + let cancelled = false; + + const poll = async () => { + let up: boolean; + try { + const response = await fetch(STATUS_URL, { + cache: 'no-store', + signal: AbortSignal.timeout(POLL_TIMEOUT_MS) + }); + up = response.ok; + } catch { + up = false; + } + if (cancelled) return; + + const elapsed = Date.now() - started; + if (!up) { + seenDown = true; + } else if (seenDown || elapsed > NO_DOWNTIME_RELOAD_MS) { + window.location.reload(); + return; + } + if (elapsed > GIVE_UP_MS) { + setGaveUp(true); + return; + } + timer = setTimeout(() => void poll(), POLL_INTERVAL_MS); + }; + + timer = setTimeout(() => void poll(), FIRST_POLL_DELAY_MS); + return () => { + cancelled = true; + clearTimeout(timer); + }; + }, []); + + return ( + + + EMS-ESP + + Restarting{target ? ` from ${target}` : ''} + + + {gaveUp ? ( + <> + + The device has not come back on this address. + {viaAccessPoint && + ` If you were connected to the recovery access point, reconnect to your normal network and open http://${hostname}.local`} + + + + ) : ( + <> + + Please wait… + + + + + {viaAccessPoint && ( + + EMS-ESP does not use the recovery access point. If this page does not + reload, reconnect to your normal network and open http://{hostname} + .local + + )} + + )} + + + ); +}; + +export default RestartMonitor; diff --git a/interface/src/recovery/SignIn.tsx b/interface/src/recovery/SignIn.tsx new file mode 100644 index 000000000..3fbd10285 --- /dev/null +++ b/interface/src/recovery/SignIn.tsx @@ -0,0 +1,142 @@ +import { type KeyboardEvent, useState } from 'react'; + +import ForwardIcon from '@mui/icons-material/Forward'; +import VisibilityIcon from '@mui/icons-material/Visibility'; +import VisibilityOffIcon from '@mui/icons-material/VisibilityOff'; +import { + Box, + Button, + IconButton, + InputAdornment, + Paper, + TextField, + Typography +} from '@mui/material'; +import type { Theme } from '@mui/material/styles'; + +import { toast } from 'components/toast'; + +import { HttpError, setToken, signIn } from './api'; + +interface SignInProps { + onSignedIn: () => void; +} + +const SignIn = ({ onSignedIn }: SignInProps) => { + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [showPassword, setShowPassword] = useState(false); + const [processing, setProcessing] = useState(false); + + const doSignIn = async () => { + if (!username || !password) { + toast.warning('Please enter the admin username and password'); + return; + } + setProcessing(true); + try { + const { access_token } = await signIn(username, password); + setToken(access_token); + onSignedIn(); + } catch (error) { + if (error instanceof HttpError && error.status === 401) { + toast.warning('Invalid login details'); + } else { + toast.error('Error: ' + (error as Error).message); + } + setProcessing(false); + } + }; + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Enter') { + void doSignIn(); + } + }; + + return ( + ({ + display: 'flex', + height: '100vh', + margin: 'auto', + padding: 2, + justifyContent: 'center', + flexDirection: 'column', + maxWidth: theme.breakpoints.values.sm + })} + > + ({ + textAlign: 'center', + padding: theme.spacing(2), + paddingTop: '172px', + backgroundImage: 'url("/app/icon.png")', + backgroundRepeat: 'no-repeat', + backgroundPosition: '50% ' + theme.spacing(2), + width: '100%' + })} + > + EMS-ESP + + Recovery + + + setUsername(e.target.value)} + slotProps={{ input: { autoCapitalize: 'none', autoCorrect: 'off' } }} + /> + setPassword(e.target.value)} + onKeyDown={onKeyDown} + slotProps={{ + input: { + endAdornment: ( + + setShowPassword(!showPassword)} + edge="end" + > + {showPassword ? : } + + + ) + } + }} + /> + + + + + + ); +}; + +export default SignIn; diff --git a/interface/src/recovery/api.ts b/interface/src/recovery/api.ts new file mode 100644 index 000000000..d6d4f3372 --- /dev/null +++ b/interface/src/recovery/api.ts @@ -0,0 +1,109 @@ +import type { RecoveryStatus, UploadResult } from './types'; + +const TOKEN_KEY = 'recovery_token'; + +export const getToken = (): string | null => sessionStorage.getItem(TOKEN_KEY); + +export const setToken = (token: string | null): void => { + if (token) { + sessionStorage.setItem(TOKEN_KEY, token); + } else { + sessionStorage.removeItem(TOKEN_KEY); + } +}; + +export class HttpError extends Error { + readonly status: number; + + constructor(status: number, message: string) { + super(message); + this.status = status; + } +} + +const authHeaders = (): Record => { + const token = getToken(); + return token ? { Authorization: `Bearer ${token}` } : {}; +}; + +const request = async ( + method: string, + url: string, + body?: unknown +): Promise => { + const init: RequestInit = { method, headers: authHeaders(), cache: 'no-store' }; + if (body !== undefined) { + init.headers = { ...authHeaders(), 'Content-Type': 'application/json' }; + init.body = JSON.stringify(body); + } + const response = await fetch(url, init); + if (!response.ok) { + throw new HttpError( + response.status, + response.statusText || `HTTP ${response.status}` + ); + } + const text = await response.text(); + return (text ? JSON.parse(text) : undefined) as T; +}; + +export const STATUS_URL = '/rest/recovery/status'; + +export const readStatus = () => request('GET', STATUS_URL); + +export const signIn = (username: string, password: string) => + request<{ access_token: string }>('POST', '/rest/signIn', { username, password }); + +export const setBootPartition = (partition: string) => + request('POST', '/rest/recovery/boot', { partition }); + +export const restart = () => request('POST', '/rest/recovery/restart'); + +export const factoryReset = () => request('POST', '/rest/recovery/format'); + +export interface UploadHandle { + promise: Promise; + abort: () => void; +} + +// XHR instead of fetch, for upload progress events +export const uploadFile = ( + file: File, + partition: string, + onProgress: (percent: number) => void +): UploadHandle => { + const xhr = new XMLHttpRequest(); + const promise = new Promise((resolve, reject) => { + xhr.upload.onprogress = (event) => { + if (event.lengthComputable && event.total > 0) { + onProgress(Math.round((event.loaded * 100) / event.total)); + } + }; + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + try { + resolve( + (xhr.responseText ? JSON.parse(xhr.responseText) : {}) as UploadResult + ); + } catch { + resolve({}); + } + } else { + reject(new HttpError(xhr.status, xhr.statusText)); + } + }; + // the device closes the connection on unsupported files, so there is no status code + xhr.onerror = () => reject(new HttpError(0, 'Connection closed')); + xhr.onabort = () => reject(new HttpError(-1, 'Aborted')); + }); + + const formData = new FormData(); + formData.append('file', file); + xhr.open('POST', `/rest/uploadFile?partition=${encodeURIComponent(partition)}`); + for (const [key, value] of Object.entries(authHeaders())) { + xhr.setRequestHeader(key, value); + } + xhr.send(formData); + + return { promise, abort: () => xhr.abort() }; +}; diff --git a/interface/src/recovery/index.tsx b/interface/src/recovery/index.tsx new file mode 100644 index 000000000..6f0b5afd4 --- /dev/null +++ b/interface/src/recovery/index.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; + +import RecoveryApp from './RecoveryApp'; + +createRoot(document.getElementById('root')!).render( + + + +); diff --git a/interface/src/recovery/types.ts b/interface/src/recovery/types.ts new file mode 100644 index 000000000..6f2b77397 --- /dev/null +++ b/interface/src/recovery/types.ts @@ -0,0 +1,55 @@ +export interface RecoveryPartition { + label: string; + factory: boolean; + address: number; + size: number; + valid: boolean; + image_size: number; + version: string; + install_date: number; + // bootloader rollback state, empty if the partition has never been selected + ota_state: '' | 'new' | 'pending' | 'valid' | 'invalid' | 'aborted'; +} + +export interface RecoveryStatus { + recovery_version: string; + authenticated: boolean; + hostname: string; + chip: string; + flash_size: number; + psram_size: number; + free_heap: number; + mac: string; + uptime: number; + reason: '' | 'crash' | 'request'; // why EMS-ESP switched to the recovery + reset_reason: + | 'power_on' + | 'external' + | 'software' + | 'panic' + | 'watchdog' + | 'brownout' + | 'other'; + ap_ssid: string; + ap_ip: string; + ap_clients: number; + wifi_ssid: string; + wifi_started: boolean; // false while waiting for Ethernet, or when Ethernet is connected + wifi_connected: boolean; + wifi_ip?: string; + wifi_rssi?: number; + static_ip: boolean; + disable_reset: boolean; // factory reset is disabled in the EMS-ESP settings + // only on boards with Ethernet configured + eth_state?: 'disabled' | 'failed' | 'no_link' | 'connecting' | 'connected'; + eth_ip?: string; + running: string; + boot: string; + upload_target: string; + partitions: RecoveryPartition[]; +} + +export interface UploadResult { + md5?: string; + md5_ok?: boolean; +} diff --git a/interface/src/types/system.ts b/interface/src/types/system.ts index 32538bc5b..6aef9ed44 100644 --- a/interface/src/types/system.ts +++ b/interface/src/types/system.ts @@ -51,7 +51,7 @@ export interface SystemStatus { free_caps: number; model: string; board: string; - has_loader: boolean; + recovery_version?: string; has_partition: boolean; partitions: { partition: string; diff --git a/interface/vite.config.ts b/interface/vite.config.ts index c87ab4e41..d788d153a 100644 --- a/interface/vite.config.ts +++ b/interface/vite.config.ts @@ -12,6 +12,7 @@ const KB_DIVISOR = 1024; const REPEAT_CHAR = '='; const REPEAT_COUNT = 50; const DEFAULT_OUT_DIR = 'dist'; +const RECOVERY_OUT_DIR = 'dist-recovery'; const ES_TARGET = 'es2020'; const CHUNK_SIZE_WARNING_LIMIT = 1024; const ASSETS_INLINE_LIMIT = 4096; @@ -205,6 +206,65 @@ const imageOptimizationPlugin = { export default defineConfig( async ({ command, mode }: { command: string; mode: string }) => { + if (command === 'serve' && mode === 'recovery') { + console.log('Preparing Recovery WebUI with mock server'); + const recoveryMockUrl = pathToFileURL( + path.resolve(import.meta.dirname, '../mock-api/recoveryMock.js') + ).href; + const { default: recoveryMock } = (await import( + /* @vite-ignore */ recoveryMockUrl + )) as { default: () => PluginOption }; + return { + plugins: [...createBasePlugins(true, false), recoveryMock()], + resolve: { + alias: RESOLVE_ALIASES, + extensions: RESOLVE_EXTENSIONS, + tsconfigPaths: true + }, + server: { + open: '/recovery.html', + port: 3001 + } + }; + } + + if (mode === 'recovery') { + console.log('Preparing for Recovery WebUI, optimized build'); + return { + plugins: [...createBasePlugins(false, true), imageOptimizationPlugin], + resolve: { + alias: RESOLVE_ALIASES, + extensions: RESOLVE_EXTENSIONS, + tsconfigPaths: true + }, + build: { + ...createBaseBuildConfig(), + outDir: RECOVERY_OUT_DIR, + emptyOutDir: true, + minify: 'terser' as const, + terserOptions: createProductionTerserOptions(), + rollupOptions: { + input: path.resolve(import.meta.dirname, 'recovery.html'), + checks: { + pluginTimings: false + }, + treeshake: { + moduleSideEffects: false, + propertyReadSideEffects: false as const, + unknownGlobalSideEffects: false + }, + output: { + chunkFileNames: 'assets/[name]-[hash].js', + entryFileNames: 'assets/[name]-[hash].js', + assetFileNames: 'assets/[name]-[hash].[ext]', + manualChunks, + sourcemap: false + } + } + } + }; + } + if (command === 'serve') { console.log(`Preparing for standalone build with server, mode=${mode}`); const mockServerUrl = pathToFileURL( diff --git a/lib/OtaUpdater/OtaUpdater.cpp b/lib/OtaUpdater/OtaUpdater.cpp new file mode 100644 index 000000000..df2c88b64 --- /dev/null +++ b/lib/OtaUpdater/OtaUpdater.cpp @@ -0,0 +1,290 @@ +#include "OtaUpdater.h" + +#include +#include +#include +#ifdef OTA_UPDATER_ANY_PARTITION +#include +#include +#endif + +#include + +static String getFilenameExtension(const String & filename) { + const auto pos = filename.lastIndexOf('.'); + if (pos != -1) { + return filename.substring(static_cast(pos) + 1); + } + return {}; +} + +OtaUpdater::FileType OtaUpdater::classify(const String & filename, size_t filesize, size_t min_firmware_size) { + const String extension = getFilenameExtension(filename); + if (extension == "bin" && filename.endsWith("littlefs.bin")) { + return FileType::FILESYSTEM; + } + if (extension == "bin") { + 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; + } + if (extension == "md5") { + return FileType::MD5; + } + return FileType::UNSUPPORTED; +} + +bool OtaUpdater::isCompatibleFirmware(const uint8_t * data, size_t len) { + // 0xE9 magic at offset 0 indicates an esp bin, chip id at offset 12 +#if CONFIG_IDF_TARGET_ESP32 + constexpr uint8_t chip_id = ESP_CHIP_ID_ESP32; +#elif CONFIG_IDF_TARGET_ESP32S2 + constexpr uint8_t chip_id = ESP_CHIP_ID_ESP32S2; +#elif CONFIG_IDF_TARGET_ESP32C3 + constexpr uint8_t chip_id = ESP_CHIP_ID_ESP32C3; +#elif CONFIG_IDF_TARGET_ESP32S3 + constexpr uint8_t chip_id = ESP_CHIP_ID_ESP32S3; +#elif CONFIG_IDF_TARGET_ESP32C6 + constexpr uint8_t chip_id = ESP_CHIP_ID_ESP32C6; +#else + return true; +#endif + return len <= 12 || (data[0] == ESP_IMAGE_HEADER_MAGIC && data[12] == chip_id); +} + +bool OtaUpdater::isRecoveryFirmware(const uint8_t * data, size_t len) { + constexpr size_t offset = sizeof(esp_image_header_t) + sizeof(esp_image_segment_header_t); + if (len < offset + offsetof(esp_app_desc_t, time)) { + return false; + } + esp_app_desc_t desc; + memcpy(&desc, data + offset, offsetof(esp_app_desc_t, time)); + return desc.magic_word == ESP_APP_DESC_MAGIC_WORD && strncmp(desc.project_name, RECOVERY_PROJECT_NAME, sizeof(desc.project_name)) == 0; +} + +bool OtaUpdater::isRecoveryPartition(const esp_partition_t * partition, esp_app_desc_t * desc) { + esp_app_desc_t d; + if (partition == nullptr || esp_ota_get_partition_description(partition, &d) != ESP_OK + || strncmp(d.project_name, RECOVERY_PROJECT_NAME, sizeof(d.project_name)) != 0) { + return false; + } + if (desc != nullptr) { + *desc = d; + } + return true; +} + +bool OtaUpdater::parseMd5Digest(const uint8_t * data, size_t len, std::array & out) { + size_t i = 0; + while (i < len && (data[i] == ' ' || data[i] == '\t' || data[i] == '\r' || data[i] == '\n')) { + ++i; + } + if (len - i < 32) { + return false; + } + for (size_t n = 0; n < 32; n++) { + const char c = static_cast(data[i + n]); + const bool hex = (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'); + if (!hex) { + return false; + } + out[n] = (c >= 'A' && c <= 'F') ? static_cast(c - 'A' + 'a') : c; + } + out[32] = '\0'; + return true; +} + +bool OtaUpdater::confirmRunningApp() { +#ifdef CONFIG_APP_ROLLBACK_ENABLE + esp_ota_img_states_t state; + if (esp_ota_get_state_partition(esp_ota_get_running_partition(), &state) == ESP_OK && state == ESP_OTA_IMG_PENDING_VERIFY) { + return esp_ota_mark_app_valid_cancel_rollback() == ESP_OK; + } +#endif + return false; +} + +bool OtaUpdater::setMd5(const uint8_t * data, size_t len) { + return parseMd5Digest(data, len, _md5); +} + +void OtaUpdater::clearMd5() { + _md5.front() = '\0'; + _md5_applied = false; +} + +bool OtaUpdater::hasMd5() const { + return strlen(_md5.data()) == _md5.size() - 1; +} + +#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(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 = ""; + + confirmRunningApp(); + + if (target != nullptr) { +#ifdef OTA_UPDATER_ANY_PARTITION + if (filesize > target->size + 4096) { // allow for the multipart overhead + _error = "Firmware does not fit in the partition"; + return false; + } + _dram = static_cast(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(); + _md5_applied = true; + } + return true; +#else + _error = "Writing to a specific partition is not supported"; + return false; +#endif + } + + if (!Update.begin(filesize - sizeof(esp_image_header_t))) { + return false; + } + if (hasMd5()) { + Update.setMD5(_md5.data()); + _md5_applied = true; + } + return true; +} + +bool OtaUpdater::beginFilesystem() { + clearMd5(); // so Update.end() doesn't compare against a stale digest + _error = ""; + // the HTTP content length is the multipart body size, not the file size, so it can exceed + // the partition by a few hundred bytes. Let the Update library size against the whole partition. + return Update.begin(UPDATE_SIZE_UNKNOWN, U_SPIFFS); +} + +bool OtaUpdater::write(const uint8_t * data, size_t len) { +#ifdef OTA_UPDATER_ANY_PARTITION + if (_target != nullptr) { + 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) { + 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(_dram, n); + } + _written += n; + data += n; + len -= n; + } + return true; + } +#endif + return Update.write(const_cast(data), len) == len; +} + +bool OtaUpdater::end() { +#ifdef OTA_UPDATER_ANY_PARTITION + if (_target != nullptr) { + const esp_partition_t * target = _target; + const bool set_boot = _set_boot; + releaseTarget(); + + if (_md5_applied) { + _md5Builder.calculate(); + if (strcmp(_md5Builder.toString().c_str(), _md5.data()) != 0) { + _error = "MD5 check failed"; + return false; + } + } + + 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) { + 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 + return Update.end(true); +} + +void OtaUpdater::abort() { +#ifdef OTA_UPDATER_ANY_PARTITION + if (_target != nullptr) { + releaseTarget(); + return; + } +#endif + if (Update.isRunning()) { + Update.abort(); + } +} + +size_t OtaUpdater::progress() const { +#ifdef OTA_UPDATER_ANY_PARTITION + if (_target != nullptr) { + return _written; + } +#endif + return Update.progress(); +} + +const char * OtaUpdater::errorString() const { + return _error[0] != '\0' ? _error : Update.errorString(); +} diff --git a/lib/OtaUpdater/OtaUpdater.h b/lib/OtaUpdater/OtaUpdater.h new file mode 100644 index 000000000..d13f4bb3f --- /dev/null +++ b/lib/OtaUpdater/OtaUpdater.h @@ -0,0 +1,90 @@ +#ifndef OtaUpdater_h +#define OtaUpdater_h + +#include +#include + +#include + +#ifdef OTA_UPDATER_ANY_PARTITION +#include +#endif + +// Validates and streams firmware (.bin), LittleFS images (*littlefs.bin) and MD5 digests (.md5) into flash. +// Shared between the EMS-ESP application and the recovery application. +class OtaUpdater { + public: + enum class FileType : uint8_t { UNSUPPORTED, FIRMWARE, FILESYSTEM, JSON, MD5 }; + + // 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); + + // checks the app description at the start of an image, needs the first 112 bytes + static bool isRecoveryFirmware(const uint8_t * data, size_t len); + // fills in desc if the partition holds the recovery firmware + static bool isRecoveryPartition(const esp_partition_t * partition, esp_app_desc_t * desc = nullptr); + + // Accepts a raw 32-char hex digest, optionally with trailing newline or a GNU "hash filename" suffix. + static bool parseMd5Digest(const uint8_t * data, size_t len, std::array & out); + + // With bootloader rollback a new image boots as pending verification until confirmed, and the + // IDF refuses to start another firmware update until then. Returns true if it was pending. + static bool confirmRunningApp(); + + bool setMd5(const uint8_t * data, size_t len); + void clearMd5(); + bool hasMd5() const; + bool md5Applied() const { + return _md5_applied; + } + const char * md5() const { + return _md5.data(); + } + + // filesize is the HTTP content length, which is slightly bigger than the image. + // Without a target the image goes to the next OTA partition after the running one. Writing to a + // specific partition needs OTA_UPDATER_ANY_PARTITION, which adds ~1.7KB. + bool beginFirmware(size_t filesize, const esp_partition_t * target = nullptr, bool set_boot = true); + bool beginFilesystem(); + bool write(const uint8_t * data, size_t len); + bool end(); // on success the new firmware becomes the boot partition, unless set_boot was false + void abort(); + + size_t progress() const; + const char * errorString() const; + + private: + std::array _md5{}; + bool _md5_applied = false; + const char * _error = ""; + +#ifdef OTA_UPDATER_ANY_PARTITION + // 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; + 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 +}; + +#endif diff --git a/mock-api/recoveryMock.js b/mock-api/recoveryMock.js new file mode 100644 index 000000000..5549dc5de --- /dev/null +++ b/mock-api/recoveryMock.js @@ -0,0 +1,221 @@ +// Vite plugin that mocks the EMS-ESP Recovery firmware REST API, used by `pnpm recovery` +// Sign in with admin/admin. Uploading any *.bin >= 512KB "flashes" it into the next OTA partition. +// It starts as if an update in app1 failed to start and the bootloader fell back to the recovery. + +const MIN_FIRMWARE_SIZE = 512 * 1024; +const RESTART_DOWNTIME_MS = 4000; +const TOKEN = 'mock-recovery-token'; +const MB = 1048576; + +const state = { + token: null, + boot: 'boot', + downUntil: 0, + md5: null, + partitions: [ + { + label: 'boot', + factory: true, + address: 0x10000, + size: 0x480000, + valid: true, + image_size: 0.8 * MB, + version: '1.0.0', + install_date: 0, + ota_state: '' + }, + { + label: 'app0', + factory: false, + address: 0x490000, + size: 0x490000, + valid: true, + image_size: 1.86 * MB, + version: '3.8.1', + install_date: 1767225600, + ota_state: 'valid' + }, + { + label: 'app1', + factory: false, + address: 0x920000, + size: 0x490000, + valid: true, + image_size: 1.91 * MB, + version: '3.9.0-dev.16', + install_date: 1790000000, + ota_state: 'aborted' + } + ] +}; + +const failedToStart = (p) => p.ota_state === 'aborted' || p.ota_state === 'invalid'; + +const started = Date.now(); + +// same rule as the firmware: an empty partition, then a failed one, otherwise the oldest install date +const nextUploadTarget = () => { + const ota = state.partitions.filter((p) => !p.factory); + const rank = (p) => (!p.valid ? 0 : failedToStart(p) ? 1 : 2); + return ota.reduce((a, b) => + rank(b) < rank(a) || (rank(b) === rank(a) && b.install_date < a.install_date) + ? b + : a + ).label; +}; + +const status = (authenticated) => ({ + recovery_version: '1.0.0', + authenticated, + hostname: 'ems-esp', + chip: 'ESP32', // a wired E32V2 with a static IP, WiFi is not used + flash_size: 16 * MB, + psram_size: 8 * MB, + free_heap: 245760, + mac: '24:0A:C4:00:00:01', + uptime: Math.floor((Date.now() - started) / 1000), + reason: '', + reset_reason: 'panic', + ap_ssid: 'ems-esp-recovery', + ap_ip: '192.168.4.1', + ap_clients: 1, + wifi_ssid: 'MyHomeWiFi', + wifi_started: false, + wifi_connected: false, + static_ip: true, + disable_reset: false, // set to true to test with the factory reset disabled + eth_state: 'connected', + eth_ip: '192.168.1.50', + running: 'boot', + boot: state.boot, + upload_target: nextUploadTarget(), + partitions: state.partitions +}); + +const readBody = (req) => + new Promise((resolve, reject) => { + const chunks = []; + req.on('data', (c) => chunks.push(c)); + req.on('end', () => resolve(Buffer.concat(chunks))); + req.on('error', reject); + }); + +const send = (res, code, body) => { + res.statusCode = code; + if (body !== undefined) { + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify(body)); + } else { + res.end(); + } +}; + +const isAuthorized = (req) => + state.token && req.headers.authorization === `Bearer ${state.token}`; + +const restart = () => { + state.token = null; + state.downUntil = Date.now() + RESTART_DOWNTIME_MS; +}; + +const uploadedFilename = (body) => { + const match = /filename="([^"]+)"/.exec(body.subarray(0, 1024).toString()); + return match ? match[1] : ''; +}; + +export default function recoveryMock() { + return { + name: 'recovery-mock', + configureServer(server) { + server.middlewares.use(async (req, res, next) => { + const [url, query] = req.url.split('?'); + const params = new URLSearchParams(query ?? ''); + if (!url.startsWith('/rest/')) { + return next(); + } + + if (Date.now() < state.downUntil) { + return send(res, 503); + } + + if (url === '/rest/recovery/status' && req.method === 'GET') { + return send(res, 200, status(!!isAuthorized(req))); + } + + if (url === '/rest/signIn' && req.method === 'POST') { + const { username, password } = JSON.parse( + (await readBody(req)).toString() || '{}' + ); + if (username === 'admin' && password === 'admin') { + state.token = TOKEN; + return send(res, 200, { access_token: TOKEN }); + } + return send(res, 401); + } + + if (!isAuthorized(req)) { + await readBody(req); + return send(res, 401); + } + + if (url === '/rest/recovery/restart' && req.method === 'POST') { + send(res, 200); + return restart(); + } + + if (url === '/rest/recovery/format' && req.method === 'POST') { + if (status(true).disable_reset) return send(res, 403); + send(res, 200); + return restart(); + } + + if (url === '/rest/recovery/boot' && req.method === 'POST') { + const { partition } = JSON.parse((await readBody(req)).toString() || '{}'); + const p = state.partitions.find((x) => x.label === partition); + if (!p) return send(res, 404); + if (!p.valid) return send(res, 400); + if (!p.factory) p.ota_state = 'new'; + state.boot = partition; + send(res, 200); + return restart(); + } + + if (url === '/rest/uploadFile' && req.method === 'POST') { + const body = await readBody(req); + const filename = uploadedFilename(body); + if (filename.endsWith('.md5')) { + state.md5 = '0123456789abcdef0123456789abcdef'; + return send(res, 200, { md5: state.md5 }); + } + if ( + !filename.endsWith('.bin') || + filename.endsWith('littlefs.bin') || + body.length < MIN_FIRMWARE_SIZE + ) { + return send(res, 406); + } + if (filename.startsWith('EMS-ESP-Recovery')) { + return send(res, 409); + } + const target = state.partitions.find( + (x) => x.label === (params.get('partition') ?? nextUploadTarget()) + ); + if (!target || target.factory) return send(res, 400); + Object.assign(target, { + valid: true, + image_size: body.length, + version: '', + install_date: Math.floor(Date.now() / 1000), + ota_state: 'new' + }); + state.boot = target.label; + const md5_ok = !!state.md5; + state.md5 = null; + return md5_ok ? send(res, 200, { md5_ok }) : send(res, 200); + } + + return send(res, 404); + }); + } + }; +} diff --git a/mock-api/restServer.ts b/mock-api/restServer.ts index c606b951b..f46d3433c 100644 --- a/mock-api/restServer.ts +++ b/mock-api/restServer.ts @@ -116,7 +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: '1.0.0', has_partition: true, partitions: [ { @@ -151,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 @@ -4691,6 +4692,15 @@ router return { md5: digest }; } + // the firmware recognises the recovery image by its app description, the mock by its file name + if (fileExtension === 'bin' && fileName.startsWith('EMS-ESP-Recovery')) { + const md5_ok = pendingFirmwareMd5; + pendingFirmwareMd5 = false; + system_status.recovery_version = '1.0.0'; + console.log('Recovery firmware installed'); + return md5_ok ? { recovery: true, md5_ok } : { recovery: true }; + } + if (fileExtension === 'bin' && pendingFirmwareMd5) { pendingFirmwareMd5 = false; console.log('Firmware MD5 matches'); diff --git a/platformio.ini b/platformio.ini index 9fa302349..96b90738b 100644 --- a/platformio.ini +++ b/platformio.ini @@ -9,7 +9,7 @@ [platformio] ; build all supported targets. Use pio_local.ini to override and build specific targets. -default_envs = s_4M, s_16M, s_16M_P, s3_16M_P +default_envs = s_4M, s_16M, s_16M_P, s3_16M_P, recovery_s_16M_P, recovery_s3_16M_P description = EMS-ESP Firmware for the ESP32 src_dir = src @@ -60,8 +60,6 @@ unbuild_flags = [espressif32_base_4M] framework = arduino board_build.partitions = partitions/esp32_partition_4M.csv -board_upload.flash_size = 4MB -board_build.app_partition_name = app0 platform = https://github.com/tasmota/platform-espressif32/releases/download/2026.05.50/platform-espressif32.zip ; Tasmota Arduino Core 3.3.8 based on IDF 5.5.4.260407 ; platform = https://github.com/tasmota/platform-espressif32/releases/download/2026.09.50/platform-espressif32.zip ; Tasmota Arduino Core 3.3.12 based on IDF 5.5.5 @@ -69,17 +67,17 @@ platform = https://github.com/tasmota/platform-espressif32/releases/download/202 [espressif32_base_16M] framework = arduino board_build.partitions = partitions/esp32_partition_16M.csv -board_upload.flash_size = 16MB -board_build.app_partition_name = app0 platform = https://github.com/tasmota/platform-espressif32/releases/download/2026.05.50/platform-espressif32.zip ; Tasmota Arduino Core 3.3.8 based on IDF 5.5.4.260407 ; platform = https://github.com/tasmota/platform-espressif32/releases/download/2026.09.50/platform-espressif32.zip ; Tasmota Arduino Core 3.3.12 based on IDF 5.5.5 +build_flags = + ${common.build_flags} + -D EMSESP_HAS_RECOVERY ; installs and falls back to the recovery firmware in the factory partition + -D OTA_UPDATER_ANY_PARTITION ; so the recovery firmware can be uploaded into the factory partition ; 32MB Flash variants [espressif32_base_32M] framework = arduino board_build.partitions = partitions/esp32_partition_32M.csv -board_upload.flash_size = 32MB -board_build.app_partition_name = app0 platform = https://github.com/tasmota/platform-espressif32/releases/download/2026.05.50/platform-espressif32.zip ; Tasmota Arduino Core 3.3.8 based on IDF 5.5.4.260407 ; platform = https://github.com/tasmota/platform-espressif32/releases/download/2026.09.50/platform-espressif32.zip ; Tasmota Arduino Core 3.3.12 based on IDF 5.5.5 @@ -163,12 +161,69 @@ build_flags = ; XIAO ESP32C - 512KB SRAM & 4MB Flash - https://wiki.seeedstudio.com/xiao_esp32c6_getting_started/ [env:c6_xiao_4M] extends = espressif32_base_4M -board_build.app_partition_name = app0 board = seeed_xiao_esp32c6 build_flags = ${common.build_flags} -DEMSESP_EN_ONLY +; Crash test builds for the bootloader rollback and the recovery fallback. Install with the web upload, not over +; serial, since a serial flash boots the recovery. Change EMSESP_CRASH_TEST to pick the test: +; 1 crashes on every start, so the bootloader rolls back to the previous firmware +; 2 runs normally until it's confirmed after 2 minutes, then crashes on every start. After 5 crashes in a row +; it switches to the recovery firmware. Without one it keeps crashing until reflashed over serial +[env:crashtest_s_16M_P] +extends = env:s_16M_P +build_flags = + ${espressif32_base_16M.build_flags} + -D EMSESP_CRASH_TEST=1 +custom_fw_name = EMS-ESP-CrashTest + +; EMS-ESP Recovery - a small app for the factory (boot) partition with an access point and a firmware upload page. +; Only for boards with a factory partition (16MB). Sources are in ./recovery, the WebUI is built from ./interface. +; build: pio run -e recovery_s3_16M_P +; flash into the boot partition: pio run -e recovery_s3_16M_P -t upload +; run it from app0 like a normal app instead, add an [env:recovery_s3_16M_P] section to pio_local.ini with custom_app_offset = +; develop the WebUI with a mock: cd interface && pnpm recovery +[recovery_base] +extends = espressif32_base_16M +board_build.app_partition_name = boot +build_src_filter = -<*> +<../recovery/> +build_flags = + ${factory_settings.build_flags} + -std=gnu++20 -Os + -D OTA_UPDATER_ANY_PARTITION + -D CONFIG_ASYNC_TCP_RUNNING_CORE=1 + -D CONFIG_ASYNC_TCP_STACK_SIZE=8192 +build_unflags = + ${common.core_unbuild_flags} +lib_ldf_mode = chain +lib_deps = + bblanchon/ArduinoJson @ 7.4.3 + ESP32Async/AsyncTCP @ 3.5.0 + ESP32Async/ESPAsyncWebServer @ 3.12.1 +extra_scripts = + pre:scripts/build_recovery_interface.py + post:scripts/set_app_desc.py + post:scripts/rename_fw.py + post:scripts/upload_factory.py +upload_protocol = esptool +custom_app_offset = 0x10000 +custom_fw_name = EMS-ESP-Recovery +custom_version_file = recovery/recovery_version.h +custom_version_define = RECOVERY_VERSION + +[env:recovery_s_16M] +extends = recovery_base +board = s_16M + +[env:recovery_s_16M_P] +extends = recovery_base +board = s_16M_P + +[env:recovery_s3_16M_P] +extends = recovery_base +board = s3_16M_P + ; foundation for building and testing natively, standalone without an ESP32 ; use the `standalone` environment instead of `native` for testing [env:native] @@ -199,6 +254,7 @@ build_src_filter = -<../lib/uuid-syslog> -<../lib/eModbus> -<../lib/OneWire> + -<../lib/OtaUpdater> -<../src/core/ModuleLibrary.cpp> lib_ldf_mode = off lib_deps = @@ -242,6 +298,7 @@ build_src_filter = -<../lib/uuid-syslog> -<../lib/eModbus> -<../lib/OneWire> + -<../lib/OtaUpdater> -<../src/core/ModuleLibrary.cpp> lib_ldf_mode = off lib_deps = Unity diff --git a/project-words.txt b/project-words.txt index 15da3c6be..12f5c75f8 100644 --- a/project-words.txt +++ b/project-words.txt @@ -1351,4 +1351,10 @@ zyxwvutsrqponmlkjihgfedcba öffnen česky živanović -neopixel \ No newline at end of file +neopixel +firmwares +recognises +POWERON +crashtest +otadata +merase \ No newline at end of file diff --git a/recovery/main.cpp b/recovery/main.cpp new file mode 100644 index 000000000..23089223f --- /dev/null +++ b/recovery/main.cpp @@ -0,0 +1,871 @@ +// EMS-ESP Recovery +// +// Minimal application that lives in the factory ("boot") partition. It brings up a WiFi access point +// and connects to the network with the saved EMS-ESP settings (Ethernet, else WiFi, with the static IP +// if one is set), and serves a cut-down WebUI to upload a new EMS-ESP firmware into the OTA partitions +// or select which OTA partition to boot. +// +// The EMS-ESP settings in LittleFS are only ever read, never written, except for a factory reset +// which erases them. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if CONFIG_IDF_TARGET_ESP32 +#include +#endif + +#include +#include +#include +#include + +#include + +#include "WWWData.h" // generated by `pnpm build-recovery` in ./interface +#include "recovery_version.h" + +#ifndef FACTORY_AP_CHANNEL +#define FACTORY_AP_CHANNEL 1 +#endif + +static constexpr size_t MIN_FIRMWARE_SIZE = 512 * 1024; +static constexpr uint32_t RESTART_DELAY_MS = 500; +static constexpr uint32_t ETH_WAIT_MS = 15000; // how long Ethernet gets to obtain an address before WiFi is tried +static constexpr char NVS_NAMESPACE[] = "ems-esp"; +static constexpr char NVS_FRESH_FW[] = "fresh_firmware"; // EMSESP_NVS_BOOT_NEW_FIRMWARE in emsesp.h +static constexpr char NVS_REASON[] = "rec_reason"; // EMSESP_NVS_RECOVERY_REASON in emsesp.h + +#define LOG(...) \ + do { \ + Serial.print("[recovery] "); \ + Serial.printf(__VA_ARGS__); \ + Serial.println(); \ + } while (0) + +struct AdminUser { + String username; + String password; +}; + +struct Settings { + String ap_ssid = FACTORY_AP_SSID; + String ap_password = FACTORY_AP_PASSWORD; + uint8_t ap_channel = FACTORY_AP_CHANNEL; + IPAddress ap_ip; + IPAddress ap_gateway; + IPAddress ap_subnet; + String wifi_ssid = FACTORY_WIFI_SSID; + String wifi_password = FACTORY_WIFI_PASSWORD; + String hostname = FACTORY_NETWORK_HOSTNAME; + bool static_ip = false; + IPAddress local_ip; + IPAddress gateway_ip; + IPAddress subnet_mask; + IPAddress dns_ip_1; + IPAddress dns_ip_2; + bool eth_10mbit = false; + uint8_t phy_type = 0; // PHY_type in src/core/system.h, 0 is no Ethernet + int8_t eth_power = -1; + uint8_t eth_phy_addr = 0; + uint8_t eth_clock_mode = 0; + bool disable_reset = false; // factory reset is disabled in the EMS-ESP settings + std::vector admins; +}; + +struct PartitionInfo { + String label; + bool factory; + uint32_t address; + uint32_t size; + bool valid; + uint32_t image_size; + String version; + uint32_t install_date; // UTC epoch, 0 if unknown + String ota_state; // bootloader rollback state, empty if the partition has never been selected +}; + +static AsyncWebServer server(80); +static DNSServer dnsServer; +static Settings settings; +static OtaUpdater ota; + +static std::vector partitions; +static uint32_t restart_at = 0; +static String session_token; +static String ap_ssid; +static String recovery_reason; // set by EMS-ESP when it switched to the recovery +static const char * eth_state = ""; // "", "disabled", "failed" or "started" +static uint32_t wifi_join_at = 0; // when to try WiFi if Ethernet has no address, 0 if not waiting +static bool wifi_started = false; + +static bool ethConnected(); + +// upload state, only one upload at a time +static int upload_error = 0; +static bool upload_done = false; +static String upload_target; + +// ---------------------------------------------------------------------------- +// settings (read-only) +// ---------------------------------------------------------------------------- + +static bool readJsonFile(const char * path, JsonDocument & doc) { + File file = LittleFS.open(path, "r"); + if (!file) { + return false; + } + const DeserializationError error = deserializeJson(doc, file); + file.close(); + if (error) { + LOG("Failed to parse %s: %s", path, error.c_str()); + return false; + } + return true; +} + +static IPAddress parseIP(JsonVariantConst value, const char * fallback) { + IPAddress ip; + if (!ip.fromString(value | fallback)) { + ip.fromString(fallback); + } + return ip; +} + +static void loadSettings() { + // never format the filesystem, it holds the EMS-ESP settings + const bool mounted = LittleFS.begin(false); + LOG("LittleFS %s", mounted ? "mounted" : "not available, using factory settings"); + + JsonDocument doc; + if (mounted && readJsonFile("/config/apSettings.json", doc)) { + settings.ap_ssid = doc["ssid"] | FACTORY_AP_SSID; + settings.ap_password = doc["password"] | FACTORY_AP_PASSWORD; + settings.ap_channel = doc["channel"] | FACTORY_AP_CHANNEL; + } + settings.ap_ip = parseIP(doc["local_ip"], FACTORY_AP_LOCAL_IP); + settings.ap_gateway = parseIP(doc["gateway_ip"], FACTORY_AP_GATEWAY_IP); + settings.ap_subnet = parseIP(doc["subnet_mask"], FACTORY_AP_SUBNET_MASK); + + doc.clear(); + if (mounted && readJsonFile("/config/networkSettings.json", doc)) { + settings.wifi_ssid = doc["ssid"] | FACTORY_WIFI_SSID; + settings.wifi_password = doc["password"] | FACTORY_WIFI_PASSWORD; + settings.hostname = doc["hostname"] | FACTORY_NETWORK_HOSTNAME; + settings.eth_10mbit = doc["eth_10mbit"] | false; + settings.local_ip.fromString(doc["local_ip"] | ""); + settings.gateway_ip.fromString(doc["gateway_ip"] | ""); + settings.subnet_mask.fromString(doc["subnet_mask"] | ""); + settings.dns_ip_1.fromString(doc["dns_ip_1"] | ""); + settings.dns_ip_2.fromString(doc["dns_ip_2"] | ""); + settings.static_ip = (doc["static_ip_config"] | false) && settings.local_ip != IPAddress(); + } + + // the Ethernet PHY settings come from the board profile, stored with the EMS-ESP application settings + doc.clear(); + if (mounted && readJsonFile("/config/emsespSettings.json", doc)) { + settings.phy_type = doc["phy_type"] | 0; + settings.eth_power = doc["eth_power"] | -1; + settings.eth_phy_addr = doc["eth_phy_addr"] | 0; + settings.eth_clock_mode = doc["eth_clock_mode"] | 0; + settings.disable_reset = doc["disable_reset"] | false; + } + + doc.clear(); + if (mounted && readJsonFile("/config/securitySettings.json", doc)) { + for (JsonObject user : doc["users"].as()) { + if (user["admin"] | false) { + settings.admins.push_back({user["username"] | "", user["password"] | ""}); + } + } + } + if (settings.admins.empty()) { + // the admin user couldn't be read, so sign in with the factory login but never allow a factory reset + settings.disable_reset = true; + settings.admins.push_back({FACTORY_ADMIN_USERNAME, FACTORY_ADMIN_PASSWORD}); + } + + if (mounted) { + LittleFS.end(); + } +} + +// ---------------------------------------------------------------------------- +// partitions +// ---------------------------------------------------------------------------- + +static bool openNvs(Preferences & nvs, bool readOnly) { + if (esp_partition_find_first(ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_DATA_NVS, "nvs1")) { + return nvs.begin(NVS_NAMESPACE, readOnly, "nvs1"); + } + return nvs.begin(NVS_NAMESPACE, readOnly, "nvs"); +} + +static const char * otaStateName(esp_ota_img_states_t state) { + switch (state) { + case ESP_OTA_IMG_NEW: + return "new"; + case ESP_OTA_IMG_PENDING_VERIFY: + return "pending"; + case ESP_OTA_IMG_VALID: + return "valid"; + case ESP_OTA_IMG_INVALID: + return "invalid"; + case ESP_OTA_IMG_ABORTED: + return "aborted"; + default: + return ""; + } +} + +static const char * resetReasonName(esp_reset_reason_t reason) { + switch (reason) { + case ESP_RST_POWERON: + return "power_on"; + case ESP_RST_EXT: + return "external"; + case ESP_RST_SW: + return "software"; + case ESP_RST_PANIC: + return "panic"; + case ESP_RST_INT_WDT: + case ESP_RST_TASK_WDT: + case ESP_RST_WDT: + return "watchdog"; + case ESP_RST_BROWNOUT: + return "brownout"; + default: + return "other"; + } +} + +// one-shot, so a later restart into the recovery doesn't show a stale reason +static void loadRecoveryReason() { + Preferences nvs; + if (!openNvs(nvs, false)) { + return; + } + if (nvs.isKey(NVS_REASON)) { + recovery_reason = nvs.getString(NVS_REASON); + nvs.remove(NVS_REASON); + } + nvs.end(); +} + +static void loadPartitions() { + partitions.clear(); + + Preferences nvs; + const bool has_nvs = openNvs(nvs, true); + + const esp_partition_t * running = esp_ota_get_running_partition(); + esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_ANY, nullptr); + for (; it != nullptr; it = esp_partition_next(it)) { + const esp_partition_t * part = esp_partition_get(it); + + PartitionInfo info; + info.label = part->label; + info.factory = part->subtype == ESP_PARTITION_SUBTYPE_APP_FACTORY; + info.address = part->address; + info.size = part->size; + info.image_size = 0; + info.install_date = 0; + + esp_image_metadata_t meta = {}; + esp_partition_pos_t pos = {.offset = part->address, .size = part->size}; + info.valid = esp_image_verify(ESP_IMAGE_VERIFY_SILENT, &pos, &meta) == ESP_OK; + if (info.valid) { + info.image_size = meta.image_len; + } + + esp_ota_img_states_t state; + if (!info.factory && esp_ota_get_state_partition(part, &state) == ESP_OK) { + info.ota_state = otaStateName(state); + } + + if (part == running) { + info.version = RECOVERY_VERSION; + } else if (info.valid) { + // EMS-ESP stores the version and install date of each partition in NVS, keyed by the label + if (has_nvs && nvs.isKey(part->label)) { + info.version = nvs.getString(part->label); + info.install_date = nvs.getULong(("d_" + info.label).c_str(), 0); + } + esp_app_desc_t desc; + if (info.version.isEmpty() && esp_ota_get_partition_description(part, &desc) == ESP_OK) { + info.version = desc.version; + } + } + + partitions.push_back(info); + } + esp_partition_iterator_release(it); + + if (has_nvs) { + nvs.end(); + } +} + +static const esp_partition_t * findAppPartition(const char * label) { + return esp_partition_find_first(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_ANY, label); +} + +static bool failedToStart(const PartitionInfo & info) { + return info.ota_state == "aborted" || info.ota_state == "invalid"; +} + +// An empty OTA partition if there is one, then one whose firmware failed to start, so a working firmware +// is kept. Otherwise the one with the oldest install date, keeping the most recent firmware as a fallback. +static const esp_partition_t * defaultUploadTarget() { + const PartitionInfo * best = nullptr; + auto rank = [](const PartitionInfo & info) { return !info.valid ? 0 : failedToStart(info) ? 1 : 2; }; + for (const auto & info : partitions) { + if (info.factory) { + continue; + } + if (best == nullptr || rank(info) < rank(*best) || (rank(info) == rank(*best) && info.install_date < best->install_date)) { + best = &info; + } + } + return best ? findAppPartition(best->label.c_str()) : esp_ota_get_next_update_partition(nullptr); +} + +static const esp_partition_t * uploadTarget(AsyncWebServerRequest * request) { + if (!request->hasParam("partition")) { + return defaultUploadTarget(); + } + const esp_partition_t * part = findAppPartition(request->getParam("partition")->value().c_str()); + if (part == nullptr || part->subtype == ESP_PARTITION_SUBTYPE_APP_FACTORY || part == esp_ota_get_running_partition()) { + return nullptr; + } + return part; +} + +// tells EMS-ESP it's running a newly flashed firmware, and drops the now stale version info for that partition +static void markFreshFirmware(const String & label) { + Preferences nvs; + if (!openNvs(nvs, false)) { + return; + } + nvs.putBool(NVS_FRESH_FW, true); + if (!label.isEmpty()) { + nvs.remove(label.c_str()); + nvs.remove(("d_" + label).c_str()); + } + nvs.end(); +} + +// ---------------------------------------------------------------------------- +// authentication +// ---------------------------------------------------------------------------- + +static String generateToken() { + uint8_t bytes[16]; + esp_fill_random(bytes, sizeof(bytes)); + String token; + token.reserve(sizeof(bytes) * 2); + for (uint8_t b : bytes) { + char hex[3]; + snprintf(hex, sizeof(hex), "%02x", b); + token += hex; + } + return token; +} + +static bool isAuthorized(AsyncWebServerRequest * request) { + if (session_token.isEmpty() || !request->hasHeader("Authorization")) { + return false; + } + return request->header("Authorization") == "Bearer " + session_token; +} + +// ---------------------------------------------------------------------------- +// web handlers +// ---------------------------------------------------------------------------- + +class StaticContentHandler : public AsyncWebHandler { + public: + bool canHandle(AsyncWebServerRequest * request) const override { + return request->method() == HTTP_GET || request->method() == HTTP_HEAD; + } + + void handleRequest(AsyncWebServerRequest * request) override { + const WWWAsset * asset = lookup(request->url().c_str()); + if (asset == nullptr) { + asset = lookup("/index.html"); // SPA fallback, also catches captive portal probes + } + if (asset == nullptr) { + request->send(404); + return; + } + + const String & inm = request->header(asyncsrv::T_INM); + if (inm.length() != 0 && strcmp(inm.c_str(), asset->etag) == 0) { + request->send(304); + return; + } + + AsyncWebServerResponse * response = request->beginResponse(200, asset->contentType, asset->content, asset->len); + response->addHeader(asyncsrv::T_Content_Encoding, asyncsrv::T_gzip, false); + response->addHeader(asyncsrv::T_ETag, asset->etag, false); + request->send(response); + } + + private: + static const WWWAsset * lookup(const char * url) { + for (size_t i = 0; i < WWW_ASSETS_COUNT; i++) { + if (strcmp(WWW_ASSETS[i].uri, url) == 0) { + return &WWW_ASSETS[i]; + } + } + return nullptr; + } +}; + +static void handleStatus(AsyncWebServerRequest * request) { + auto * response = new AsyncJsonResponse(); + JsonObject root = response->getRoot().to(); + + root["recovery_version"] = RECOVERY_VERSION; + root["authenticated"] = isAuthorized(request); + root["hostname"] = settings.hostname; + root["chip"] = ESP.getChipModel(); + root["flash_size"] = ESP.getFlashChipSize(); + root["psram_size"] = ESP.getPsramSize(); + root["free_heap"] = ESP.getFreeHeap(); + root["mac"] = WiFi.macAddress(); + root["uptime"] = millis() / 1000; + root["reason"] = recovery_reason; + root["reset_reason"] = resetReasonName(esp_reset_reason()); + root["ap_ssid"] = ap_ssid; + root["ap_ip"] = WiFi.softAPIP().toString(); + root["ap_clients"] = WiFi.softAPgetStationNum(); + root["wifi_ssid"] = settings.wifi_ssid; + root["wifi_connected"] = WiFi.isConnected(); + if (WiFi.isConnected()) { + root["wifi_ip"] = WiFi.localIP().toString(); + root["wifi_rssi"] = WiFi.RSSI(); + } + root["wifi_started"] = wifi_started; + root["static_ip"] = settings.static_ip; + root["disable_reset"] = settings.disable_reset; + + // only on boards with Ethernet configured + if (eth_state[0] != '\0') { +#if CONFIG_IDF_TARGET_ESP32 + if (ethConnected()) { + root["eth_state"] = "connected"; + root["eth_ip"] = ETH.localIP().toString(); + } else if (strcmp(eth_state, "started") == 0) { + root["eth_state"] = ETH.linkUp() ? "connecting" : "no_link"; + } else +#endif + { + root["eth_state"] = eth_state; + } + } + + const esp_partition_t * boot = esp_ota_get_boot_partition(); + const esp_partition_t * running = esp_ota_get_running_partition(); + const esp_partition_t * target = defaultUploadTarget(); + root["running"] = running ? running->label : ""; + root["boot"] = boot ? boot->label : ""; + root["upload_target"] = target ? target->label : ""; + + JsonArray parts = root["partitions"].to(); + for (const auto & info : partitions) { + JsonObject p = parts.add(); + p["label"] = info.label; + p["factory"] = info.factory; + p["address"] = info.address; + p["size"] = info.size; + p["valid"] = info.valid; + p["image_size"] = info.image_size; + p["version"] = info.version; + p["install_date"] = info.install_date; + p["ota_state"] = info.ota_state; + } + + response->setLength(); + request->send(response); +} + +static void handleSignIn(AsyncWebServerRequest * request, JsonVariant & json) { + const String username = json["username"] | ""; + const String password = json["password"] | ""; + + for (const auto & admin : settings.admins) { + if (admin.username == username && admin.password == password) { + session_token = generateToken(); + + auto * response = new AsyncJsonResponse(); + JsonObject root = response->getRoot().to(); + root["access_token"] = session_token; + response->setLength(); + request->send(response); + LOG("User %s signed in", username.c_str()); + return; + } + } + + LOG("Failed sign in for user %s", username.c_str()); + request->send(401); +} + +static void handleSetBoot(AsyncWebServerRequest * request, JsonVariant & json) { + if (!isAuthorized(request)) { + request->send(401); + return; + } + + const String label = json["partition"] | ""; + const esp_partition_t * part = findAppPartition(label.c_str()); + if (part == nullptr) { + request->send(404); + return; + } + + // esp_ota_set_boot_partition() validates the image first + const esp_err_t err = esp_ota_set_boot_partition(part); + if (err != ESP_OK) { + LOG("Cannot boot from %s: %s", part->label, esp_err_to_name(err)); + request->send(400); + return; + } + + LOG("Next boot from %s, restarting", part->label); + request->send(200); + restart_at = millis() + RESTART_DELAY_MS; +} + +static void handleRestart(AsyncWebServerRequest * request) { + if (!isAuthorized(request)) { + request->send(401); + return; + } + LOG("Restart requested"); + request->send(200); + restart_at = millis() + RESTART_DELAY_MS; +} + +// factory reset, erases all EMS-ESP settings and restarts +// only signed in admins are authorized (the recovery only accepts admin users), and only if not disabled in the settings +static void handleFactoryReset(AsyncWebServerRequest * request) { + if (!isAuthorized(request)) { + request->send(401); + return; + } + if (settings.disable_reset) { + LOG("Factory reset disabled"); + request->send(403); + return; + } + + const bool ok = LittleFS.format(); // the only place the recovery writes to the filesystem + LOG("Factory reset %s", ok ? "done" : "failed"); + if (!ok) { + request->send(500); + return; + } + + request->send(200); + restart_at = millis() + RESTART_DELAY_MS; +} + +static void uploadFail(AsyncWebServerRequest * request, int code) { + if (upload_error) { + return; + } + upload_error = code; + LOG("Upload error %d", code); + request->send(code); + if (code == 406) { + request->client()->close(); + } +} + +static void handleUpload(AsyncWebServerRequest * request, const String & filename, size_t index, uint8_t * data, size_t len, bool final) { + if (!index) { + upload_error = 0; + upload_done = false; + upload_target.clear(); + + if (!isAuthorized(request)) { + uploadFail(request, 403); + return; + } + + const size_t filesize = request->contentLength(); + switch (OtaUpdater::classify(filename, filesize, MIN_FIRMWARE_SIZE)) { + case OtaUpdater::FileType::FIRMWARE: + break; + case OtaUpdater::FileType::MD5: + if (ota.setMd5(data, len)) { + LOG("MD5 digest received (%s)", ota.md5()); + } else { + uploadFail(request, 406); + } + return; + default: + // the recovery app only flashes firmware, it never touches the settings + ota.clearMd5(); + LOG("Unsupported file %s (%u bytes)", filename.c_str(), static_cast(filesize)); + uploadFail(request, 406); + return; + } + + if (!OtaUpdater::isCompatibleFirmware(data, len)) { + uploadFail(request, 503); + return; + } + if (OtaUpdater::isRecoveryFirmware(data, len)) { + uploadFail(request, 409); + return; + } + + const esp_partition_t * target = uploadTarget(request); + if (target == nullptr) { + uploadFail(request, 400); + return; + } + if (!ota.beginFirmware(filesize, target)) { + LOG("Cannot write to %s: %s", target->label, ota.errorString()); + uploadFail(request, 507); + return; + } + upload_target = target->label; + LOG("Uploading %s (%uKB) to %s%s", filename.c_str(), static_cast(filesize / 1024), target->label, ota.md5Applied() ? ", MD5 check enabled" : ""); + + request->onDisconnect([] { + if (!upload_done && !upload_target.isEmpty()) { + ota.abort(); + } + }); + } + + if (upload_error || upload_target.isEmpty()) { + return; + } + + if (!ota.write(data, len)) { + LOG("Write failed at %u: %s", static_cast(ota.progress()), ota.errorString()); + ota.abort(); + uploadFail(request, 500); + return; + } + + if (final) { + // on success this also makes the new partition the boot partition + if (!ota.end()) { + LOG("Upload failed: %s", ota.errorString()); + uploadFail(request, 500); + return; + } + upload_done = true; + } +} + +static void handleUploadComplete(AsyncWebServerRequest * request) { + if (upload_error) { + return; // response already sent + } + + if (upload_done) { + LOG("Firmware written to %s", upload_target.c_str()); + markFreshFirmware(upload_target); + loadPartitions(); + // treat the new upload as the most recent, so it isn't the default target for the next one + for (auto & info : partitions) { + if (info.label == upload_target) { + info.install_date = UINT32_MAX; + } + } + + if (ota.md5Applied()) { + auto * response = new AsyncJsonResponse(); + JsonObject root = response->getRoot().to(); + root["md5_ok"] = true; + response->setLength(); + request->send(response); + } else { + request->send(200); + } + ota.clearMd5(); + return; + } + + if (ota.hasMd5()) { + auto * response = new AsyncJsonResponse(); + JsonObject root = response->getRoot().to(); + root["md5"] = ota.md5(); + response->setLength(); + request->send(response); + return; + } + + request->send(500); +} + +// ---------------------------------------------------------------------------- +// setup & loop +// ---------------------------------------------------------------------------- + +static void joinWiFi() { + wifi_join_at = 0; + if (settings.wifi_ssid.isEmpty()) { + return; + } + WiFi.mode(WIFI_AP_STA); + if (settings.static_ip) { + WiFi.config(settings.local_ip, settings.gateway_ip, settings.subnet_mask, settings.dns_ip_1, settings.dns_ip_2); + } + WiFi.begin(settings.wifi_ssid.c_str(), settings.wifi_password.c_str()); + wifi_started = true; + LOG("Connecting to WiFi %s%s", settings.wifi_ssid.c_str(), settings.static_ip ? (" with IP " + settings.local_ip.toString()).c_str() : ""); +} + +// same bring-up as Network::startEthernet() in src/core/network.cpp +static bool startEthernet() { +#if CONFIG_IDF_TARGET_ESP32 + if (settings.phy_type == 0) { + return false; + } + const auto clock = static_cast(settings.eth_clock_mode); + if (ESP.getPsramSize() && (clock == ETH_CLOCK_GPIO16_OUT || clock == ETH_CLOCK_GPIO17_OUT)) { + LOG("Ethernet clock mode %d conflicts with PSRAM, Ethernet disabled", settings.eth_clock_mode); + eth_state = "disabled"; + return false; + } + + // reset the PHY, it doesn't always start after a warm boot + if (settings.eth_power != -1) { + pinMode(settings.eth_power, OUTPUT); + digitalWrite(settings.eth_power, LOW); + delay(500); + digitalWrite(settings.eth_power, HIGH); + } + + if (settings.eth_10mbit) { + ETH.setAutoNegotiation(false); + ETH.setLinkSpeed(10); + ETH.setFullDuplex(false); + } + + const eth_phy_type_t type = settings.phy_type == 1 ? ETH_PHY_LAN8720 : settings.phy_type == 2 ? ETH_PHY_TLK110 : ETH_PHY_RTL8201; + if (!ETH.begin(type, settings.eth_phy_addr, 23, 18, settings.eth_power, clock)) { + LOG("Ethernet failed to start, no PHY found"); + eth_state = "failed"; + return false; + } + if (settings.static_ip) { + ETH.config(settings.local_ip, settings.gateway_ip, settings.subnet_mask, settings.dns_ip_1, settings.dns_ip_2); + } + eth_state = "started"; + LOG("Ethernet started%s", settings.static_ip ? (" with IP " + settings.local_ip.toString()).c_str() : ""); + return true; +#else + return false; +#endif +} + +static bool ethConnected() { +#if CONFIG_IDF_TARGET_ESP32 + return strcmp(eth_state, "started") == 0 && ETH.hasIP(); +#else + return false; +#endif +} + +static void startNetwork() { + WiFi.persistent(false); + WiFi.setHostname(settings.hostname.c_str()); // also the default for the Ethernet interface + WiFi.mode(WIFI_AP); + + // max SSID length is 32 + ap_ssid = settings.ap_ssid.substring(0, 23) + "-recovery"; + WiFi.softAPConfig(settings.ap_ip, settings.ap_gateway, settings.ap_subnet); + // WPA2 needs at least 8 characters, otherwise fall back to the factory password + const String & ap_password = settings.ap_password.length() >= 8 ? settings.ap_password : String(FACTORY_AP_PASSWORD); + WiFi.softAP(ap_ssid.c_str(), ap_password.c_str(), settings.ap_channel); + LOG("Access point %s started on %s", ap_ssid.c_str(), WiFi.softAPIP().toString().c_str()); + + // like EMS-ESP, Ethernet first and WiFi only if Ethernet doesn't get an address, as both share a static IP + if (startEthernet()) { + wifi_join_at = millis() + ETH_WAIT_MS; + } else { + joinWiFi(); + } + + dnsServer.start(53, "*", WiFi.softAPIP()); + + if (MDNS.begin(settings.hostname.c_str())) { + MDNS.addService("http", "tcp", 80); + } +} + +static void startWebServer() { + server.on("/rest/recovery/status", HTTP_GET, handleStatus); + server.on("/rest/recovery/restart", HTTP_POST, handleRestart); + server.on("/rest/recovery/format", HTTP_POST, handleFactoryReset); + server.on("/rest/uploadFile", HTTP_POST, handleUploadComplete, handleUpload); + server.addHandler(new AsyncCallbackJsonWebHandler("/rest/signIn", handleSignIn)); + server.addHandler(new AsyncCallbackJsonWebHandler("/rest/recovery/boot", handleSetBoot)); + server.addHandler(new StaticContentHandler()); // must be last + server.begin(); +} + +void setup() { + Serial.begin(115200); + LOG("EMS-ESP Recovery v%s", RECOVERY_VERSION); + + loadSettings(); + loadRecoveryReason(); + loadPartitions(); + startNetwork(); + startWebServer(); +} + +void loop() { + dnsServer.processNextRequest(); + + static bool was_connected = false; + if (WiFi.isConnected() != was_connected) { + was_connected = WiFi.isConnected(); + if (was_connected) { + LOG("WiFi connected, IP %s", WiFi.localIP().toString().c_str()); + } + } + +#if CONFIG_IDF_TARGET_ESP32 + static bool eth_was_connected = false; + if (ethConnected() != eth_was_connected) { + eth_was_connected = ethConnected(); + if (eth_was_connected) { + LOG("Ethernet connected, IP %s", ETH.localIP().toString().c_str()); + } + } +#endif + + if (wifi_join_at && (int32_t)(millis() - wifi_join_at) >= 0) { + if (ethConnected()) { + wifi_join_at = 0; + } else { + LOG("No Ethernet connection"); + joinWiFi(); + } + } + + if (restart_at && (int32_t)(millis() - restart_at) >= 0) { + ESP.restart(); + } + + delay(10); +} diff --git a/recovery/recovery_version.h b/recovery/recovery_version.h new file mode 100644 index 000000000..3b757c2a0 --- /dev/null +++ b/recovery/recovery_version.h @@ -0,0 +1 @@ +#define RECOVERY_VERSION "1.0.0" diff --git a/scripts/build_recovery_interface.py b/scripts/build_recovery_interface.py new file mode 100644 index 000000000..23a82bdde --- /dev/null +++ b/scripts/build_recovery_interface.py @@ -0,0 +1,59 @@ +# Builds the Recovery WebUI into recovery/WWWData.h when it's missing or older than its sources +from pathlib import Path +import os +import shutil +import subprocess + +Import("env") + +INTERFACE_DIR = Path("interface") +OUTPUT_FILE = Path("recovery") / "WWWData.h" +SOURCES = [ + INTERFACE_DIR / "recovery.html", + INTERFACE_DIR / "vite.config.ts", + INTERFACE_DIR / "progmem-generator.js", + INTERFACE_DIR / "src" / "recovery", + INTERFACE_DIR / "src" / "components", + INTERFACE_DIR / "src" / "CustomTheme.tsx", + INTERFACE_DIR / "public", +] + + +def newest_mtime(paths): + newest = 0.0 + for path in paths: + if path.is_dir(): + for f in path.rglob("*"): + if f.is_file(): + newest = max(newest, f.stat().st_mtime) + elif path.exists(): + newest = max(newest, path.stat().st_mtime) + return newest + + +def is_stale(): + if not OUTPUT_FILE.exists(): + return True + return newest_mtime(SOURCES) > OUTPUT_FILE.stat().st_mtime + + +def build(): + pnpm = next((n for n in ("pnpm", "pnpm.cmd", "pnpm.exe") if shutil.which(n)), None) + if pnpm is None: + print("Error: pnpm not found in PATH, cannot build the Recovery WebUI") + env.Exit(1) + + os.environ["CI"] = "true" + for command in (f"{pnpm} install", f"{pnpm} build-recovery"): + print(f"Running: {command}") + result = subprocess.run(command, shell=True, cwd=INTERFACE_DIR) + if result.returncode != 0: + print(f"Error: '{command}' failed") + env.Exit(1) + + +if is_stale(): + print("Building Recovery WebUI...") + build() +else: + print("Recovery WebUI is up to date") diff --git a/scripts/do_fresh_install.sh b/scripts/do_fresh_install.sh index 790ced624..27e053600 100755 --- a/scripts/do_fresh_install.sh +++ b/scripts/do_fresh_install.sh @@ -1,26 +1,133 @@ +#!/usr/bin/env bash + +# Run like: +# ./scripts/do_fresh_install.sh +# +# Full fresh install of EMS-ESP on an ESP32 16MB board (s_16M_P). +# +# 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 + +cd "$(dirname "$0")" + +# Bold ANSI: 36 cyan, 33 yellow, 35 magenta, 32 green, 31 red. +step() { + local color=$1 + shift + printf '\n\033[1;%sm▶ %s\033[0m\n' "$color" "$*" +} +note() { + printf ' \033[2m%s\033[0m\n' "$*" +} ESPTOOL=~/.platformio/penv/bin/esptool +PYTHON=~/.platformio/penv/bin/python + PORT=/dev/ttyUSB0 -BUILD=../.pio/build/s_16M_P -BOOT_APP0=~/.platformio/packages/framework-arduinoespressif32/tools/partitions/boot_app0.bin +BAUD=${BAUD:-2000000} +# Fast is the installer's default: NVS, otadata, and the first 4 KB of every +# app partition, so nothing old can boot. About a second. ERASE=full still +# wipes the whole chip. +ERASE=${ERASE:-fast} -# erase -$ESPTOOL --chip esp32 --port $PORT --after no-reset erase-flash +BUILD_DIR=../.pio/build/s_16M_P +RECOVERY_DIR=../.pio/build/recovery_s_16M_P +FIRMWARE_DIR=../build/firmware -# create the filesystem -~/.platformio/penv/bin/python build_littlefs.py data $BUILD/littlefs.bin +# bin files +BOOT_APP0_BIN=~/.platformio/packages/framework-arduinoespressif32/tools/partitions/boot_app0.bin +FIRMWARE_BIN=$BUILD_DIR/firmware.bin +RECOVERY_BIN=$RECOVERY_DIR/firmware.bin +BOOTLOADER_BIN=$BUILD_DIR/bootloader.bin +PARTITIONS_BIN=$BUILD_DIR/partitions.bin +LITTLEFS_BIN=$FIRMWARE_DIR/emsesp_settings_v39.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 +# welcome message +step 33 "Welcome to the EMS-ESP fresh install" +note "This will install the latest EMS-ESP firmware and LittleFS" -# flash bootloader + partition table + app (app0 @ 0x490000), then boot -$ESPTOOL --chip esp32 --port $PORT --baud 460800 \ +# check if platformio is installed +if [[ ! -f $PYTHON ]]; then + printf '\033[1;31mPlatformIO is not installed - please install it first\033[0m\n' >&2 + exit 1 +fi + +# check if esptool is installed +if [[ ! -f $ESPTOOL ]]; then + printf '\033[1;31mESPTool is not installed - please install it first\033[0m\n' >&2 + exit 1 +fi + +# Build LittleFS from data folder +step 33 "Building LittleFS from data folder" +note "packs data/pre_load.json -> $LITTLEFS_BIN" +$PYTHON build_littlefs.py data $LITTLEFS_BIN + +# Check if all files are present +step 33 "Checking images..." +for f in \ + "$BOOTLOADER_BIN" \ + "$PARTITIONS_BIN" \ + "$FIRMWARE_BIN" \ + "$RECOVERY_BIN" \ + "$BOOT_APP0_BIN" \ + "$LITTLEFS_BIN"; do + if [[ ! -f $f ]]; then + printf '\033[1;31mMissing %s - build s_16M_P and recovery_s_16M_P first\033[0m\n' "$f" >&2 + exit 1 + fi + note "ok $f" +done + +# show port, erase and baud rate +step 33 "Port: $PORT, Erase: $ERASE, Baud: $BAUD" + +# Fast erase on the 16 MB table: nvs, otadata, nvs1, and the starts of boot, +# app0 and app1. Stay in the bootloader for the write that follows. +if [[ $ERASE == full ]]; then + step 33 "Full erase - wiping the whole chip" + $ESPTOOL --chip esp32 --port $PORT --after no-reset erase-flash +else + step 33 "Fast erase - NVS, otadata, and the first 4 KB of every app" + note "nothing old can boot; takes about a second" + before=default-reset + for spec in \ + "0x9000 0x5000 nvs" \ + "0xe000 0x2000 otadata" \ + "0x10000 0x1000 boot" \ + "0x490000 0x1000 app0" \ + "0x920000 0x1000 app1" \ + "0xdb0000 0x40000 nvs1"; do + set -- $spec + printf ' \033[33merase-region %s %s (%s)\033[0m\n' "$1" "$2" "$3" + $ESPTOOL --chip esp32 --port $PORT --before $before --after no-reset \ + erase-region "$1" "$2" + before=no-reset + done +fi + +step 33 "Writing flash, then hard-reset into EMS-ESP" +note "0x1000 bootloader 0x8000 partitions 0xe000 otadata" +note "0x10000 recovery 0x490000 firmware 0xdf0000 LittleFS" +$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 \ - 0xe000 $BOOT_APP0 \ - 0x490000 $BUILD/firmware.bin + 0x1000 $BOOTLOADER_BIN \ + 0x8000 $PARTITIONS_BIN \ + 0xe000 $BOOT_APP0_BIN \ + 0x10000 $RECOVERY_BIN \ + 0x490000 $FIRMWARE_BIN \ + 0xdf0000 $LITTLEFS_BIN + +step 32 "Done - gateway should boot EMS-ESP from app0" diff --git a/scripts/rename_fw.py b/scripts/rename_fw.py index d63ff2f97..847634433 100755 --- a/scripts/rename_fw.py +++ b/scripts/rename_fw.py @@ -11,14 +11,16 @@ OUTPUT_DIR = Path("build") def bin_copy(source, target, env): """Optimized firmware renaming and copying function.""" - # Get the application version from emsesp_version.h - version_file = Path('./src/emsesp_version.h') + # Get the application version, from emsesp_version.h unless overridden in the env + fw_name = env.GetProjectOption("custom_fw_name", "EMS-ESP") + version_file = Path(env.GetProjectOption("custom_version_file", "./src/emsesp_version.h")) + version_define = env.GetProjectOption("custom_version_define", "EMSESP_APP_VERSION") if not version_file.exists(): - print("Error: emsesp_version.h not found!") + print(f"Error: {version_file} not found!") return app_version = None - version_pattern = re.compile(r'^#define EMSESP_APP_VERSION\s+"(\S+)"') + version_pattern = re.compile(rf'^#define {version_define}\s+"(\S+)"') with version_file.open('r') as f: for line in f: @@ -28,7 +30,7 @@ def bin_copy(source, target, env): break if not app_version: - print("Error: Could not find EMSESP_APP_VERSION in emsesp_version.h!") + print(f"Error: Could not find {version_define} in {version_file}!") return # Get the chip type, in uppercase @@ -61,7 +63,7 @@ def bin_copy(source, target, env): # Convert . to _ so Windows doesn't complain # Format is EMS-ESP--- with + at the end if it has PSRAM - variant = f"EMS-ESP-{app_version.replace('.', '_')}-{mcu}-{flash_mem}{'+' if psram else ''}" + variant = f"{fw_name}-{app_version.replace('.', '_')}-{mcu}-{flash_mem}{'+' if psram else ''}" # Create output directories firmware_dir = OUTPUT_DIR / "firmware" diff --git a/scripts/set_app_desc.py b/scripts/set_app_desc.py new file mode 100644 index 000000000..05ba3f227 --- /dev/null +++ b/scripts/set_app_desc.py @@ -0,0 +1,69 @@ +# Writes the project name (custom_fw_name) and version (custom_version_file/custom_version_define) into the +# esp_app_desc_t of firmware.bin, which the prebuilt Arduino libraries leave empty. The application can then +# identify the image with esp_ota_get_partition_description(), or from the first bytes of an upload. +# Must run before rename_fw.py so the renamed copy and its MD5 include the change. + +import hashlib +import re +import struct +from pathlib import Path + +Import("env") + +IMAGE_HEADER_LEN = 24 +SEGMENT_HEADER_LEN = 8 +APP_DESC_OFFSET = IMAGE_HEADER_LEN + SEGMENT_HEADER_LEN +APP_DESC_MAGIC = 0xABCD5432 +HASH_APPENDED_OFFSET = 23 + + +def read_version(env): + version_file = Path(env.GetProjectOption("custom_version_file", "./src/emsesp_version.h")) + version_define = env.GetProjectOption("custom_version_define", "EMSESP_APP_VERSION") + pattern = re.compile(rf'^#define {version_define}\s+"(\S+)"') + for line in version_file.read_text().splitlines(): + match = pattern.match(line) + if match: + return match.group(1) + raise ValueError(f"{version_define} not found in {version_file}") + + +def put_string(image, offset, value): + encoded = value.encode()[:31] + image[offset:offset + 32] = encoded + b"\0" * (32 - len(encoded)) + + +def set_app_desc(source, target, env): + path = Path(target[0].get_abspath()) + image = bytearray(path.read_bytes()) + + (magic,) = struct.unpack_from(" -#include #include -static String getFilenameExtension(const String & filename) { - const auto pos = filename.lastIndexOf('.'); - if (pos != -1) { - return filename.substring(static_cast(pos) + 1); - } - return {}; -} - -// Accepts a raw 32-char hex digest, optionally with trailing newline or a GNU "hash filename" suffix. -static bool parseMd5Digest(const uint8_t * data, size_t len, std::array & out) { - size_t i = 0; - while (i < len && (data[i] == ' ' || data[i] == '\t' || data[i] == '\r' || data[i] == '\n')) { - ++i; - } - if (len - i < 32) { - return false; - } - for (size_t n = 0; n < 32; n++) { - const char c = static_cast(data[i + n]); - const bool hex = (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'); - if (!hex) { - return false; - } - out[n] = (c >= 'A' && c <= 'F') ? static_cast(c - 'A' + 'a') : c; - } - out[32] = '\0'; - return true; -} - UploadFileService::UploadFileService(AsyncWebServer * server, SecurityManager * securityManager) : _securityManager(securityManager) , _is_firmware(false) , _is_filesystem(false) - , _md5_applied(false) - , _md5() { + , _is_recovery(false) + , _response_sent(false) { server->on( UPLOAD_FILE_PATH, HTTP_POST, @@ -60,68 +30,61 @@ void UploadFileService::handleUpload(AsyncWebServerRequest * request, const Stri // at init if (!index) { // check details of the file, to see if its a valid bin or json file - const String extension = getFilenameExtension(filename); - const std::size_t filesize = request->contentLength(); + const std::size_t filesize = request->contentLength(); _is_firmware = false; _is_filesystem = false; + _is_recovery = false; + _response_sent = false; - if (extension == "bin" && filename.endsWith("littlefs.bin")) { - // LittleFS filesystem image + switch (OtaUpdater::classify(filename, filesize, emsesp::System::MIN_FIRMWARE_SIZE)) { + case OtaUpdater::FileType::FILESYSTEM: _is_filesystem = true; - _md5[0] = '\0'; // clear any stale md5 so Update.end() doesn't compare against it - _md5_applied = false; - } else if ((extension == "bin") && (filesize >= emsesp::System::MIN_FIRMWARE_SIZE)) { + _ota.clearMd5(); // clear any stale md5 so Update.end() doesn't compare against it + break; + case OtaUpdater::FileType::FIRMWARE: _is_firmware = true; - } else if (extension == "json") { - _md5[0] = '\0'; // clear md5 - _md5_applied = false; - } else if (extension == "md5") { - if (!parseMd5Digest(data, len, _md5)) { + break; + case OtaUpdater::FileType::JSON: + _ota.clearMd5(); + break; + case OtaUpdater::FileType::MD5: + if (!_ota.setMd5(data, len)) { emsesp::EMSESP::logger().err("Invalid MD5 digest file"); handleError(request, 406); // Not Acceptable } else { - emsesp::EMSESP::logger().info("MD5 digest received (%s). Now upload the firmware BIN", _md5.data()); + emsesp::EMSESP::logger().info("MD5 digest received (%s). Now upload the firmware BIN", _ota.md5()); } return; - } else { - _md5.front() = '\0'; - _md5_applied = false; + default: + _ota.clearMd5(); emsesp::EMSESP::logger().err("Unsupported file type: %s, size: %u", filename.c_str(), filesize); handleError(request, 406); // Not Acceptable - unsupported file type return; } if (_is_firmware) { - // Check firmware header, 0xE9 magic offset 0 indicates esp bin, chip offset 12: esp32:0, S2:2, C3:5 -#if CONFIG_IDF_TARGET_ESP32 // ESP32/PICO-D4 - if (len > 12 && (data[0] != ESP_IMAGE_HEADER_MAGIC || data[12] != ESP_CHIP_ID_ESP32)) { - handleError(request, 503); // service unavailable - return; - } -#elif CONFIG_IDF_TARGET_ESP32S2 - if (len > 12 && (data[0] != ESP_IMAGE_HEADER_MAGIC || data[12] != ESP_CHIP_ID_ESP32S2)) { - handleError(request, 503); // service unavailable - return; - } -#elif CONFIG_IDF_TARGET_ESP32C3 - if (len > 12 && (data[0] != ESP_IMAGE_HEADER_MAGIC || data[12] != ESP_CHIP_ID_ESP32C3)) { - handleError(request, 503); // service unavailable - return; - } -#elif CONFIG_IDF_TARGET_ESP32S3 - if (len > 12 && (data[0] != ESP_IMAGE_HEADER_MAGIC || data[12] != ESP_CHIP_ID_ESP32S3)) { - handleError(request, 503); // service unavailable - return; - } -#elif CONFIG_IDF_TARGET_ESP32C6 - if (len > 12 && (data[0] != ESP_IMAGE_HEADER_MAGIC || data[12] != ESP_CHIP_ID_ESP32C6)) { + if (!OtaUpdater::isCompatibleFirmware(data, len)) { handleError(request, 503); // service unavailable return; } + + const esp_partition_t * target = nullptr; +#ifdef EMSESP_HAS_RECOVERY + if (OtaUpdater::isRecoveryFirmware(data, len)) { + target = esp_partition_find_first(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_APP_FACTORY, nullptr); + if (target == nullptr || target == esp_ota_get_running_partition()) { + emsesp::EMSESP::logger().err("The recovery firmware can't be installed on this board"); + handleError(request, 406); + return; + } + _is_recovery = true; + emsesp::EMSESP::logger().info("Recovery firmware uploading to the %s partition (size: %dKB). Please wait...", target->label, filesize / 1024); + } else #endif - // it's firmware - initialize the ArduinoOTA updater - emsesp::EMSESP::logger().info("Firmware uploading (file %s, size: %dKB). Please wait...", filename.c_str(), filesize / 1024); + { + emsesp::EMSESP::logger().info("Firmware uploading (file %s, size: %dKB). Please wait...", filename.c_str(), filesize / 1024); + } // turn off UART to prevent interference with the upload emsesp::EMSuart::stop(); @@ -129,15 +92,13 @@ void UploadFileService::handleUpload(AsyncWebServerRequest * request, const Stri // tell main loop we're uploading so services as paused (e.g. MQTT) emsesp::EMSESP::system_.systemStatus(emsesp::SYSTEM_STATUS::SYSTEM_STATUS_UPLOADING); - if (Update.begin(filesize - sizeof(esp_image_header_t))) { - _md5_applied = false; - if (strlen(_md5.data()) == _md5.size() - 1) { - Update.setMD5(_md5.data()); - _md5_applied = true; - emsesp::EMSESP::logger().info("Firmware MD5 check enabled (%s)", _md5.data()); + if (_ota.beginFirmware(filesize, target, !_is_recovery)) { + if (_ota.md5Applied()) { + emsesp::EMSESP::logger().info("Firmware MD5 check enabled (%s)", _ota.md5()); } request->onDisconnect([this] { handleDisconnect(); }); // success, let's make sure we end the update if the client hangs up } else { + emsesp::EMSESP::logger().err("OTA begin failed: %s", _ota.errorString()); handleError(request, 507); // failed to begin, send an error response Insufficient Storage return; } @@ -147,14 +108,10 @@ void UploadFileService::handleUpload(AsyncWebServerRequest * request, const Stri emsesp::EMSuart::stop(); LittleFS.end(); // unmount LittleFS before we overwrite the partition under it - // request->contentLength() is the multipart HTTP body size, not the file size, - // so it can exceed the partition by a few hundred bytes. Use UPDATE_SIZE_UNKNOWN - // and let the Update library size against the whole partition. - if (Update.begin(UPDATE_SIZE_UNKNOWN, U_SPIFFS)) { - // emsesp::EMSESP::logger().info("Update.begin(U_SPIFFS) ok, partition size %u bytes", static_cast(Update.size())); + if (_ota.beginFilesystem()) { request->onDisconnect([this] { handleDisconnect(); }); } else { - emsesp::EMSESP::logger().err("Update.begin(U_SPIFFS) failed: %s", Update.errorString()); + emsesp::EMSESP::logger().err("Update.begin(U_SPIFFS) failed: %s", _ota.errorString()); handleError(request, 507); return; } @@ -167,17 +124,18 @@ void UploadFileService::handleUpload(AsyncWebServerRequest * request, const Stri if (_is_firmware || _is_filesystem) { if (!request->_tempObject) { //continue with the OTA update - if (Update.write(data, len) != len) { + if (!_ota.write(data, len)) { emsesp::EMSESP::logger().err("OTA update failed at offset %u (chunk %u): %s", - static_cast(Update.progress()), + static_cast(_ota.progress()), static_cast(len), - Update.errorString()); + _ota.errorString()); + _ota.abort(); handleError(request, 500); // internal error, failed return; } if (final) { - if (!Update.end(true)) { - emsesp::EMSESP::logger().err("OTA update failed: %s", Update.errorString()); + if (!_ota.end()) { + emsesp::EMSESP::logger().err("OTA update failed: %s", _ota.errorString()); handleError(request, 500); // internal error, failed return; } @@ -192,6 +150,19 @@ void UploadFileService::handleUpload(AsyncWebServerRequest * request, const Stri } void UploadFileService::uploadComplete(AsyncWebServerRequest * request) { + if (request->_tempObject) { + const int code = static_cast(reinterpret_cast(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? @@ -206,21 +177,39 @@ void UploadFileService::uploadComplete(AsyncWebServerRequest * request) { // check if it was a firmware or filesystem image upgrade // if no error, send the success response and request a restart +#ifdef EMSESP_HAS_RECOVERY + if (_is_recovery && !request->_tempObject) { + emsesp::EMSESP::system_.recovery_installed(); + auto * response = new emsesp::PsramAsyncJsonResponse(false); + JsonObject root = response->getRoot(); + root["recovery"] = true; + if (_ota.md5Applied()) { + emsesp::EMSESP::logger().info("Firmware MD5 matches"); + root["md5_ok"] = true; + } + response->setLength(); + request->send(response); + _ota.clearMd5(); + _is_recovery = false; + emsesp::EMSESP::system_.systemStatus(emsesp::SYSTEM_STATUS::SYSTEM_STATUS_NORMAL); // no restart needed + return; + } +#endif + if ((_is_firmware || _is_filesystem) && !request->_tempObject) { if (_is_firmware) { // set NVS to tell EMS-ESP this is a new fresh firmware on next restart emsesp::EMSESP::nvs_.putBool(emsesp::EMSESP_NVS_BOOT_NEW_FIRMWARE, true); } - if (_is_firmware && _md5_applied) { + if (_is_firmware && _ota.md5Applied()) { emsesp::EMSESP::logger().info("Firmware MD5 matches"); auto * response = new emsesp::PsramAsyncJsonResponse(false); JsonObject root = response->getRoot(); root["md5_ok"] = true; response->setLength(); request->send(response); - _md5.front() = '\0'; - _md5_applied = false; + _ota.clearMd5(); } else { AsyncWebServerResponse * response = request->beginResponse(200); request->send(response); @@ -231,10 +220,10 @@ void UploadFileService::uploadComplete(AsyncWebServerRequest * request) { } // add MD5 to the response - if (strlen(_md5.data()) == _md5.size() - 1) { + if (_ota.hasMd5()) { auto * response = new emsesp::PsramAsyncJsonResponse(false); JsonObject root = response->getRoot(); - root["md5"] = _md5.data(); + root["md5"] = _ota.md5(); response->setLength(); request->send(response); return; @@ -244,25 +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(static_cast(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; - Update.abort(); + _is_recovery = false; } // if we aborted a filesystem upload, remount LittleFS so the device keeps working @@ -275,6 +264,12 @@ void UploadFileService::handleDisconnect() { emsesp::EMSESP::logger().info("Upload finished"); emsesp::EMSESP::system_.uart_init(); // re-enable UART + _ota.abort(); + if (_is_recovery || _is_firmware || _is_filesystem) { + emsesp::EMSESP::system_.systemStatus(emsesp::SYSTEM_STATUS::SYSTEM_STATUS_NORMAL); + } + _is_firmware = false; _is_filesystem = false; + _is_recovery = false; } diff --git a/src/ESP32React/UploadFileService.h b/src/ESP32React/UploadFileService.h index 9da611c7a..75890dd08 100644 --- a/src/ESP32React/UploadFileService.h +++ b/src/ESP32React/UploadFileService.h @@ -6,11 +6,9 @@ #include #include #include -#include +#include #include -#include - #define UPLOAD_FILE_PATH "/rest/uploadFile" #define TEMP_FILENAME_PATH "/pre_load.json" // for uploaded json files, handled by System::check_restore() @@ -20,11 +18,12 @@ class UploadFileService { UploadFileService(AsyncWebServer * server, SecurityManager * securityManager); private: - SecurityManager * _securityManager; - bool _is_firmware; - bool _is_filesystem; - bool _md5_applied; // true if an MD5 digest was applied to the current firmware upload - std::array _md5; + SecurityManager * _securityManager; + bool _is_firmware; + bool _is_filesystem; + bool _is_recovery; // firmware for the factory partition, which doesn't change the boot partition + bool _response_sent; + OtaUpdater _ota; void handleUpload(AsyncWebServerRequest * request, const String & filename, size_t index, uint8_t * data, size_t len, bool final); void uploadComplete(AsyncWebServerRequest * request); diff --git a/src/core/emsesp.cpp b/src/core/emsesp.cpp index 37c9328ae..977b3c2b9 100644 --- a/src/core/emsesp.cpp +++ b/src/core/emsesp.cpp @@ -1897,6 +1897,8 @@ void EMSESP::start() { nvs_.begin("ems-esp", false, "nvs"); #endif + System::check_crash_loop(); + // set valid GPIOs list based on ESP32 chip/platform type system_.set_valid_system_gpios(); diff --git a/src/core/emsesp.h b/src/core/emsesp.h index ac15d7fb5..638a6ad15 100644 --- a/src/core/emsesp.h +++ b/src/core/emsesp.h @@ -116,6 +116,8 @@ class EMSESPShell; class Shower; static constexpr const char * EMSESP_NVS_BOOT_NEW_FIRMWARE = "fresh_firmware"; // max 15 characters +// why EMS-ESP switched to the recovery firmware, read and removed by the recovery app (recovery/main.cpp) +static constexpr const char * EMSESP_NVS_RECOVERY_REASON = "rec_reason"; class EMSESP { public: diff --git a/src/core/main.cpp b/src/core/main.cpp index b7271ee31..72c4cd5af 100644 --- a/src/core/main.cpp +++ b/src/core/main.cpp @@ -18,12 +18,32 @@ #include "emsesp.h" +#ifdef EMSESP_CRASH_TEST +#include +#endif + using namespace emsesp; static EMSESP application; // the main application +#ifdef EMSESP_CRASH_TEST +// test builds for the bootloader rollback and the recovery fallback, see [env:crashtest_s3_16M_P] in platformio.ini +static void crash_test() { + esp_ota_img_states_t state = ESP_OTA_IMG_UNDEFINED; + esp_ota_get_state_partition(esp_ota_get_running_partition(), &state); + if (EMSESP_CRASH_TEST == 1 || state == ESP_OTA_IMG_VALID) { + Serial.println("Crash test, aborting"); + Serial.flush(); + abort(); + } +} +#endif + void setup() { application.start(); +#ifdef EMSESP_CRASH_TEST + crash_test(); +#endif } void loop() { diff --git a/src/core/system.cpp b/src/core/system.cpp index ff9fa1e91..27d8489e7 100644 --- a/src/core/system.cpp +++ b/src/core/system.cpp @@ -27,6 +27,7 @@ #include #include "esp_efuse.h" #include +#include #endif #include @@ -43,10 +44,24 @@ #define ENABLE_SMTP #include #include +#include + +// Keeps new firmware in the bootloader's pending verification state until System::check_firmware_health() +// confirms it, so a crash or restart before then rolls back to the previous image +extern "C" bool verifyRollbackLater() { + return true; +} #endif namespace emsesp { +#ifndef EMSESP_STANDALONE +// survives restarts, panics and watchdog resets but not a power cycle, hence the magic +static constexpr uint32_t CRASH_COUNT_MAGIC = 0x43524153; +RTC_NOINIT_ATTR static uint32_t crash_count_magic_; +RTC_NOINIT_ATTR static uint32_t crash_count_; +#endif + // Languages supported. Note: the order is important // and must match locale_translations.h and common.h #if defined(EMSESP_TEST) @@ -517,6 +532,10 @@ void System::get_partition_info() { } } +#ifdef EMSESP_HAS_RECOVERY + // the recovery firmware is shown separately + is_valid = is_valid && !OtaUpdater::isRecoveryPartition(part); +#endif // get the version from the NVS store, and add to map if (is_valid) { PartitionInfo p_info; @@ -547,6 +566,35 @@ void System::get_partition_info() { #endif } +void System::recovery_installed() { +#ifdef EMSESP_HAS_RECOVERY + // drop the version info of an EMS-ESP firmware that was in the factory partition before + const esp_partition_t * factory = esp_partition_find_first(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_APP_FACTORY, nullptr); + if (factory != nullptr) { + char c[20]; + snprintf(c, sizeof(c), "d_%s", factory->label); + 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()); +#endif +} + +std::string System::recovery_version() const { +#ifdef EMSESP_HAS_RECOVERY + esp_app_desc_t desc; + if (OtaUpdater::isRecoveryPartition(esp_partition_find_first(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_APP_FACTORY, nullptr), &desc)) { + return desc.version; + } +#endif + return ""; +} + // set install time/date for the current partition, in UTC // assumes NTP is connected and working void System::set_partition_install_date() { @@ -595,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; @@ -610,7 +664,13 @@ void System::system_restart(const char * partitionname) { // Factory partition - label will be "factory" const esp_partition_t * partition = esp_partition_find_first(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_APP_FACTORY, NULL); if (partition && !strcmp(partition->label, partitionname)) { +#ifdef EMSESP_HAS_RECOVERY + if (esp_ota_set_boot_partition(partition) == ESP_OK) { + EMSESP::nvs_.putString(EMSESP_NVS_RECOVERY_REASON, "request"); + } +#else esp_ota_set_boot_partition(partition); +#endif } else // try and find the partition by name if (strcmp(esp_ota_get_running_partition()->label, partitionname)) { @@ -762,11 +822,74 @@ void System::store_settings(WebSettings & settings) { auto_fw_check_ = settings.auto_fw_check; } +// counts consecutive crashes and falls back to the recovery firmware in the factory partition (16MB boards) +void System::check_crash_loop() { +#ifndef EMSESP_STANDALONE + if (crash_count_magic_ != CRASH_COUNT_MAGIC) { + crash_count_magic_ = CRASH_COUNT_MAGIC; + crash_count_ = 0; + } + + switch (esp_reset_reason()) { + case ESP_RST_PANIC: + case ESP_RST_INT_WDT: + case ESP_RST_TASK_WDT: + case ESP_RST_WDT: + crash_count_++; + break; + default: + crash_count_ = 0; + return; + } + +#ifdef EMSESP_HAS_RECOVERY + if (crash_count_ < CRASH_LOOP_LIMIT) { + return; + } + + const esp_partition_t * factory = esp_partition_find_first(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_APP_FACTORY, nullptr); + if (factory == nullptr || factory == esp_ota_get_running_partition()) { + return; + } + // fails if the factory partition doesn't hold a valid image, then keep trying the current one + if (esp_ota_set_boot_partition(factory) == ESP_OK) { + crash_count_ = 0; + EMSESP::nvs_.putString(EMSESP_NVS_RECOVERY_REASON, "crash"); + Serial.printf("Crashed %d times in a row, restarting into the %s partition\n", CRASH_LOOP_LIMIT, factory->label); + Serial.flush(); + esp_restart(); + } +#endif +#endif +} + +// once the firmware has run long enough, confirm it to the bootloader so it isn't rolled back +void System::check_firmware_health() { +#ifndef EMSESP_STANDALONE + if (firmware_healthy_ || uuid::get_uptime_sec() < FIRMWARE_HEALTHY_UPTIME) { + return; + } + firmware_healthy_ = true; + crash_count_ = 0; + if (OtaUpdater::confirmRunningApp()) { + LOG_INFO("New firmware confirmed as working"); + } +#endif +} + // Starts up core services void System::start() { get_partition_info(); // get the partition info #ifndef EMSESP_STANDALONE + if (crash_count_) { + LOG_WARNING("Restarted after a crash (%u in a row)", crash_count_); + } + const esp_partition_t * invalid = esp_ota_get_last_invalid_partition(); + if (invalid != nullptr) { + LOG_WARNING("Firmware in partition %s failed to start and was rolled back", invalid->label); + } + // disable bluetooth module // periph_module_disable(PERIPH_BT_MODULE); if (low_clock_) { @@ -836,23 +959,30 @@ void System::button_OnDblClick(PButton & b) { #endif } -// button long press +// button long press, held for BUTTON_LongPressDelay (3000 ms, 3 seconds) +// restart EMS-ESP void System::button_OnLongPress(PButton & b) { - LOG_NOTICE("Button pressed - long press - restart EMS-ESP"); - EMSESP::system_.system_restart("boot"); -} - -// button indefinite press -void System::button_OnVLongPress(PButton & b) { if (EMSESP::system_.disable_reset()) { - LOG_NOTICE("Factory reset disabled"); + LOG_NOTICE("reset disabled, can't boot"); return; } - LOG_NOTICE("Button pressed - very long press - perform factory reset"); - EMSESP::led_.start_led_fast_flash(5); // Start LED flash timer for 5 seconds + LOG_NOTICE("Button pressed - long press - restart EMS-ESP"); + EMSESP::system_.system_restart(); } -// push button +// button indefinite press, held for BUTTON_VLongPressDelay (6000 ms, 6 seconds) +// restart EMS_ESP into the Recovery boot menu, where you can optionally do a factory reset +void System::button_OnVLongPress(PButton & b) { + if (EMSESP::system_.disable_reset()) { + LOG_NOTICE("reset disabled, can't boot"); + return; + } + LOG_NOTICE("Button pressed - very long press - perform factory reset in Recovery Mode"); + EMSESP::system_.system_restart("boot"); + // EMSESP::led_.start_led_fast_flash(5); // Start LED flash timer for 5 seconds +} + +// push button, held for BUTTON_Debounce (200 ms) void System::button_init() { #ifndef EMSESP_STANDALONE if (!myPButton_.init(pbutton_gpio_, HIGH)) { @@ -1028,6 +1158,8 @@ void System::system_check() { if (!last_system_check_ || ((uint32_t)(current_uptime - last_system_check_) >= SYSTEM_CHECK_FREQUENCY)) { last_system_check_ = current_uptime; + check_firmware_health(); + #ifndef EMSESP_STANDALONE #if CONFIG_IDF_TARGET_ESP32S3 || CONFIG_IDF_TARGET_ESP32C3 || CONFIG_IDF_TARGET_ESP32S2 temperature_sensor_get_celsius(temperature_handle_, &temperature_); @@ -1208,6 +1340,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 @@ -1219,13 +1361,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(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:"); @@ -3346,6 +3517,8 @@ bool System::uploadFirmwareURL(const char * url) { return false; // error } + OtaUpdater::confirmRunningApp(); + // check we have enough space for the upload in the ota partition if (!Update.begin(firmware_size)) { LOG_ERROR("Firmware upload failed - no space"); @@ -3768,9 +3941,9 @@ void System::remove_optional_gpio(uint8_t pin) { // set unused gpios to default state input high-Z void System::reset_unused_gpios() { #if CONFIG_IDF_TARGET_ESP32 -constexpr uint8_t tx0 = 1; // don't change tx0 pin + constexpr uint8_t tx0 = 1; // don't change tx0 pin #else -constexpr uint8_t tx0 = 255; // no valid pin, chips have native USB, tx0 not connected to transceiver + constexpr uint8_t tx0 = 255; // no valid pin, chips have native USB, tx0 not connected to transceiver #endif for (const auto & pin : valid_system_gpios_) { auto it = std::find_if(used_gpios_.begin(), used_gpios_.end(), [pin](const GpioUsage & usage) { return usage.pin == pin; }); diff --git a/src/core/system.h b/src/core/system.h index 66b1c8fc0..9ed145795 100644 --- a/src/core/system.h +++ b/src/core/system.h @@ -81,6 +81,11 @@ class System { void start(); bool loop(); // returns true if the LED flash is active + static void check_crash_loop(); // call at boot once NVS is open, may restart into the factory partition + + void recovery_installed(); // called on the AsyncTCP task after the recovery firmware was written + std::string recovery_version() const; // empty if there's no recovery firmware + // commands static bool command_read(const char * value, const int8_t id); static bool command_send(const char * value, const int8_t id); @@ -370,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: @@ -385,6 +391,14 @@ class System { void set_partition_install_date(); + // firmware is confirmed to the bootloader and the crash counter cleared once it has been up this long + static constexpr uint32_t FIRMWARE_HEALTHY_UPTIME = 120; // seconds + // consecutive crashes before falling back to the recovery firmware in the factory partition + static constexpr uint8_t CRASH_LOOP_LIMIT = 5; + + bool firmware_healthy_ = false; + void check_firmware_health(); + // button static PButton myPButton_; // PButton instance static void button_OnClick(PButton & b); @@ -395,8 +409,8 @@ class System { static constexpr uint32_t BUTTON_DblClickDelay = 250; // Max period between clicks for a double click event (in ms) // button press delays - static constexpr uint32_t BUTTON_LongPressDelay = 3000; // Hold period for a long press event (in ms) - ~3 seconds - static constexpr uint32_t BUTTON_VLongPressDelay = 9500; // Hold period for a very long press event (in ms) - !10 seconds + static constexpr uint32_t BUTTON_LongPressDelay = 3000; // Hold period for a long press event (in ms) - 3 seconds + static constexpr uint32_t BUTTON_VLongPressDelay = 9500; // Hold period for a very long press event (in ms) - 9.5 seconds // healthcheck #ifdef EMSESP_PINGTEST diff --git a/src/emsesp_version.h b/src/emsesp_version.h index fc1c2c9d9..845c6c1ad 100644 --- a/src/emsesp_version.h +++ b/src/emsesp_version.h @@ -1 +1 @@ -#define EMSESP_APP_VERSION "3.9.0-dev.16" +#define EMSESP_APP_VERSION "3.9.0-dev.17" diff --git a/src/web/WebStatusService.cpp b/src/web/WebStatusService.cpp index 642d84a52..107cca941 100644 --- a/src/web/WebStatusService.cpp +++ b/src/web/WebStatusService.cpp @@ -134,10 +134,12 @@ 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(); - partition = esp_ota_get_next_update_partition(nullptr); + if (partition != NULL) { + root["recovery_version"] = EMSESP::system_.recovery_version(); + } + partition = esp_ota_get_next_update_partition(nullptr); if (partition) { uint64_t buffer; esp_partition_read(partition, 0, &buffer, 8);