add recovery firmware with OTA updater and recovery web interface

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
proddy
2026-10-06 22:57:25 +02:00
co-authored by Cursor
parent 1375f87163
commit 6411b6ffbd
59 changed files with 3595 additions and 339 deletions
+1
View File
@@ -1,6 +1,7 @@
node_modules/
build/
dist/
dist-recovery/
src/i18n/*
.prettierrc
+1
View File
@@ -18,6 +18,7 @@ export default defineConfig(
{
ignores: [
'dist/*',
'dist-recovery/*',
'*.mjs',
'build/*',
'*.js',
+4 -2
View File
@@ -18,6 +18,8 @@
"standalone": "concurrently -c \"auto\" \"typesafe-i18n\" \"pnpm:mock-rest\" \"vite dev\"",
"typesafe-i18n": "typesafe-i18n --no-watch",
"build-webUI": "typesafe-i18n --no-watch && vite build && node progmem-generator.js",
"recovery": "vite dev --mode recovery",
"build-recovery": "vite build --mode recovery && node progmem-generator.js --src ./dist-recovery --out ../recovery/WWWData.h --index recovery.html",
"format": "prettier -l -w '**/*.{ts,tsx,js,css,json,md}'",
"lint": "eslint . --fix",
"standalone-devcontainer": "concurrently -c \"auto\" \"typesafe-i18n\" \"pnpm:mock-rest\" \"vite --host\""
@@ -54,9 +56,9 @@
"rollup-plugin-visualizer": "^7.1.1",
"terser": "^5.51.2",
"typescript": "^6.0.3",
"typescript-eslint": "^8.71.0",
"typescript-eslint": "^8.71.1",
"vite": "^8.3.2",
"vite-plugin-imagemin": "^0.6.1"
},
"packageManager": "pnpm@12.8.1"
"packageManager": "pnpm@12.9.1"
}
+141 -141
View File
@@ -7,153 +7,153 @@ importers:
configDependencies: {}
packageManagerDependencies:
pnpm:
specifier: 12.8.1
version: 12.8.1
specifier: 12.9.1
version: 12.9.1
packages:
'@pnpm/exe.android-arm64@12.8.1':
resolution: {integrity: sha512-siDxcidfz5eM6L2tJfik+i2bbUEw8UC7MZ3Yl2HYdRmqbdhHGAOQACtOOu6QVW4Jul4/w5wVKm+wM8ACtgh1Pg==}
'@pnpm/exe.android-arm64@12.9.1':
resolution: {integrity: sha512-Hgm1XdqMTfBC8PdGLL+NjZWv4xyi7D6XNrN9fb24XGYlyTBmUtdCefKnCLmHujShRD8BEO1zqM30SNcKBDLysw==}
cpu: [arm64]
os: [android]
'@pnpm/exe.android-x64@12.8.1':
resolution: {integrity: sha512-0F/uqPRc3aN4vLHsa0f+6fTtmsigFQ9/yxEU43fionF//pNHEd5e97b3w/nKIgqYMCUQCJphmBRMA9Q8/wlo9Q==}
'@pnpm/exe.android-x64@12.9.1':
resolution: {integrity: sha512-WDGCe6jZRVZP3RC/q6hOkZylkPkIQIi1/GcrR/d/YzbihhAHXCczrk+UYnV5TgbsEnAYO7Cx+2rX3nfhHvy24w==}
cpu: [x64]
os: [android]
'@pnpm/exe.darwin-arm64@12.8.1':
resolution: {integrity: sha512-/rwavvMQJsl2RIxOHBnDF+4Gk+fhQFAY4M3PQZoKskJOEnaJcygG9p1xby6wcQcbS4d9dIubv09CQhufTgfmpw==}
'@pnpm/exe.darwin-arm64@12.9.1':
resolution: {integrity: sha512-+7iiEEBgYN9szPWUnAuN6eOVMTI6Hrki5bcuC9n9xl9tTnT53Tpxpo45Hml+W4IrAU0d+Tmd0sPnVIbX59PD6w==}
cpu: [arm64]
os: [darwin]
'@pnpm/exe.darwin-x64@12.8.1':
resolution: {integrity: sha512-htYt2gN7zqJKLhC99Ft6EUiO7myjZAZXfmGCTJUL1v+o7vE9hZdE1+fAkk4Oj1ZHNRti6b2woOxEkRkAusi8OQ==}
'@pnpm/exe.darwin-x64@12.9.1':
resolution: {integrity: sha512-SK7TxJiR12GbJZGffRFHItkc+Mx+99v6xMMJ9/X//8NwWV1wo17a2bwfGI8qZpl17YwsrmPEFOOqhzE1GIlrtQ==}
cpu: [x64]
os: [darwin]
'@pnpm/exe.freebsd-x64@12.8.1':
resolution: {integrity: sha512-PB5BULNgytxvExoOXUUeseS/DcpPENs+cg3iA6xHHeaLT4ctEGKSGXEbuj7USeqvo8JBpAx039hivnbzGqDwnA==}
'@pnpm/exe.freebsd-x64@12.9.1':
resolution: {integrity: sha512-4AjLk6uvTs2iO6wMit2hcxJ2M32xb7OV5SNFk+w6G8/NzcjcMB7ENEpyeXyLiP0kcnp8uaCGUct2NfrLCJ35eQ==}
cpu: [x64]
os: [freebsd]
'@pnpm/exe.linux-arm64-musl@12.8.1':
resolution: {integrity: sha512-kKeyC/ArjrgdciQRBrOL0j9HtOI4gDRgoyP+eJ1CQKyadpKlf/DhDDyspXe07R3B9KagTVLUF2nlt6Dr5quFIg==}
'@pnpm/exe.linux-arm64-musl@12.9.1':
resolution: {integrity: sha512-dZL2ES1ckDWM9v+EtxoOklZ0WNdtayooi9iLiDUubopudX3zXPNR2mlrxBRgjbDgECOndQen2SmyxL5eZiBuKw==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@pnpm/exe.linux-arm64@12.8.1':
resolution: {integrity: sha512-q4s9a9X2O3N9aeUFooW24orNrxvu20+jyVUFR5RanPCqOKKPBrgs2iywMkBBx1aXkkzdWT54/mfz8Be8sJlWEw==}
'@pnpm/exe.linux-arm64@12.9.1':
resolution: {integrity: sha512-cx+uF6xqwEtm7B8NV+1ve0xBg9gcuzQZ5R+0WgOWvKG/h978UljS6BgWs1ipIGuGgUdeVhm3PKmBltXIcczLXA==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@pnpm/exe.linux-ppc64@12.8.1':
resolution: {integrity: sha512-F7XSjKJthwCh/L6abZiMpAfo2Cm61SZIT3e4dfgiIjESndlWaXCnfLKngSgZ/SEMsdmHT6dud3+kWIB3tw0/xQ==}
'@pnpm/exe.linux-ppc64@12.9.1':
resolution: {integrity: sha512-MpjPsy4XR17zAtLziQZfsN+wveWa/bUJggZtwfo3+SuyRmXnSvVkFD4+v8/3RTWyYaM6mwLUTjZDJ+g3urL+ew==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@pnpm/exe.linux-riscv64@12.8.1':
resolution: {integrity: sha512-z1ecpIK/ZY08Unk69KWWE9867oaKUytGUjrHnBoGFAAhsK+q+0cfvBH+f5fWobJ7FX97leMlLO+gkJolddM45Q==}
'@pnpm/exe.linux-riscv64@12.9.1':
resolution: {integrity: sha512-Oo67TKqqH+Bno+L6X7Xyorwp1QG82zKIkL0vbo1x2kcPTDiyPbdM+d6lIs4+hzeB1KHT5FDoGzZj2dad7Ft+eg==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@pnpm/exe.linux-s390x@12.8.1':
resolution: {integrity: sha512-KU6tnrBfu4uKXTq6N5qJwSm8FSrs9Wc8b5AUEsHK9bRXwNdsZ1/lRxG1++X29SKRW9OSNuDSjxASv5mAvGo1gA==}
'@pnpm/exe.linux-s390x@12.9.1':
resolution: {integrity: sha512-QAD5FRCdW36HfD58BV7DoDm6z8yv+GgSqcUV1+ERwYjpVQuG6ZTpf5o0x3KDas8eTLIW7MLYVdDdlAurWeArEg==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@pnpm/exe.linux-x64-musl@12.8.1':
resolution: {integrity: sha512-eUE8BWJkDr2tJb/yrk0yVnszlmNqFarVxUlqiZ9BAkct28Nq/5g2H0tvMDmPA7fAQW3STNj+omJPPRVT+4s5sQ==}
'@pnpm/exe.linux-x64-musl@12.9.1':
resolution: {integrity: sha512-mQDhBsGqcTkrLQM4q5igmRFcr6EAl4UM/FGawY+0CWGcqPpUmYUvDy9LHy80xxkHIWVJ886gvvso2XOjfav1Jw==}
cpu: [x64]
os: [linux]
libc: [musl]
'@pnpm/exe.linux-x64@12.8.1':
resolution: {integrity: sha512-8bDZ0lZlCdi2rvnFul7EYgcC7zKeWSe76y8JV2oXobaS8p9i9d6PSf6LgLtTM0fI7R9Oh4eLCbveXyNDMmvZ+g==}
'@pnpm/exe.linux-x64@12.9.1':
resolution: {integrity: sha512-+M5N74hM4fc87AAckxx/UIW+dUnWf2eko+HchJs/NZUxehLhekNyMI/GNaNXlvd+kWR+97XXBD51XRkkNiUtrA==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@pnpm/exe.win32-arm64@12.8.1':
resolution: {integrity: sha512-MsT0dlrRxnCRCHCaosNbhWpEiAFnFuMEu3KuDJDU41BXgTwiINxU8goloUSUDejWZUqKaUqNC/qUTihl9nFtqg==}
'@pnpm/exe.win32-arm64@12.9.1':
resolution: {integrity: sha512-mI28C2wxobrDi6x1OYhBRyG10hnDq/4sEIyy5RzoX6LB4kLuv6K/d25HOpMwlW20cuM4M72k9NZpOdcmUxMbEA==}
cpu: [arm64]
os: [win32]
'@pnpm/exe.win32-x64@12.8.1':
resolution: {integrity: sha512-SWtPe0PsbTTk//gJND10tMdOfCYjrkq5+qV49hzVhY7xz2iJ339Rc+xwASlcvbwTfrvH39YNqpKYibD+CRGLwA==}
'@pnpm/exe.win32-x64@12.9.1':
resolution: {integrity: sha512-SWoDMT0oD7NrDSIoPpkKD09D9fAQKNjwFZ3lezlCUhAfXxMv70zsfrNT2/P+GoCK2kefXF9OysUZkH1l5vvvfA==}
cpu: [x64]
os: [win32]
pnpm@12.8.1:
resolution: {integrity: sha512-9kupB1B/XOr+BsjTjmBS0BeURFgOwSed3Vv8EctIqoomRLZlmOB+dh2oSHKp/FfV+QK4f6SdAkGY1VhhKqu+RQ==}
pnpm@12.9.1:
resolution: {integrity: sha512-BrBV//XNINwSeB3hc87TMQzglRvy7GICEaFgRdVETVyTpmMhB2TvKaZTphBFm6A2jw50+E2AxUcjz5Wq57wNbQ==}
engines: {node: '>=18.*'}
hasBin: true
snapshots:
'@pnpm/exe.android-arm64@12.8.1':
'@pnpm/exe.android-arm64@12.9.1':
optional: true
'@pnpm/exe.android-x64@12.8.1':
'@pnpm/exe.android-x64@12.9.1':
optional: true
'@pnpm/exe.darwin-arm64@12.8.1':
'@pnpm/exe.darwin-arm64@12.9.1':
optional: true
'@pnpm/exe.darwin-x64@12.8.1':
'@pnpm/exe.darwin-x64@12.9.1':
optional: true
'@pnpm/exe.freebsd-x64@12.8.1':
'@pnpm/exe.freebsd-x64@12.9.1':
optional: true
'@pnpm/exe.linux-arm64-musl@12.8.1':
'@pnpm/exe.linux-arm64-musl@12.9.1':
optional: true
'@pnpm/exe.linux-arm64@12.8.1':
'@pnpm/exe.linux-arm64@12.9.1':
optional: true
'@pnpm/exe.linux-ppc64@12.8.1':
'@pnpm/exe.linux-ppc64@12.9.1':
optional: true
'@pnpm/exe.linux-riscv64@12.8.1':
'@pnpm/exe.linux-riscv64@12.9.1':
optional: true
'@pnpm/exe.linux-s390x@12.8.1':
'@pnpm/exe.linux-s390x@12.9.1':
optional: true
'@pnpm/exe.linux-x64-musl@12.8.1':
'@pnpm/exe.linux-x64-musl@12.9.1':
optional: true
'@pnpm/exe.linux-x64@12.8.1':
'@pnpm/exe.linux-x64@12.9.1':
optional: true
'@pnpm/exe.win32-arm64@12.8.1':
'@pnpm/exe.win32-arm64@12.9.1':
optional: true
'@pnpm/exe.win32-x64@12.8.1':
'@pnpm/exe.win32-x64@12.9.1':
optional: true
pnpm@12.8.1:
pnpm@12.9.1:
optionalDependencies:
'@pnpm/exe.android-arm64': 12.8.1
'@pnpm/exe.android-x64': 12.8.1
'@pnpm/exe.darwin-arm64': 12.8.1
'@pnpm/exe.darwin-x64': 12.8.1
'@pnpm/exe.freebsd-x64': 12.8.1
'@pnpm/exe.linux-arm64': 12.8.1
'@pnpm/exe.linux-arm64-musl': 12.8.1
'@pnpm/exe.linux-ppc64': 12.8.1
'@pnpm/exe.linux-riscv64': 12.8.1
'@pnpm/exe.linux-s390x': 12.8.1
'@pnpm/exe.linux-x64': 12.8.1
'@pnpm/exe.linux-x64-musl': 12.8.1
'@pnpm/exe.win32-arm64': 12.8.1
'@pnpm/exe.win32-x64': 12.8.1
'@pnpm/exe.android-arm64': 12.9.1
'@pnpm/exe.android-x64': 12.9.1
'@pnpm/exe.darwin-arm64': 12.9.1
'@pnpm/exe.darwin-x64': 12.9.1
'@pnpm/exe.freebsd-x64': 12.9.1
'@pnpm/exe.linux-arm64': 12.9.1
'@pnpm/exe.linux-arm64-musl': 12.9.1
'@pnpm/exe.linux-ppc64': 12.9.1
'@pnpm/exe.linux-riscv64': 12.9.1
'@pnpm/exe.linux-s390x': 12.9.1
'@pnpm/exe.linux-x64': 12.9.1
'@pnpm/exe.linux-x64-musl': 12.9.1
'@pnpm/exe.win32-arm64': 12.9.1
'@pnpm/exe.win32-x64': 12.9.1
---
lockfileVersion: '9.0'
@@ -255,8 +255,8 @@ importers:
specifier: ^6.0.3
version: 6.0.3
typescript-eslint:
specifier: ^8.71.0
version: 8.71.0(eslint@10.12.0(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)
specifier: ^8.71.1
version: 8.71.1(eslint@10.12.0(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)
vite:
specifier: ^8.3.2
version: 8.3.2(@types/node@26.6.4)(terser@5.51.2)
@@ -870,63 +870,63 @@ packages:
'@types/svgo@2.6.4':
resolution: {integrity: sha512-l4cmyPEckf8moNYHdJ+4wkHvFxjyW6ulm9l4YGaOxeyBWPhBOT0gvni1InpFPdzx1dKf/2s62qGITwxNWnPQng==}
'@typescript-eslint/eslint-plugin@8.71.0':
resolution: {integrity: sha512-pqcS9c1HxZTHt7End4nXqd0s5lJrrFzrgCkKFJrsbUnaL6M3+6oBFZaslg6Gjsl3argl2DDRFROnXARaZ2e4Nw==}
'@typescript-eslint/eslint-plugin@8.71.1':
resolution: {integrity: sha512-nNlBf6HuotqMgZvSkWD5hNdemiBIxQ2NmrlgfcUIeZRs+BbakjscCQRq/Hb74EFVnFiSWWdL12JruqwBy3ERDw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
'@typescript-eslint/parser': ^8.71.0
'@typescript-eslint/parser': ^8.71.1
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/parser@8.71.0':
resolution: {integrity: sha512-CG4nPk1f2zc8yw4pALqHsFYH2hdo+h1T9daSp21+Hnxi9LOE3GT9hAfTKJCBXVNM2GmYs1eMEP615wPoeOgk3A==}
'@typescript-eslint/parser@8.71.1':
resolution: {integrity: sha512-oM/5sAqz/l1v/zYK1qTOPrn2+JIrBGC1V6uNYucRO5gh6eT9vWJ/RhPbGE59byvZEUQ4VP6XkLBaS5Jti5jpvw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/project-service@8.71.0':
resolution: {integrity: sha512-aABjw5rjBacYONVPaPiWOCjJu0vEF4a25iQuodlmQYL1trtLZ0X/y+2Vzl3BKI1odM4LnwLE1oUDXYp1wzx1TQ==}
'@typescript-eslint/project-service@8.71.1':
resolution: {integrity: sha512-Oijc9RUsohHjncg6iGi7gCtjnEjYt9FWv9u6ygK8uKcHK5eLjLAkqERQ9BaytbpceOFdk6kifPCS/zAZE+A99g==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/scope-manager@8.71.0':
resolution: {integrity: sha512-gWF0BhUcnjZxSpLE8ngS/59n2SB0J3YqRxvX1+2aoRJk9hNtHSLOV+TcarFiOr5ipXm3yc1QrI4c9YZc8zyCxw==}
'@typescript-eslint/scope-manager@8.71.1':
resolution: {integrity: sha512-1YUDZXdTnXLlob03SFHBipdyMBsDVm7zoSz0ytTJ39dk5J1TgsTK03VlR/dLXG/RLLUrPcYbD4rp23BmHxHP0w==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/tsconfig-utils@8.71.0':
resolution: {integrity: sha512-Z1UlWHADEK2Mlb9NpWfDeSjqoZ5EyrOv4R3eQpbkzqn/EwaIdOpXXupEA1+0ZIOSJSZZDBHG0BrQyN8zUG6Pwg==}
'@typescript-eslint/tsconfig-utils@8.71.1':
resolution: {integrity: sha512-oTvkml5SxXhgg+WWKhod5qWQICDXtOB+/Hi9VdPKFAANJ2b7YOgMffgvzXuqog7R6A/JtrzRf128dsxummyWhg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/type-utils@8.71.0':
resolution: {integrity: sha512-i8uO1qbdxeKgRnS5sCRt6On3/nfo2d2DwQe3Yvjx543zLy7r8ySqRuPPiIIXAhS03U0v5NfAFx+rUgxFzKKwNw==}
'@typescript-eslint/type-utils@8.71.1':
resolution: {integrity: sha512-kH3t3jZCYv2PZSBEWPKTariLrCaoF1CLoPitYFBJOhAGEwWnSRAtGEifrfrpBa4K9JlwWNZotgA5w3/wLSk/FQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/types@8.71.0':
resolution: {integrity: sha512-cJ4OoxPGWvFnBTnSZyaU+qJzGTqPTGJY+gDchj6cRyLRdmIdt4rcsE4twj+zPfrNiWuVi38wijHzShL++Z9atQ==}
'@typescript-eslint/types@8.71.1':
resolution: {integrity: sha512-ybYnPTUwg3VQQOFEiBPLs8Jxl1ry2vXdn1kVKguomBbvxpetdu3MKTNDqFNUrlG8ylP9MpZosmXMFYlGQHk1oQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/typescript-estree@8.71.0':
resolution: {integrity: sha512-PEEF4G5sLLWAS5BpPrUvms4ySZkiBQQZM4z+3ReI46axK5Vqr/vXBQatJQIZZOYdGyPUAKTtsrWzpqKuU+3DEw==}
'@typescript-eslint/typescript-estree@8.71.1':
resolution: {integrity: sha512-pSKSEK1JJpHs6KiKVtABCE4iJK5d7FLFzDXQNBQ2oRY7LEd3UwOt3TZemTgu9syTIf56dJWlTn/pdmrO9oKUXQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/utils@8.71.0':
resolution: {integrity: sha512-pKR/tEMVrXZG23UFKUn5BQf3zfmfk7KQceI2cGzywZ5nxM5Eu3hEJU1utjWzydtzBbcJAQhHN8iPCxobHpPcZQ==}
'@typescript-eslint/utils@8.71.1':
resolution: {integrity: sha512-CBjT6gfAz3DW2j9Q4moFnqt208Aq+506sVGXfpRcyAeqn0EpayL0mjdWoCO2t3RC7GewNrFxdeehuEaPg6DMzQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/visitor-keys@8.71.0':
resolution: {integrity: sha512-8eQ9R218XORK+KLosnf4bu/QsUXvUyVwTbArg7/0NMB1Pu87OJKvj4nhFblkYE8gQV73mW1dx1ptlPCkwRGa7A==}
'@typescript-eslint/visitor-keys@8.71.1':
resolution: {integrity: sha512-0GxiUGqMU0qXJt58cBsiG3DP8lUmBaFo2vChtUX+CT7PnR3v/VGLLrzVP4K9r1lNiZXjHYefMBbPunplr09v6w==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
acorn-jsx@5.3.2:
@@ -934,8 +934,8 @@ packages:
peerDependencies:
acorn: ^6.0.0 || ^7.0.0 || ^8.0.0
acorn@8.18.0:
resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==}
acorn@8.19.0:
resolution: {integrity: sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==}
engines: {node: '>=0.4.0'}
hasBin: true
@@ -2304,8 +2304,8 @@ packages:
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
nanoid@3.3.19:
resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==}
nanoid@3.3.20:
resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
@@ -2523,8 +2523,8 @@ packages:
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
engines: {node: '>= 0.4'}
postcss@8.5.28:
resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==}
postcss@8.5.29:
resolution: {integrity: sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==}
engines: {node: ^10 || ^12 || >=14}
powershell-utils@0.2.1:
@@ -2993,8 +2993,8 @@ packages:
peerDependencies:
typescript: '>=3.5.1'
typescript-eslint@8.71.0:
resolution: {integrity: sha512-fBdHYiqQ14RW6mOMXD14Svn82ZsCYAoQSzGRzyEjR59S5A2Krh/l7fGTOQ7iCr8gGy/mHVXtEF7s5fgjEdV0Pw==}
typescript-eslint@8.71.1:
resolution: {integrity: sha512-oiNVPC3/NbV1FpaHU07l8O6ynqG9v4PAiW2WvTrAmZdR0f2HI/gE668ElMLCK7Xbsu5k2fK5C4fXNiq05j7/nQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
@@ -3791,14 +3791,14 @@ snapshots:
dependencies:
'@types/node': 26.6.4
'@typescript-eslint/eslint-plugin@8.71.0(@typescript-eslint/parser@8.71.0(eslint@10.12.0(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.12.0(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)':
'@typescript-eslint/eslint-plugin@8.71.1(@typescript-eslint/parser@8.71.1(eslint@10.12.0(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.12.0(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
'@typescript-eslint/parser': 8.71.0(eslint@10.12.0(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)
'@typescript-eslint/scope-manager': 8.71.0
'@typescript-eslint/type-utils': 8.71.0(eslint@10.12.0(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)
'@typescript-eslint/utils': 8.71.0(eslint@10.12.0(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)
'@typescript-eslint/visitor-keys': 8.71.0
'@typescript-eslint/parser': 8.71.1(eslint@10.12.0(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)
'@typescript-eslint/scope-manager': 8.71.1
'@typescript-eslint/type-utils': 8.71.1(eslint@10.12.0(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)
'@typescript-eslint/utils': 8.71.1(eslint@10.12.0(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)
'@typescript-eslint/visitor-keys': 8.71.1
eslint: 10.12.0(supports-color@10.2.2)
ignore: 7.0.12
natural-compare: 1.4.0
@@ -3807,41 +3807,41 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/parser@8.71.0(eslint@10.12.0(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)':
'@typescript-eslint/parser@8.71.1(eslint@10.12.0(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)':
dependencies:
'@typescript-eslint/scope-manager': 8.71.0
'@typescript-eslint/types': 8.71.0
'@typescript-eslint/typescript-estree': 8.71.0(supports-color@10.2.2)(typescript@6.0.3)
'@typescript-eslint/visitor-keys': 8.71.0
'@typescript-eslint/scope-manager': 8.71.1
'@typescript-eslint/types': 8.71.1
'@typescript-eslint/typescript-estree': 8.71.1(supports-color@10.2.2)(typescript@6.0.3)
'@typescript-eslint/visitor-keys': 8.71.1
debug: 4.4.3(supports-color@10.2.2)
eslint: 10.12.0(supports-color@10.2.2)
typescript: 6.0.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/project-service@8.71.0(supports-color@10.2.2)(typescript@6.0.3)':
'@typescript-eslint/project-service@8.71.1(supports-color@10.2.2)(typescript@6.0.3)':
dependencies:
'@typescript-eslint/tsconfig-utils': 8.71.0(typescript@6.0.3)
'@typescript-eslint/types': 8.71.0
'@typescript-eslint/tsconfig-utils': 8.71.1(typescript@6.0.3)
'@typescript-eslint/types': 8.71.1
debug: 4.4.3(supports-color@10.2.2)
typescript: 6.0.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/scope-manager@8.71.0':
'@typescript-eslint/scope-manager@8.71.1':
dependencies:
'@typescript-eslint/types': 8.71.0
'@typescript-eslint/visitor-keys': 8.71.0
'@typescript-eslint/types': 8.71.1
'@typescript-eslint/visitor-keys': 8.71.1
'@typescript-eslint/tsconfig-utils@8.71.0(typescript@6.0.3)':
'@typescript-eslint/tsconfig-utils@8.71.1(typescript@6.0.3)':
dependencies:
typescript: 6.0.3
'@typescript-eslint/type-utils@8.71.0(eslint@10.12.0(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)':
'@typescript-eslint/type-utils@8.71.1(eslint@10.12.0(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)':
dependencies:
'@typescript-eslint/types': 8.71.0
'@typescript-eslint/typescript-estree': 8.71.0(supports-color@10.2.2)(typescript@6.0.3)
'@typescript-eslint/utils': 8.71.0(eslint@10.12.0(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)
'@typescript-eslint/types': 8.71.1
'@typescript-eslint/typescript-estree': 8.71.1(supports-color@10.2.2)(typescript@6.0.3)
'@typescript-eslint/utils': 8.71.1(eslint@10.12.0(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)
debug: 4.4.3(supports-color@10.2.2)
eslint: 10.12.0(supports-color@10.2.2)
ts-api-utils: 2.5.0(typescript@6.0.3)
@@ -3849,14 +3849,14 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/types@8.71.0': {}
'@typescript-eslint/types@8.71.1': {}
'@typescript-eslint/typescript-estree@8.71.0(supports-color@10.2.2)(typescript@6.0.3)':
'@typescript-eslint/typescript-estree@8.71.1(supports-color@10.2.2)(typescript@6.0.3)':
dependencies:
'@typescript-eslint/project-service': 8.71.0(supports-color@10.2.2)(typescript@6.0.3)
'@typescript-eslint/tsconfig-utils': 8.71.0(typescript@6.0.3)
'@typescript-eslint/types': 8.71.0
'@typescript-eslint/visitor-keys': 8.71.0
'@typescript-eslint/project-service': 8.71.1(supports-color@10.2.2)(typescript@6.0.3)
'@typescript-eslint/tsconfig-utils': 8.71.1(typescript@6.0.3)
'@typescript-eslint/types': 8.71.1
'@typescript-eslint/visitor-keys': 8.71.1
debug: 4.4.3(supports-color@10.2.2)
minimatch: 10.2.6
semver: 7.8.5
@@ -3866,27 +3866,27 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/utils@8.71.0(eslint@10.12.0(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)':
'@typescript-eslint/utils@8.71.1(eslint@10.12.0(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)':
dependencies:
'@eslint-community/eslint-utils': 4.10.1(eslint@10.12.0(supports-color@10.2.2))
'@typescript-eslint/scope-manager': 8.71.0
'@typescript-eslint/types': 8.71.0
'@typescript-eslint/typescript-estree': 8.71.0(supports-color@10.2.2)(typescript@6.0.3)
'@typescript-eslint/scope-manager': 8.71.1
'@typescript-eslint/types': 8.71.1
'@typescript-eslint/typescript-estree': 8.71.1(supports-color@10.2.2)(typescript@6.0.3)
eslint: 10.12.0(supports-color@10.2.2)
typescript: 6.0.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/visitor-keys@8.71.0':
'@typescript-eslint/visitor-keys@8.71.1':
dependencies:
'@typescript-eslint/types': 8.71.0
'@typescript-eslint/types': 8.71.1
eslint-visitor-keys: 5.0.1
acorn-jsx@5.3.2(acorn@8.18.0):
acorn-jsx@5.3.2(acorn@8.19.0):
dependencies:
acorn: 8.18.0
acorn: 8.19.0
acorn@8.18.0: {}
acorn@8.19.0: {}
ajv@6.15.0:
dependencies:
@@ -4536,8 +4536,8 @@ snapshots:
espree@11.2.0:
dependencies:
acorn: 8.18.0
acorn-jsx: 5.3.2(acorn@8.18.0)
acorn: 8.19.0
acorn-jsx: 5.3.2(acorn@8.19.0)
eslint-visitor-keys: 5.0.1
esquery@1.7.0:
@@ -5296,7 +5296,7 @@ snapshots:
ms@2.1.3: {}
nanoid@3.3.19: {}
nanoid@3.3.20: {}
natural-compare@1.4.0: {}
@@ -5493,9 +5493,9 @@ snapshots:
possible-typed-array-names@1.1.0: {}
postcss@8.5.28:
postcss@8.5.29:
dependencies:
nanoid: 3.3.19
nanoid: 3.3.20
picocolors: 1.1.1
source-map-js: 1.2.2
@@ -5874,7 +5874,7 @@ snapshots:
terser@5.51.2:
dependencies:
'@jridgewell/source-map': 0.3.11
acorn: 8.18.0
acorn: 8.19.0
commander: 2.20.3
source-map-support: 0.5.21
@@ -5931,12 +5931,12 @@ snapshots:
dependencies:
typescript: 6.0.3
typescript-eslint@8.71.0(eslint@10.12.0(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3):
typescript-eslint@8.71.1(eslint@10.12.0(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3):
dependencies:
'@typescript-eslint/eslint-plugin': 8.71.0(@typescript-eslint/parser@8.71.0(eslint@10.12.0(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.12.0(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)
'@typescript-eslint/parser': 8.71.0(eslint@10.12.0(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)
'@typescript-eslint/typescript-estree': 8.71.0(supports-color@10.2.2)(typescript@6.0.3)
'@typescript-eslint/utils': 8.71.0(eslint@10.12.0(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)
'@typescript-eslint/eslint-plugin': 8.71.1(@typescript-eslint/parser@8.71.1(eslint@10.12.0(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.12.0(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)
'@typescript-eslint/parser': 8.71.1(eslint@10.12.0(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)
'@typescript-eslint/typescript-estree': 8.71.1(supports-color@10.2.2)(typescript@6.0.3)
'@typescript-eslint/utils': 8.71.1(eslint@10.12.0(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)
eslint: 10.12.0(supports-color@10.2.2)
typescript: 6.0.3
transitivePeerDependencies:
@@ -6025,7 +6025,7 @@ snapshots:
dependencies:
lightningcss: 1.33.0
picomatch: 4.0.7
postcss: 8.5.28
postcss: 8.5.29
rolldown: 1.2.12
tinyglobby: 0.2.17
optionalDependencies:
+13 -3
View File
@@ -10,10 +10,17 @@ import mime from 'mime-types';
import { relative, resolve, sep } from 'path';
import zlib from 'zlib';
// optional args: --src <dir> --out <file> --index <html file served as /index.html>
const getArg = (name, fallback) => {
const i = process.argv.indexOf(name);
return i !== -1 && process.argv[i + 1] ? process.argv[i + 1] : fallback;
};
const ARDUINO_INCLUDES = '#include <Arduino.h>\n\n';
const INDENT = ' ';
const outputPath = '../src/ESP32React/WWWData.h';
const sourcePath = './dist';
const outputPath = getArg('--out', '../src/ESP32React/WWWData.h');
const sourcePath = getArg('--src', './dist');
const indexFile = getArg('--index', 'index.html');
const bytesPerLine = 20;
let totalSize = 0;
let bundleStats = {
@@ -129,7 +136,10 @@ const writeFile = (relativeFilePath, buffer) => {
bundleStats[fileType].compressed += zipBuffer.length;
fileInfo.push({
uri: '/' + relativeFilePath.replace(sep, '/'),
uri:
relativeFilePath === indexFile
? '/index.html'
: '/' + relativeFilePath.replace(sep, '/'),
mimeType,
variable,
size,
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" />
<link rel="stylesheet" href="/css/roboto.css" />
<title>EMS-ESP Recovery</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/recovery/index.tsx"></script>
</body>
</html>
+19 -1
View File
@@ -71,6 +71,7 @@ interface VersionData {
build_flags?: string;
partition: string;
partitions: PartitionData[];
recovery_version?: string; // only on boards with a factory partition, empty if not installed
developer_mode: boolean;
disable_reset: boolean;
}
@@ -721,6 +722,19 @@ const Version = () => {
</Typography>
</Typography>
</Grid>
{data.recovery_version !== undefined && (
<>
<Grid size={{ xs: 4, md: 2 }}>
<Typography color="secondary">{LL.RECOVERY()}</Typography>
</Grid>
<Grid size={{ xs: 8, md: 10 }}>
<Typography>
{data.recovery_version || LL.RECOVERY_NOT_INSTALLED()}
</Typography>
</Grid>
</>
)}
</Grid>
{internetLive ? (
@@ -848,7 +862,11 @@ const Version = () => {
<Typography sx={{ pt: 2, pb: 2 }} variant="h6" color="primary">
{LL.UPLOAD()}
</Typography>
<SingleUpload text={LL.UPLOAD_FIRMWARE_TEXT()} doRestart={doRestart} />
<SingleUpload
text={LL.UPLOAD_FIRMWARE_TEXT()}
doRestart={doRestart}
onRecoveryInstalled={() => void loadData()}
/>
</>
)}
</Box>
@@ -14,10 +14,15 @@ import { LinearProgressWithLabel } from './LinearProgressWithLabel';
interface SingleUploadProps {
doRestart: () => void;
onRecoveryInstalled?: () => void;
text: string;
}
const SingleUpload = ({ doRestart, text }: SingleUploadProps) => {
const SingleUpload = ({
doRestart,
onRecoveryInstalled,
text
}: SingleUploadProps) => {
const [md5, setMd5] = useState<string>();
const [file, setFile] = useState<File>();
const { LL } = useI18nContext();
@@ -42,6 +47,12 @@ const SingleUpload = ({ doRestart, text }: SingleUploadProps) => {
setMd5((payload as { md5: string }).md5);
toast.success(LL.UPLOAD_MD5_RECEIVED());
setFile(undefined);
} else if (payload && typeof payload === 'object' && 'recovery' in payload) {
// written to the factory partition, EMS-ESP keeps running
toast.success(LL.RECOVERY_INSTALLED());
setMd5(undefined);
setFile(undefined);
onRecoveryInstalled?.();
} else {
if (payload && typeof payload === 'object' && 'md5_ok' in payload) {
toast.success(LL.UPLOAD_MD5_MATCHED());
+4 -1
View File
@@ -352,7 +352,10 @@ const cs: Translation = {
UPLOAD_BACKUP_TEXT: 'Klikněte, abyste vybrali, nebo drop soubor zálohy (.json) sem',
UPLOAD_FIRMWARE_TEXT: 'Klikněte, abyste vybrali, nebo drop soubor firmware .bin/.md5 sem',
UPLOAD_MD5_RECEIVED: 'Digest přijat, nyní nahrajte firmware .bin',
UPLOAD_MD5_MATCHED: 'MD5 souhlasí, firmware se instaluje...'
UPLOAD_MD5_MATCHED: 'MD5 souhlasí, firmware se instaluje...',
RECOVERY: 'Obnova',
RECOVERY_NOT_INSTALLED: 'Nenainstalováno',
RECOVERY_INSTALLED: 'Firmware pro obnovu nainstalován'
};
export default cs;
+4 -1
View File
@@ -352,7 +352,10 @@ const de: Translation = {
UPLOAD_BACKUP_TEXT: 'Klicken Sie, um eine Sicherungsdatei (.json) auszuwählen, oder drop ein',
UPLOAD_FIRMWARE_TEXT: 'Klicken Sie, um eine Firmware-Datei (.bin/.md5) auszuwählen, oder drop ein',
UPLOAD_MD5_RECEIVED: 'Prüfsumme empfangen, jetzt die Firmware-Datei (.bin) hochladen',
UPLOAD_MD5_MATCHED: 'MD5 stimmt überein, Firmware wird installiert...'
UPLOAD_MD5_MATCHED: 'MD5 stimmt überein, Firmware wird installiert...',
RECOVERY: 'Wiederherstellung',
RECOVERY_NOT_INSTALLED: 'Nicht installiert',
RECOVERY_INSTALLED: 'Wiederherstellungs-Firmware installiert'
};
export default de;
+4 -1
View File
@@ -352,7 +352,10 @@ const en: Translation = {
UPLOAD_BACKUP_TEXT: 'Click to select, or drop a backup file (.json) here',
UPLOAD_FIRMWARE_TEXT: 'Click to select, or drop a firmware .bin/.md5 file here',
UPLOAD_MD5_RECEIVED: 'Digest received, now upload the firmware .bin file',
UPLOAD_MD5_MATCHED: 'MD5 matches, installing firmware...'
UPLOAD_MD5_MATCHED: 'MD5 matches, installing firmware...',
RECOVERY: 'Recovery',
RECOVERY_NOT_INSTALLED: 'Not installed',
RECOVERY_INSTALLED: 'Recovery firmware installed'
};
export default en;
+4 -1
View File
@@ -352,7 +352,10 @@ const fr: Translation = {
UPLOAD_BACKUP_TEXT: 'Cliquez pour sélectionner, ou drop un fichier de sauvegarde (.json) ici',
UPLOAD_FIRMWARE_TEXT: 'Cliquez pour sélectionner, ou drop un fichier firmware .bin/.md5 ici',
UPLOAD_MD5_RECEIVED: 'Empreinte reçue, téléversez maintenant le fichier firmware .bin',
UPLOAD_MD5_MATCHED: 'MD5 concordant, installation du firmware...'
UPLOAD_MD5_MATCHED: 'MD5 concordant, installation du firmware...',
RECOVERY: 'Récupération',
RECOVERY_NOT_INSTALLED: 'Non installé',
RECOVERY_INSTALLED: 'Firmware de récupération installé'
};
export default fr;
+4 -1
View File
@@ -352,7 +352,10 @@ const it: Translation = {
UPLOAD_BACKUP_TEXT: 'Klik per selezionare, o drop un file di backup (.json) qui',
UPLOAD_FIRMWARE_TEXT: 'Klik per selezionare, o drop un file firmware .bin/.md5 qui',
UPLOAD_MD5_RECEIVED: 'Digest ricevuto, ora caricare il file firmware .bin',
UPLOAD_MD5_MATCHED: 'MD5 corrispondente, installazione firmware...'
UPLOAD_MD5_MATCHED: 'MD5 corrispondente, installazione firmware...',
RECOVERY: 'Ripristino',
RECOVERY_NOT_INSTALLED: 'Non installato',
RECOVERY_INSTALLED: 'Firmware di ripristino installato'
};
export default it;
+4 -1
View File
@@ -352,7 +352,10 @@ const nl: Translation = {
UPLOAD_BACKUP_TEXT: 'Klik voor selecteren, of drop een back-upbestand (.json) hier',
UPLOAD_FIRMWARE_TEXT: 'Klik voor selecteren, of drop een firmware .bin/.md5 bestand hier',
UPLOAD_MD5_RECEIVED: 'Digest ontvangen, upload nu het firmware .bin-bestand',
UPLOAD_MD5_MATCHED: 'MD5 komt overeen, firmware wordt geïnstalleerd...'
UPLOAD_MD5_MATCHED: 'MD5 komt overeen, firmware wordt geïnstalleerd...',
RECOVERY: 'Herstel',
RECOVERY_NOT_INSTALLED: 'Niet geïnstalleerd',
RECOVERY_INSTALLED: 'Herstelfirmware geïnstalleerd'
};
export default nl;
+4 -1
View File
@@ -352,7 +352,10 @@ const no: Translation = {
UPLOAD_BACKUP_TEXT: 'Klikk for å velge, eller drop en sikkerhetskopi (.json) her',
UPLOAD_FIRMWARE_TEXT: 'Klikk for å velge, eller drop en firmware .bin/.md5 her',
UPLOAD_MD5_RECEIVED: 'MD5 mottatt, last nå opp firmware .bin-filen',
UPLOAD_MD5_MATCHED: 'MD5 stemmer, firmware installeres...'
UPLOAD_MD5_MATCHED: 'MD5 stemmer, firmware installeres...',
RECOVERY: 'Gjenoppretting',
RECOVERY_NOT_INSTALLED: 'Ikke installert',
RECOVERY_INSTALLED: 'Gjenopprettingsfirmware installert'
};
export default no;
+4 -1
View File
@@ -352,7 +352,10 @@ const pl: BaseTranslation = {
UPLOAD_BACKUP_TEXT: 'Kliknij, aby wybrać, lub drop plik kopii zapasowej (.json) tutaj',
UPLOAD_FIRMWARE_TEXT: 'Kliknij, aby wybrać, lub drop plik firmware .bin/.md5 tutaj',
UPLOAD_MD5_RECEIVED: 'Suma MD5 odebrana, teraz wgraj plik firmware .bin',
UPLOAD_MD5_MATCHED: 'MD5 zgodne, instalowanie firmware...'
UPLOAD_MD5_MATCHED: 'MD5 zgodne, instalowanie firmware...',
RECOVERY: 'Odzyskiwanie',
RECOVERY_NOT_INSTALLED: 'Nie zainstalowano',
RECOVERY_INSTALLED: 'Zainstalowano firmware odzyskiwania'
};
export default pl;
+4 -1
View File
@@ -352,7 +352,10 @@ const sk: Translation = {
UPLOAD_BACKUP_TEXT: 'Kliknite, aby ste vybrali, alebo drop súbor zálohy (.json) sem',
UPLOAD_FIRMWARE_TEXT: 'Kliknite, aby ste vybrali, alebo drop súbor firmware .bin/.md5 sem',
UPLOAD_MD5_RECEIVED: 'Suma MD5 prijatá, teraz nahrajte firmware .bin',
UPLOAD_MD5_MATCHED: 'MD5 súhlasí, firmvér sa inštaluje...'
UPLOAD_MD5_MATCHED: 'MD5 súhlasí, firmvér sa inštaluje...',
RECOVERY: 'Obnova',
RECOVERY_NOT_INSTALLED: 'Nenainštalované',
RECOVERY_INSTALLED: 'Firmvér obnovy nainštalovaný'
};
export default sk;
+4 -1
View File
@@ -352,7 +352,10 @@ const sv: Translation = {
UPLOAD_BACKUP_TEXT: 'Klicka för att välja, eller drop en säkerhetskopia (.json) här',
UPLOAD_FIRMWARE_TEXT: 'Klicka för att välja, eller drop en firmware .bin/.md5 här',
UPLOAD_MD5_RECEIVED: 'MD5 mottagen, ladda nu upp firmware .bin-filen',
UPLOAD_MD5_MATCHED: 'MD5 stämmer, firmware installeras...'
UPLOAD_MD5_MATCHED: 'MD5 stämmer, firmware installeras...',
RECOVERY: 'Återställning',
RECOVERY_NOT_INSTALLED: 'Inte installerad',
RECOVERY_INSTALLED: 'Återställningsfirmware installerad'
};
export default sv;
+4 -1
View File
@@ -352,7 +352,10 @@ const tr: Translation = {
UPLOAD_BACKUP_TEXT: 'Bir yedek dosyası (.json), buraya drop yapın',
UPLOAD_FIRMWARE_TEXT: 'Bir firmware .bin dosyası, buraya drop yapın',
UPLOAD_MD5_RECEIVED: 'Özet alındı, şimdi firmware .bin dosyasını yükleyin',
UPLOAD_MD5_MATCHED: 'MD5 eşleşti, firmware yükleniyor...'
UPLOAD_MD5_MATCHED: 'MD5 eşleşti, firmware yükleniyor...',
RECOVERY: 'Kurtarma',
RECOVERY_NOT_INSTALLED: 'Yüklü değil',
RECOVERY_INSTALLED: 'Kurtarma firmware yüklendi'
};
export default tr;
+380
View File
@@ -0,0 +1,380 @@
import { type ReactNode, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel';
import HealthAndSafetyIcon from '@mui/icons-material/HealthAndSafety';
import LogoutIcon from '@mui/icons-material/Logout';
import MemoryIcon from '@mui/icons-material/Memory';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import RestartAltIcon from '@mui/icons-material/RestartAlt';
import {
Avatar,
Box,
Button,
Chip,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Divider,
List,
ListItem,
ListItemAvatar,
ListItemText,
MenuItem,
TextField,
Typography
} from '@mui/material';
import { dialogStyle } from 'CustomTheme';
import ButtonRow from 'components/ButtonRow';
import MessageBox from 'components/MessageBox';
import SectionContent from 'components/SectionContent';
import { toast } from 'components/toast';
import FirmwareUpload from './FirmwareUpload';
import { HttpError, restart, setBootPartition } from './api';
import type { RecoveryPartition, RecoveryStatus } from './types';
const formatMB = (bytes: number) => `${(bytes / 1048576).toFixed(2)} MB`;
const formatUptime = (seconds: number) => {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
return `${h}h ${m}m ${s}s`;
};
const ETH_STATE_TEXT: Record<
Exclude<RecoveryStatus['eth_state'], undefined | 'connected'>,
string
> = {
disabled: 'disabled, the clock mode conflicts with PSRAM',
failed: 'failed to start, no PHY found',
no_link: 'no cable connected',
connecting: 'waiting for an IP address'
};
const failedToStart = (p: RecoveryPartition) =>
p.ota_state === 'aborted' || p.ota_state === 'invalid';
// why the device is running the recovery, if it can be worked out
const recoveryReason = (status: RecoveryStatus, failed: RecoveryPartition[]) => {
if (status.reason === 'crash') {
return 'EMS-ESP crashed repeatedly and switched to the recovery.';
}
if (status.reason === 'request') {
return undefined; // started on purpose from EMS-ESP, e.g. a long press of the button
}
if (failed.length > 0) {
return `The firmware in ${failed.map((p) => p.label).join(' and ')} failed to start.`;
}
if (status.reset_reason === 'panic' || status.reset_reason === 'watchdog') {
return 'EMS-ESP crashed while starting.';
}
return undefined;
};
const SectionTitle = ({ children }: { children: ReactNode }) => (
<Typography sx={{ pb: 1 }} variant="h6" color="primary">
{children}
</Typography>
);
const InfoRow = ({ label, value }: { label: string; value: ReactNode }) => (
<Box sx={{ display: 'flex', py: 0.5 }}>
<Typography sx={{ width: 160, flexShrink: 0 }} color="text.secondary">
{label}
</Typography>
<Typography>{value}</Typography>
</Box>
);
interface DashboardProps {
status: RecoveryStatus;
onRestarting: (target: string) => void;
onSignOut: () => void;
}
const Dashboard = ({ status, onRestarting, onSignOut }: DashboardProps) => {
const [confirmBoot, setConfirmBoot] = useState<RecoveryPartition>();
const [pickedTarget, setPickedTarget] = useState<string>();
const uploadTarget = pickedTarget ?? status.upload_target;
const handleError = (error: unknown) => {
if (error instanceof HttpError && error.status === 401) {
toast.warning('Session expired, please sign in again');
onSignOut();
} else {
toast.error((error as Error).message);
}
};
const doBoot = async (partition: RecoveryPartition) => {
setConfirmBoot(undefined);
try {
await setBootPartition(partition.label);
onRestarting(partition.label);
} catch (error) {
if (error instanceof HttpError && error.status === 400) {
toast.error(`${partition.label} does not contain a bootable firmware`);
} else {
handleError(error);
}
}
};
const doRestart = async (target: string) => {
try {
await restart();
onRestarting(target);
} catch (error) {
handleError(error);
}
};
const otaPartitions = status.partitions.filter((p) => !p.factory);
const hasFirmware = otaPartitions.some((p) => p.valid);
const reason = recoveryReason(
status,
otaPartitions.filter((p) => p.valid && failedToStart(p))
);
return (
<>
<SectionContent>
{reason && <MessageBox sx={{ mb: 2 }} level="error" message={reason} />}
<MessageBox level={hasFirmware ? 'info' : 'warning'}>
{hasFirmware
? 'This device is running the EMS-ESP Recovery. Start one of the installed firmwares, or upload a new one.'
: 'No EMS-ESP firmware is installed. Upload a firmware to continue.'}
</MessageBox>
</SectionContent>
<SectionContent>
<SectionTitle>Firmware</SectionTitle>
<List dense>
{status.partitions.map((p, index) => (
<Box key={p.label}>
{index > 0 && <Divider component="li" />}
<ListItem
secondaryAction={
!p.factory &&
p.valid && (
<Button
startIcon={<PlayArrowIcon />}
variant="outlined"
color="primary"
size="small"
onClick={() => setConfirmBoot(p)}
>
Start
</Button>
)
}
>
<ListItemAvatar>
<Avatar
sx={{
bgcolor: p.factory
? '#5d89f7'
: p.valid
? '#2e586a'
: '#5f5f5f'
}}
>
{p.factory ? <HealthAndSafetyIcon /> : <MemoryIcon />}
</Avatar>
</ListItemAvatar>
<ListItemText
primary={
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
flexWrap: 'wrap'
}}
>
<span>{p.factory ? `Recovery (${p.label})` : p.label}</span>
{p.label === status.running && (
<Chip size="small" color="success" label="running" />
)}
{p.label === status.boot && p.label !== status.running && (
<Chip size="small" color="secondary" label="next boot" />
)}
{p.label === uploadTarget && (
<Chip
size="small"
variant="outlined"
label="upload target"
/>
)}
{!p.valid && (
<Chip
size="small"
color="warning"
variant="outlined"
label="empty"
/>
)}
{p.valid && failedToStart(p) && (
<Chip size="small" color="error" label="failed to start" />
)}
{p.valid && p.ota_state === 'pending' && (
<Chip
size="small"
color="warning"
variant="outlined"
label="unconfirmed"
/>
)}
</Box>
}
secondary={
p.valid
? `${p.version ? 'v' + p.version + ' · ' : ''}${formatMB(p.image_size)} of ${formatMB(p.size)}`
: formatMB(p.size)
}
/>
</ListItem>
</Box>
))}
</List>
</SectionContent>
<SectionContent>
<SectionTitle>Upload Firmware</SectionTitle>
<Typography sx={{ pb: 2 }} variant="body2" color="text.secondary">
The firmware is started automatically after the upload. To enable the MD5
check, upload the matching .md5 file first.
</Typography>
<TextField
select
size="small"
sx={{ mb: 2, minWidth: 260 }}
label="Write to"
value={uploadTarget}
onChange={(e) => setPickedTarget(e.target.value)}
>
{otaPartitions.map((p) => (
<MenuItem key={p.label} value={p.label}>
{p.label}
{p.valid
? ` (replaces ${p.version ? 'v' + p.version : 'installed firmware'})`
: ' (empty)'}
</MenuItem>
))}
</TextField>
<FirmwareUpload
target={uploadTarget}
chip={status.chip}
onUploaded={() => void doRestart(uploadTarget)}
onUnauthorized={onSignOut}
/>
</SectionContent>
<SectionContent>
<SectionTitle>System</SectionTitle>
<InfoRow label="Recovery version" value={'v' + status.recovery_version} />
<InfoRow
label="Chip"
value={`${status.chip}, ${formatMB(status.flash_size)} flash${status.psram_size ? ', ' + formatMB(status.psram_size) + ' PSRAM' : ''}`}
/>
<InfoRow
label="Free heap"
value={`${Math.round(status.free_heap / 1024)} KB`}
/>
<InfoRow label="Uptime" value={formatUptime(status.uptime)} />
<InfoRow label="MAC address" value={status.mac} />
<InfoRow
label="Access point"
value={`${status.ap_ssid} on ${status.ap_ip} (${status.ap_clients} connected)`}
/>
{status.eth_state && (
<InfoRow
label="Ethernet"
value={
status.eth_state === 'connected'
? `connected, ${status.eth_ip ?? ''}${status.static_ip ? ' (static IP)' : ''}`
: ETH_STATE_TEXT[status.eth_state]
}
/>
)}
<InfoRow
label="WiFi"
value={
!status.wifi_ssid
? 'not configured'
: status.wifi_connected
? `${status.wifi_ssid}, ${status.wifi_ip ?? ''} (${status.wifi_rssi ?? 0} dBm)${status.static_ip ? ', static IP' : ''}`
: !status.wifi_started
? status.eth_state === 'connected'
? 'not used, Ethernet is connected'
: 'waiting for Ethernet'
: `connecting to ${status.wifi_ssid}`
}
/>
<InfoRow label="Hostname" value={status.hostname} />
<ButtonRow>
<Button
startIcon={<RestartAltIcon />}
variant="outlined"
color="error"
onClick={() => void doRestart(status.boot)}
>
Restart{status.boot !== status.running ? ` (boots ${status.boot})` : ''}
</Button>
<Button
startIcon={<LogoutIcon />}
variant="outlined"
color="secondary"
onClick={onSignOut}
>
Sign Out
</Button>
</ButtonRow>
</SectionContent>
<Dialog
sx={dialogStyle}
open={!!confirmBoot}
onClose={() => setConfirmBoot(undefined)}
>
<DialogTitle>Start Firmware</DialogTitle>
<DialogContent dividers>
Restart and run
{confirmBoot?.version ? ` EMS-ESP v${confirmBoot.version}` : ''} from{' '}
{confirmBoot?.label}?
{confirmBoot && failedToStart(confirmBoot) && (
<MessageBox
sx={{ mt: 2 }}
level="warning"
message="This firmware failed to start before. If it fails again the device falls back to the other firmware, or to the recovery."
/>
)}
</DialogContent>
<DialogActions>
<Button
startIcon={<CancelIcon />}
variant="outlined"
color="secondary"
onClick={() => setConfirmBoot(undefined)}
>
Cancel
</Button>
<Button
startIcon={<PlayArrowIcon />}
variant="outlined"
color="primary"
onClick={() => confirmBoot && void doBoot(confirmBoot)}
>
Start
</Button>
</DialogActions>
</Dialog>
</>
);
};
export default Dashboard;
+208
View File
@@ -0,0 +1,208 @@
import { type ChangeEvent, type DragEvent, useRef, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel';
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
import UploadIcon from '@mui/icons-material/Upload';
import { Box, Button, Typography, styled } from '@mui/material';
import { toast } from 'components/toast';
import { LinearProgressWithLabel } from 'components/upload/LinearProgressWithLabel';
import { HttpError, type UploadHandle, uploadFile } from './api';
const DocumentUploader = styled(Box)<{ active?: boolean }>(({ theme, active }) => ({
border: `2px dashed ${active ? '#6dc24b' : '#4282fe'}`,
backgroundColor: '#2e3339',
padding: theme.spacing(1.25),
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
borderRadius: theme.spacing(1),
cursor: 'pointer',
minHeight: '120px',
transition: 'border-color 0.2s ease-in-out'
}));
const uploadErrorMessage = (status: number, chip: string) => {
switch (status) {
case 400:
return 'Invalid target partition';
case 401:
case 403:
return 'Not authorized, please sign in again';
case 409:
return 'This is the recovery firmware, it can only be installed from EMS-ESP';
case 503:
return `This firmware is not built for the ${chip}`;
case 507:
return 'The firmware does not fit in the partition';
case 500:
return 'Upload failed, flash write error or MD5 mismatch';
default:
return 'Upload failed. Only EMS-ESP firmware .bin and .md5 files are accepted';
}
};
interface FirmwareUploadProps {
target: string;
chip: string;
onUploaded: () => void;
onUnauthorized: () => void;
}
const FirmwareUpload = ({
target,
chip,
onUploaded,
onUnauthorized
}: FirmwareUploadProps) => {
const [file, setFile] = useState<File>();
const [dragged, setDragged] = useState(false);
const [md5, setMd5] = useState<string>();
const [progress, setProgress] = useState<number>();
const uploadRef = useRef<UploadHandle | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const selectFile = (selected?: File) => {
if (!selected) return;
if (/\.(bin|md5)$/i.test(selected.name)) {
setFile(selected);
} else {
toast.warning('Please select an EMS-ESP firmware .bin or .md5 file');
}
};
const onFileChange = (e: ChangeEvent<HTMLInputElement>) => {
selectFile(e.target.files?.[0]);
e.target.value = ''; // allow the same file to be selected again
};
const onDrop = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
setDragged(false);
selectFile(e.dataTransfer.files[0]);
};
const startUpload = async () => {
if (!file) return;
setProgress(0);
uploadRef.current = uploadFile(file, target, setProgress);
try {
const result = await uploadRef.current.promise;
if (result.md5) {
setMd5(result.md5);
toast.success('MD5 received, now upload the firmware .bin');
} else {
if (result.md5_ok) {
toast.success('Firmware MD5 matches');
}
toast.success(`Firmware written to ${target}`);
onUploaded();
}
} catch (error) {
const status = error instanceof HttpError ? error.status : 0;
if (status === -1) {
toast.warning('Upload aborted');
} else {
toast.error(uploadErrorMessage(status, chip));
if (status === 401 || status === 403) onUnauthorized();
}
} finally {
uploadRef.current = null;
setProgress(undefined);
setFile(undefined);
}
};
if (progress !== undefined) {
return (
<>
<Typography sx={{ mb: 1 }}>
Uploading {file?.name} to {target}&hellip;
</Typography>
<LinearProgressWithLabel value={progress} />
<Button
sx={{ mt: 2 }}
startIcon={<CancelIcon />}
variant="outlined"
color="secondary"
onClick={() => uploadRef.current?.abort()}
>
Cancel
</Button>
</>
);
}
return (
<>
<DocumentUploader
active={!!(file || dragged)}
onDrop={onDrop}
onDragOver={(e) => {
e.preventDefault();
setDragged(true);
}}
onDragLeave={() => setDragged(false)}
onClick={() => inputRef.current?.click()}
>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<CloudUploadIcon sx={{ mr: 4 }} color="primary" fontSize="large" />
<Typography>
{md5
? 'MD5 received. Now drop the firmware .bin file here, or click to browse'
: 'Drop an EMS-ESP firmware .bin file here, or click to browse'}
</Typography>
</Box>
<input
type="file"
hidden
ref={inputRef}
accept=".bin,.md5"
onChange={onFileChange}
style={{ display: 'none' }}
/>
{file && (
<>
<Typography sx={{ fontSize: 14, color: '#6dc24b', my: 1 }}>
{file.name}
</Typography>
<Box>
<Button
startIcon={<CancelIcon />}
variant="outlined"
color="secondary"
onClick={(e) => {
e.stopPropagation();
setFile(undefined);
}}
>
Cancel
</Button>
<Button
sx={{ ml: 2 }}
startIcon={<UploadIcon />}
variant="outlined"
color="primary"
onClick={(e) => {
e.stopPropagation();
void startUpload();
}}
>
Upload
</Button>
</Box>
</>
)}
</DocumentUploader>
{md5 && (
<Typography sx={{ mt: 2 }} variant="body2" color="success">
{'MD5: ' + md5}
</Typography>
)}
</>
);
};
export default FirmwareUpload;
+132
View File
@@ -0,0 +1,132 @@
import { useCallback, useEffect, useState } from 'react';
import RefreshIcon from '@mui/icons-material/Refresh';
import {
AppBar,
Box,
Button,
CircularProgress,
Toolbar,
Typography
} from '@mui/material';
import CustomTheme from 'CustomTheme';
import MessageBox from 'components/MessageBox';
import SectionContent from 'components/SectionContent';
import { Toaster } from 'components/toast';
import Dashboard from './Dashboard';
import RestartMonitor from './RestartMonitor';
import SignIn from './SignIn';
import { getToken, readStatus, setToken } from './api';
import type { RecoveryStatus } from './types';
const STATUS_REFRESH_MS = 5000;
const RecoveryApp = () => {
const [status, setStatus] = useState<RecoveryStatus>();
const [error, setError] = useState<string>();
const [restartTarget, setRestartTarget] = useState<string>();
const loadStatus = useCallback(async () => {
try {
const data = await readStatus();
if (!data.authenticated && getToken()) {
setToken(null); // stale token, e.g. after the device restarted
}
setStatus(data);
setError(undefined);
} catch (e) {
setError((e as Error).message);
}
}, []);
useEffect(() => {
void loadStatus();
}, [loadStatus]);
useEffect(() => {
if (!status?.authenticated || restartTarget !== undefined) return;
const timer = setInterval(() => void loadStatus(), STATUS_REFRESH_MS);
return () => clearInterval(timer);
}, [status?.authenticated, restartTarget, loadStatus]);
const signOut = () => {
setToken(null);
void loadStatus();
};
let content;
if (restartTarget !== undefined && status) {
content = (
<RestartMonitor
target={restartTarget}
viaAccessPoint={window.location.hostname === status.ap_ip}
hostname={status.hostname}
/>
);
} else if (!status) {
content = (
<SectionContent>
{error ? (
<MessageBox level="error" message={error}>
<Button
sx={{ ml: 2 }}
startIcon={<RefreshIcon />}
variant="contained"
color="error"
onClick={() => void loadStatus()}
>
Retry
</Button>
</MessageBox>
) : (
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}>
<CircularProgress size={100} />
</Box>
)}
</SectionContent>
);
} else if (!status.authenticated) {
content = <SignIn onSignedIn={() => void loadStatus()} />;
} else {
content = (
<>
<AppBar
position="fixed"
sx={{ boxShadow: 'none', backgroundColor: '#2e586a' }}
>
<Toolbar>
<img
src="/app/icon.png"
alt="EMS-ESP"
style={{ height: 32, marginRight: 16 }}
/>
<Typography variant="h6">EMS-ESP</Typography>
<Typography variant="h6" sx={{ color: '#90caf9' }}>
&nbsp;&nbsp;|&nbsp;&nbsp;
</Typography>
<Typography variant="h6">Recovery</Typography>
</Toolbar>
</AppBar>
<Box component="main" sx={{ maxWidth: 900, mx: 'auto' }}>
<Toolbar />
<Dashboard
status={status}
onRestarting={setRestartTarget}
onSignOut={signOut}
/>
</Box>
</>
);
}
return (
<CustomTheme>
{content}
<Toaster />
</CustomTheme>
);
};
export default RecoveryApp;
+149
View File
@@ -0,0 +1,149 @@
import { useEffect, useState } from 'react';
import RefreshIcon from '@mui/icons-material/Refresh';
import { Box, Button, LinearProgress, Typography } from '@mui/material';
import MessageBox from 'components/MessageBox';
import { STATUS_URL } from './api';
const POLL_INTERVAL_MS = 1000;
const POLL_TIMEOUT_MS = 3000;
const FIRST_POLL_DELAY_MS = 1500;
// reload anyway if the device never appeared to go down
const NO_DOWNTIME_RELOAD_MS = 20000;
const GIVE_UP_MS = 90000;
interface RestartMonitorProps {
target: string;
viaAccessPoint: boolean;
hostname: string;
}
// Waits for the device to go down and come back, then reloads the page. Both the recovery app
// and EMS-ESP answer on STATUS_URL (EMS-ESP serves its WebUI for unknown GETs).
const RestartMonitor = ({
target,
viaAccessPoint,
hostname
}: RestartMonitorProps) => {
const [gaveUp, setGaveUp] = useState(false);
useEffect(() => {
const started = Date.now();
let seenDown = false;
let timer: ReturnType<typeof setTimeout>;
let cancelled = false;
const poll = async () => {
let up: boolean;
try {
const response = await fetch(STATUS_URL, {
cache: 'no-store',
signal: AbortSignal.timeout(POLL_TIMEOUT_MS)
});
up = response.ok;
} catch {
up = false;
}
if (cancelled) return;
const elapsed = Date.now() - started;
if (!up) {
seenDown = true;
} else if (seenDown || elapsed > NO_DOWNTIME_RELOAD_MS) {
window.location.reload();
return;
}
if (elapsed > GIVE_UP_MS) {
setGaveUp(true);
return;
}
timer = setTimeout(() => void poll(), POLL_INTERVAL_MS);
};
timer = setTimeout(() => void poll(), FIRST_POLL_DELAY_MS);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, []);
return (
<Box
sx={{
position: 'fixed',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<Box
sx={{
width: '30%',
minWidth: '300px',
maxWidth: '500px',
backgroundColor: '#393939',
border: 2,
borderColor: '#565656',
borderRadius: '8px',
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.3)',
p: 3,
display: 'flex',
alignItems: 'center',
flexDirection: 'column'
}}
>
<img
src="/app/icon.png"
alt="EMS-ESP"
style={{ width: '40px', height: '40px', marginBottom: '16px' }}
/>
<Typography sx={{ textAlign: 'center' }} variant="h6">
Restarting{target ? ` from ${target}` : ''}
</Typography>
{gaveUp ? (
<>
<MessageBox sx={{ mt: 2 }} level="warning">
The device has not come back on this address.
{viaAccessPoint &&
` If you were connected to the recovery access point, reconnect to your normal network and open http://${hostname}.local`}
</MessageBox>
<Button
sx={{ mt: 2 }}
startIcon={<RefreshIcon />}
variant="outlined"
onClick={() => window.location.reload()}
>
Reload
</Button>
</>
) : (
<>
<Typography sx={{ mt: 2, textAlign: 'center' }} variant="h6">
Please wait&hellip;
</Typography>
<Box sx={{ width: '100%', mt: 2 }}>
<LinearProgress />
</Box>
{viaAccessPoint && (
<Typography
sx={{ mt: 2, textAlign: 'center' }}
variant="body2"
color="text.secondary"
>
EMS-ESP does not use the recovery access point. If this page does not
reload, reconnect to your normal network and open http://{hostname}
.local
</Typography>
)}
</>
)}
</Box>
</Box>
);
};
export default RestartMonitor;
+142
View File
@@ -0,0 +1,142 @@
import { type KeyboardEvent, useState } from 'react';
import ForwardIcon from '@mui/icons-material/Forward';
import VisibilityIcon from '@mui/icons-material/Visibility';
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
import {
Box,
Button,
IconButton,
InputAdornment,
Paper,
TextField,
Typography
} from '@mui/material';
import type { Theme } from '@mui/material/styles';
import { toast } from 'components/toast';
import { HttpError, setToken, signIn } from './api';
interface SignInProps {
onSignedIn: () => void;
}
const SignIn = ({ onSignedIn }: SignInProps) => {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [processing, setProcessing] = useState(false);
const doSignIn = async () => {
if (!username || !password) {
toast.warning('Please enter the admin username and password');
return;
}
setProcessing(true);
try {
const { access_token } = await signIn(username, password);
setToken(access_token);
onSignedIn();
} catch (error) {
if (error instanceof HttpError && error.status === 401) {
toast.warning('Invalid login details');
} else {
toast.error('Error: ' + (error as Error).message);
}
setProcessing(false);
}
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Enter') {
void doSignIn();
}
};
return (
<Box
sx={(theme: Theme) => ({
display: 'flex',
height: '100vh',
margin: 'auto',
padding: 2,
justifyContent: 'center',
flexDirection: 'column',
maxWidth: theme.breakpoints.values.sm
})}
>
<Paper
sx={(theme) => ({
textAlign: 'center',
padding: theme.spacing(2),
paddingTop: '172px',
backgroundImage: 'url("/app/icon.png")',
backgroundRepeat: 'no-repeat',
backgroundPosition: '50% ' + theme.spacing(2),
width: '100%'
})}
>
<Typography variant="h4">EMS-ESP</Typography>
<Typography sx={{ mb: 2 }} variant="h6" color="secondary">
Recovery
</Typography>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: 1,
alignItems: 'center'
}}
>
<TextField
disabled={processing}
sx={{ width: '32ch' }}
name="username"
label="Username"
value={username}
onChange={(e) => setUsername(e.target.value)}
slotProps={{ input: { autoCapitalize: 'none', autoCorrect: 'off' } }}
/>
<TextField
disabled={processing}
sx={{ width: '32ch' }}
name="password"
label="Password"
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={onKeyDown}
slotProps={{
input: {
endAdornment: (
<InputAdornment position="end">
<IconButton
onClick={() => setShowPassword(!showPassword)}
edge="end"
>
{showPassword ? <VisibilityIcon /> : <VisibilityOffIcon />}
</IconButton>
</InputAdornment>
)
}
}}
/>
</Box>
<Button
variant="contained"
color="primary"
sx={{ mt: 2 }}
onClick={doSignIn}
disabled={processing}
>
<ForwardIcon sx={{ mr: 1 }} />
Sign In
</Button>
</Paper>
</Box>
);
};
export default SignIn;
+107
View File
@@ -0,0 +1,107 @@
import type { RecoveryStatus, UploadResult } from './types';
const TOKEN_KEY = 'recovery_token';
export const getToken = (): string | null => sessionStorage.getItem(TOKEN_KEY);
export const setToken = (token: string | null): void => {
if (token) {
sessionStorage.setItem(TOKEN_KEY, token);
} else {
sessionStorage.removeItem(TOKEN_KEY);
}
};
export class HttpError extends Error {
readonly status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
}
}
const authHeaders = (): Record<string, string> => {
const token = getToken();
return token ? { Authorization: `Bearer ${token}` } : {};
};
const request = async <T>(
method: string,
url: string,
body?: unknown
): Promise<T> => {
const init: RequestInit = { method, headers: authHeaders(), cache: 'no-store' };
if (body !== undefined) {
init.headers = { ...authHeaders(), 'Content-Type': 'application/json' };
init.body = JSON.stringify(body);
}
const response = await fetch(url, init);
if (!response.ok) {
throw new HttpError(
response.status,
response.statusText || `HTTP ${response.status}`
);
}
const text = await response.text();
return (text ? JSON.parse(text) : undefined) as T;
};
export const STATUS_URL = '/rest/recovery/status';
export const readStatus = () => request<RecoveryStatus>('GET', STATUS_URL);
export const signIn = (username: string, password: string) =>
request<{ access_token: string }>('POST', '/rest/signIn', { username, password });
export const setBootPartition = (partition: string) =>
request<void>('POST', '/rest/recovery/boot', { partition });
export const restart = () => request<void>('POST', '/rest/recovery/restart');
export interface UploadHandle {
promise: Promise<UploadResult>;
abort: () => void;
}
// XHR instead of fetch, for upload progress events
export const uploadFile = (
file: File,
partition: string,
onProgress: (percent: number) => void
): UploadHandle => {
const xhr = new XMLHttpRequest();
const promise = new Promise<UploadResult>((resolve, reject) => {
xhr.upload.onprogress = (event) => {
if (event.lengthComputable && event.total > 0) {
onProgress(Math.round((event.loaded * 100) / event.total));
}
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
resolve(
(xhr.responseText ? JSON.parse(xhr.responseText) : {}) as UploadResult
);
} catch {
resolve({});
}
} else {
reject(new HttpError(xhr.status, xhr.statusText));
}
};
// the device closes the connection on unsupported files, so there is no status code
xhr.onerror = () => reject(new HttpError(0, 'Connection closed'));
xhr.onabort = () => reject(new HttpError(-1, 'Aborted'));
});
const formData = new FormData();
formData.append('file', file);
xhr.open('POST', `/rest/uploadFile?partition=${encodeURIComponent(partition)}`);
for (const [key, value] of Object.entries(authHeaders())) {
xhr.setRequestHeader(key, value);
}
xhr.send(formData);
return { promise, abort: () => xhr.abort() };
};
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import RecoveryApp from './RecoveryApp';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<RecoveryApp />
</StrictMode>
);
+54
View File
@@ -0,0 +1,54 @@
export interface RecoveryPartition {
label: string;
factory: boolean;
address: number;
size: number;
valid: boolean;
image_size: number;
version: string;
install_date: number;
// bootloader rollback state, empty if the partition has never been selected
ota_state: '' | 'new' | 'pending' | 'valid' | 'invalid' | 'aborted';
}
export interface RecoveryStatus {
recovery_version: string;
authenticated: boolean;
hostname: string;
chip: string;
flash_size: number;
psram_size: number;
free_heap: number;
mac: string;
uptime: number;
reason: '' | 'crash' | 'request'; // why EMS-ESP switched to the recovery
reset_reason:
| 'power_on'
| 'external'
| 'software'
| 'panic'
| 'watchdog'
| 'brownout'
| 'other';
ap_ssid: string;
ap_ip: string;
ap_clients: number;
wifi_ssid: string;
wifi_started: boolean; // false while waiting for Ethernet, or when Ethernet is connected
wifi_connected: boolean;
wifi_ip?: string;
wifi_rssi?: number;
static_ip: boolean;
// only on boards with Ethernet configured
eth_state?: 'disabled' | 'failed' | 'no_link' | 'connecting' | 'connected';
eth_ip?: string;
running: string;
boot: string;
upload_target: string;
partitions: RecoveryPartition[];
}
export interface UploadResult {
md5?: string;
md5_ok?: boolean;
}
+1
View File
@@ -52,6 +52,7 @@ export interface SystemStatus {
model: string;
board: string;
has_loader: boolean;
recovery_version?: string;
has_partition: boolean;
partitions: {
partition: string;
+60
View File
@@ -12,6 +12,7 @@ const KB_DIVISOR = 1024;
const REPEAT_CHAR = '=';
const REPEAT_COUNT = 50;
const DEFAULT_OUT_DIR = 'dist';
const RECOVERY_OUT_DIR = 'dist-recovery';
const ES_TARGET = 'es2020';
const CHUNK_SIZE_WARNING_LIMIT = 1024;
const ASSETS_INLINE_LIMIT = 4096;
@@ -205,6 +206,65 @@ const imageOptimizationPlugin = {
export default defineConfig(
async ({ command, mode }: { command: string; mode: string }) => {
if (command === 'serve' && mode === 'recovery') {
console.log('Preparing Recovery WebUI with mock server');
const recoveryMockUrl = pathToFileURL(
path.resolve(import.meta.dirname, '../mock-api/recoveryMock.js')
).href;
const { default: recoveryMock } = (await import(
/* @vite-ignore */ recoveryMockUrl
)) as { default: () => PluginOption };
return {
plugins: [...createBasePlugins(true, false), recoveryMock()],
resolve: {
alias: RESOLVE_ALIASES,
extensions: RESOLVE_EXTENSIONS,
tsconfigPaths: true
},
server: {
open: '/recovery.html',
port: 3001
}
};
}
if (mode === 'recovery') {
console.log('Preparing for Recovery WebUI, optimized build');
return {
plugins: [...createBasePlugins(false, true), imageOptimizationPlugin],
resolve: {
alias: RESOLVE_ALIASES,
extensions: RESOLVE_EXTENSIONS,
tsconfigPaths: true
},
build: {
...createBaseBuildConfig(),
outDir: RECOVERY_OUT_DIR,
emptyOutDir: true,
minify: 'terser' as const,
terserOptions: createProductionTerserOptions(),
rollupOptions: {
input: path.resolve(import.meta.dirname, 'recovery.html'),
checks: {
pluginTimings: false
},
treeshake: {
moduleSideEffects: false,
propertyReadSideEffects: false as const,
unknownGlobalSideEffects: false
},
output: {
chunkFileNames: 'assets/[name]-[hash].js',
entryFileNames: 'assets/[name]-[hash].js',
assetFileNames: 'assets/[name]-[hash].[ext]',
manualChunks,
sourcemap: false
}
}
}
};
}
if (command === 'serve') {
console.log(`Preparing for standalone build with server, mode=${mode}`);
const mockServerUrl = pathToFileURL(