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