diff --git a/.github/workflows/pages-preview.yml b/.github/workflows/pages-preview.yml
index 1615ade..2171d03 100644
--- a/.github/workflows/pages-preview.yml
+++ b/.github/workflows/pages-preview.yml
@@ -14,17 +14,17 @@ permissions:
jobs:
deploy-preview:
- name: Build & deploy /preview from main
+ name: Build & deploy /preview from ${{ github.ref_name }}
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- - name: Checkout main
+ - name: Checkout selected branch
uses: actions/checkout@v5
with:
- ref: main
+ ref: ${{ github.ref }}
fetch-depth: 0
- name: Setup Node
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4e3911a..4aa1cd5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,14 @@
# Changelog
+## 1.1.22
+### Improvement
+- Added Vietnamese translations. Thanks to ngantrandev for the contribution ([PR #176](https://github.com/thelastoutpostworkshop/ESPConnect/pull/176)).
+
+### Fixed
+- Backported ESP32-P4 revision 3.2 flash power initialization and updated flasher stub, with one UnixTight reset retry on stub failure for external USB-serial connections ([issue #175](https://github.com/thelastoutpostworkshop/ESPConnect/issues/175)).
+- Added ESP32-S31 compatibility support to tasmota-webserial-esptool v7.3.10, including the S31 stub and corrected flash-register addresses ([issue #172](https://github.com/thelastoutpostworkshop/ESPConnect/issues/172)).
+- Session log now identifies the active ESP32-S31 and ESP32-P4 revision 3.2 compatibility patches.
+
## 1.1.21
### Improvement
- Added Spanish translations. Thanks to Cayetano97 for the contribution ([PR #171](https://github.com/thelastoutpostworkshop/ESPConnect/pull/171)).
diff --git a/package-lock.json b/package-lock.json
index b8fbe58..4aeeb36 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,17 +1,18 @@
{
"name": "espconnect",
- "version": "1.1.21",
+ "version": "1.1.22",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "espconnect",
- "version": "1.1.21",
+ "version": "1.1.22",
+ "hasInstallScript": true,
"license": "MIT",
"dependencies": {
"@mdi/font": "^7.4.47",
"electron-squirrel-startup": "^1.0.1",
- "tasmota-webserial-esptool": "^7.3.7",
+ "tasmota-webserial-esptool": "7.3.10",
"vue": "^3.5.34",
"vue-i18n": "^11.4.4",
"vuetify": "^4.0.7"
@@ -29,6 +30,7 @@
"@types/node": "^26.1.1",
"@vitejs/plugin-vue": "^6.0.7",
"electron": "^43.2.0",
+ "patch-package": "^8.0.1",
"sass": "^1.100.0",
"typescript": "^5.9.3",
"vite": "^8.0.14",
@@ -2642,6 +2644,13 @@
"dev": true,
"license": "Apache-2.0"
},
+ "node_modules/@yarnpkg/lockfile": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz",
+ "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==",
+ "dev": true,
+ "license": "BSD-2-Clause"
+ },
"node_modules/abbrev": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
@@ -3177,6 +3186,56 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/call-bind": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
+ "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "get-intrinsic": "^1.3.0",
+ "set-function-length": "^1.2.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/caniuse-lite": {
"version": "1.0.30001810",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz",
@@ -3268,6 +3327,22 @@
"node": ">=6.0"
}
},
+ "node_modules/ci-info": {
+ "version": "3.9.0",
+ "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz",
+ "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/sibiraj-s"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/clean-stack": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz",
@@ -3611,7 +3686,6 @@
"integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
"dev": true,
"license": "MIT",
- "optional": true,
"dependencies": {
"es-define-property": "^1.0.0",
"es-errors": "^1.3.0",
@@ -3685,6 +3759,21 @@
"tn1150": "^0.1.0"
}
},
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/eastasianwidth": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
@@ -4387,7 +4476,6 @@
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"dev": true,
"license": "MIT",
- "optional": true,
"engines": {
"node": ">= 0.4"
}
@@ -4409,6 +4497,19 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/es-object-atoms": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
+ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/es6-error": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz",
@@ -4768,6 +4869,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/find-yarn-workspace-root": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz",
+ "integrity": "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "micromatch": "^4.0.2"
+ }
+ },
"node_modules/flora-colossus": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/flora-colossus/-/flora-colossus-2.0.0.tgz",
@@ -4950,6 +5061,31 @@
"get-folder-size": "bin/get-folder-size"
}
},
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/get-package-info": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/get-package-info/-/get-package-info-1.0.0.tgz",
@@ -4983,6 +5119,20 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/get-stream": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz",
@@ -5090,7 +5240,6 @@
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"dev": true,
"license": "MIT",
- "optional": true,
"engines": {
"node": ">= 0.4"
},
@@ -5147,7 +5296,6 @@
"integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
"dev": true,
"license": "MIT",
- "optional": true,
"dependencies": {
"es-define-property": "^1.0.0"
},
@@ -5155,6 +5303,19 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/hasown": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
@@ -5400,6 +5561,22 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/is-docker": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz",
+ "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "is-docker": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@@ -5517,6 +5694,26 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/is-wsl": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
+ "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-docker": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/isarray": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
+ "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/isbinaryfile": {
"version": "4.0.10",
"resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz",
@@ -5592,6 +5789,26 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/json-stable-stringify": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz",
+ "integrity": "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.4",
+ "isarray": "^2.0.5",
+ "jsonify": "^0.0.1",
+ "object-keys": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/json-stringify-safe": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
@@ -5613,6 +5830,16 @@
"graceful-fs": "^4.1.6"
}
},
+ "node_modules/jsonify": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz",
+ "integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==",
+ "dev": true,
+ "license": "Public Domain",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/jsonpointer": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-5.0.1.tgz",
@@ -5644,6 +5871,16 @@
"json-buffer": "3.0.1"
}
},
+ "node_modules/klaw-sync": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz",
+ "integrity": "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.1.11"
+ }
+ },
"node_modules/lightningcss": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
@@ -6257,6 +6494,16 @@
"node": ">=10"
}
},
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/mem": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/mem/-/mem-4.3.0.tgz",
@@ -6756,7 +7003,6 @@
"integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
"dev": true,
"license": "MIT",
- "optional": true,
"engines": {
"node": ">= 0.4"
}
@@ -6801,6 +7047,23 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/open": {
+ "version": "7.4.2",
+ "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz",
+ "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-docker": "^2.0.0",
+ "is-wsl": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/ora": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz",
@@ -7027,6 +7290,46 @@
"node": ">=0.10.0"
}
},
+ "node_modules/patch-package": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/patch-package/-/patch-package-8.0.1.tgz",
+ "integrity": "sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@yarnpkg/lockfile": "^1.1.0",
+ "chalk": "^4.1.2",
+ "ci-info": "^3.7.0",
+ "cross-spawn": "^7.0.3",
+ "find-yarn-workspace-root": "^2.0.0",
+ "fs-extra": "^10.0.0",
+ "json-stable-stringify": "^1.0.2",
+ "klaw-sync": "^6.0.0",
+ "minimist": "^1.2.6",
+ "open": "^7.4.2",
+ "semver": "^7.5.3",
+ "slash": "^2.0.0",
+ "tmp": "^0.2.4",
+ "yaml": "^2.2.2"
+ },
+ "bin": {
+ "patch-package": "index.js"
+ },
+ "engines": {
+ "node": ">=14",
+ "npm": ">5"
+ }
+ },
+ "node_modules/patch-package/node_modules/tmp": {
+ "version": "0.2.7",
+ "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz",
+ "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.14"
+ }
+ },
"node_modules/path-browserify": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
@@ -7862,6 +8165,24 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/set-function-length": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
+ "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "define-data-property": "^1.1.4",
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2",
+ "get-intrinsic": "^1.2.4",
+ "gopd": "^1.0.1",
+ "has-property-descriptors": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -7905,6 +8226,16 @@
"url": "https://github.com/sponsors/isaacs"
}
},
+ "node_modules/slash": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz",
+ "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/slice-ansi": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz",
@@ -9269,6 +9600,22 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/yaml": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
+ "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
+ "devOptional": true,
+ "license": "ISC",
+ "bin": {
+ "yaml": "bin.mjs"
+ },
+ "engines": {
+ "node": ">= 14.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/eemeli"
+ }
+ },
"node_modules/yargs": {
"version": "17.7.3",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz",
diff --git a/package.json b/package.json
index 116d4b5..41d2f7b 100644
--- a/package.json
+++ b/package.json
@@ -2,7 +2,7 @@
"name": "espconnect",
"productName": "ESPConnect",
"private": true,
- "version": "1.1.21",
+ "version": "1.1.22",
"type": "module",
"description": "Browser-based control center for ESP32 and ESP8266 boards",
"main": "electron/main.cjs",
@@ -22,12 +22,13 @@
"start": "electron-forge start",
"package": "electron-forge package",
"make": "electron-forge make",
- "publish": "electron-forge publish"
+ "publish": "electron-forge publish",
+ "postinstall": "patch-package"
},
"dependencies": {
"@mdi/font": "^7.4.47",
"electron-squirrel-startup": "^1.0.1",
- "tasmota-webserial-esptool": "^7.3.7",
+ "tasmota-webserial-esptool": "7.3.10",
"vue": "^3.5.34",
"vue-i18n": "^11.4.4",
"vuetify": "^4.0.7"
@@ -45,6 +46,7 @@
"@types/node": "^26.1.1",
"@vitejs/plugin-vue": "^6.0.7",
"electron": "^43.2.0",
+ "patch-package": "^8.0.1",
"sass": "^1.100.0",
"typescript": "^5.9.3",
"vite": "^8.0.14",
diff --git a/patches/tasmota-webserial-esptool+7.3.10.patch b/patches/tasmota-webserial-esptool+7.3.10.patch
new file mode 100644
index 0000000..9bcb47b
--- /dev/null
+++ b/patches/tasmota-webserial-esptool+7.3.10.patch
@@ -0,0 +1,178 @@
+diff --git a/node_modules/tasmota-webserial-esptool/dist/const.js b/node_modules/tasmota-webserial-esptool/dist/const.js
+--- a/node_modules/tasmota-webserial-esptool/dist/const.js
++++ b/node_modules/tasmota-webserial-esptool/dist/const.js
+@@ -197,7 +197,9 @@
+ export const ESP32P4_SPI_W0_OFFS = 0x58;
+ export const ESP32P4_UART_DATE_REG_ADDR = 0x500ca000 + 0x8c;
+ export const ESP32P4_BOOTLOADER_FLASH_OFFSET = 0x2000;
+-// Flash power-on related registers and bits needed for ECO6 (Rev 301)
++// Flash power-on related registers and bits needed for ECO6/ECO7 (Rev 301/302)
++export const ESP32P4_EFUSE_RD_REPEAT_DATA1_REG = ESP32P4_BASEFUSEADDR + 0x034;
++export const ESP32P4_EFUSE_DOWNLOAD_MODE_XPD_ON_MASK = 1 << 16;
+ export const ESP32P4_DR_REG_LPAON_BASE = 0x50110000;
+ export const ESP32P4_DR_REG_PMU_BASE = ESP32P4_DR_REG_LPAON_BASE + 0x5000;
+ export const ESP32P4_DR_REG_LP_SYS_BASE = ESP32P4_DR_REG_LPAON_BASE + 0x0;
+@@ -208,10 +210,10 @@
+ export const ESP32P4_PMU_0P1A_TARGET0_0 = 0xff << 23;
+ export const ESP32P4_PMU_0P1A_FORCE_TIEH_SEL_0 = 1 << 7;
+ export const ESP32P4_PMU_DATE_REG = ESP32P4_DR_REG_PMU_BASE + 0x3fc;
+-export const ESP32S31_SPI_REG_BASE = 0x20500000;
++export const ESP32S31_SPI_REG_BASE = 0x20501000;
+ export const ESP32S31_BASEFUSEADDR = 0x20715000;
+-export const ESP32S31_EFUSE_BLOCK1_ADDR = ESP32S31_BASEFUSEADDR + 0x044;
+-export const ESP32S31_MACFUSEADDR = 0x20715000 + 0x044;
++export const ESP32S31_EFUSE_BLOCK1_ADDR = ESP32S31_BASEFUSEADDR + 0x050;
++export const ESP32S31_MACFUSEADDR = ESP32S31_BASEFUSEADDR + 0x050;
+ export const ESP32S31_SPI_USR_OFFS = 0x18;
+ export const ESP32S31_SPI_USR1_OFFS = 0x1c;
+ export const ESP32S31_SPI_USR2_OFFS = 0x20;
+diff --git a/node_modules/tasmota-webserial-esptool/dist/esp_loader.js b/node_modules/tasmota-webserial-esptool/dist/esp_loader.js
+--- a/node_modules/tasmota-webserial-esptool/dist/esp_loader.js
++++ b/node_modules/tasmota-webserial-esptool/dist/esp_loader.js
+@@ -1,5 +1,5 @@
+ ///
+-import { CHIP_FAMILY_ESP32, CHIP_FAMILY_ESP32S2, CHIP_FAMILY_ESP32S3, CHIP_FAMILY_ESP32C2, CHIP_FAMILY_ESP32C3, CHIP_FAMILY_ESP32C5, CHIP_FAMILY_ESP32C6, CHIP_FAMILY_ESP32C61, CHIP_FAMILY_ESP32H2, CHIP_FAMILY_ESP32H4, CHIP_FAMILY_ESP32H21, CHIP_FAMILY_ESP32P4, CHIP_FAMILY_ESP32S31, CHIP_FAMILY_ESP8266, MAX_TIMEOUT, DEFAULT_TIMEOUT, ERASE_REGION_TIMEOUT_PER_MB, ESP_CHANGE_BAUDRATE, ESP_CHECKSUM_MAGIC, ESP_FLASH_BEGIN, ESP_FLASH_DATA, ESP_FLASH_END, ESP_MEM_BEGIN, ESP_MEM_DATA, ESP_MEM_END, ESP_READ_REG, ESP_WRITE_REG, ESP_SPI_ATTACH, ESP_SYNC, ESP_GET_SECURITY_INFO, FLASH_SECTOR_SIZE, FLASH_WRITE_SIZE, STUB_FLASH_WRITE_SIZE, MEM_END_ROM_TIMEOUT, ROM_INVALID_RECV_MSG, SYNC_PACKET, SYNC_TIMEOUT, USB_RAM_BLOCK, ESP_ERASE_FLASH, ESP_READ_FLASH, CHIP_ERASE_TIMEOUT, FLASH_READ_TIMEOUT, timeoutPerMb, ESP_ROM_BAUD, USB_JTAG_SERIAL_PID, ESP_FLASH_DEFL_BEGIN, ESP_FLASH_DEFL_DATA, ESP_FLASH_DEFL_END, getSpiFlashAddresses, DETECTED_FLASH_SIZES, CHIP_DETECT_MAGIC_REG_ADDR, CHIP_DETECT_MAGIC_VALUES, CHIP_ID_TO_INFO, ESP32_BASEFUSEADDR, ESP32_APB_CTL_DATE_ADDR, ESP32S2_EFUSE_BLOCK1_ADDR, ESP32S3_EFUSE_BLOCK1_ADDR, ESP32C2_EFUSE_BLOCK2_ADDR, ESP32C5_EFUSE_BLOCK1_ADDR, ESP32C6_EFUSE_BLOCK1_ADDR, ESP32C61_EFUSE_BLOCK1_ADDR, ESP32H2_EFUSE_BLOCK1_ADDR, ESP32P4_EFUSE_BLOCK1_ADDR, ESP32S31_EFUSE_BLOCK1_ADDR, ESP32C3_EFUSE_RD_MAC_SPI_SYS_3_REG, ESP32C3_EFUSE_RD_MAC_SPI_SYS_5_REG, ESP32C5_UART_CLKDIV_REG, ESP32C5_PCR_SYSCLK_CONF_REG, ESP32C5_PCR_SYSCLK_XTAL_FREQ_V, ESP32C5_PCR_SYSCLK_XTAL_FREQ_S, SlipReadError, ESP32P4_LP_SYSTEM_REG_ANA_XPD_PAD_GROUP_REG, ESP32P4_PMU_EXT_LDO_P0_0P1A_ANA_REG, ESP32P4_PMU_ANA_0P1A_EN_CUR_LIM_0, ESP32P4_PMU_EXT_LDO_P0_0P1A_REG, ESP32P4_PMU_0P1A_TARGET0_0, ESP32P4_PMU_0P1A_FORCE_TIEH_SEL_0, ESP32P4_PMU_DATE_REG, } from "./const";
++import { CHIP_FAMILY_ESP32, CHIP_FAMILY_ESP32S2, CHIP_FAMILY_ESP32S3, CHIP_FAMILY_ESP32C2, CHIP_FAMILY_ESP32C3, CHIP_FAMILY_ESP32C5, CHIP_FAMILY_ESP32C6, CHIP_FAMILY_ESP32C61, CHIP_FAMILY_ESP32H2, CHIP_FAMILY_ESP32H4, CHIP_FAMILY_ESP32H21, CHIP_FAMILY_ESP32P4, CHIP_FAMILY_ESP32S31, CHIP_FAMILY_ESP8266, MAX_TIMEOUT, DEFAULT_TIMEOUT, ERASE_REGION_TIMEOUT_PER_MB, ESP_CHANGE_BAUDRATE, ESP_CHECKSUM_MAGIC, ESP_FLASH_BEGIN, ESP_FLASH_DATA, ESP_FLASH_END, ESP_MEM_BEGIN, ESP_MEM_DATA, ESP_MEM_END, ESP_READ_REG, ESP_WRITE_REG, ESP_SPI_ATTACH, ESP_SYNC, ESP_GET_SECURITY_INFO, FLASH_SECTOR_SIZE, FLASH_WRITE_SIZE, STUB_FLASH_WRITE_SIZE, MEM_END_ROM_TIMEOUT, ROM_INVALID_RECV_MSG, SYNC_PACKET, SYNC_TIMEOUT, USB_RAM_BLOCK, ESP_ERASE_FLASH, ESP_READ_FLASH, CHIP_ERASE_TIMEOUT, FLASH_READ_TIMEOUT, timeoutPerMb, ESP_ROM_BAUD, USB_JTAG_SERIAL_PID, ESP_FLASH_DEFL_BEGIN, ESP_FLASH_DEFL_DATA, ESP_FLASH_DEFL_END, getSpiFlashAddresses, DETECTED_FLASH_SIZES, CHIP_DETECT_MAGIC_REG_ADDR, CHIP_DETECT_MAGIC_VALUES, CHIP_ID_TO_INFO, ESP32_BASEFUSEADDR, ESP32_APB_CTL_DATE_ADDR, ESP32S2_EFUSE_BLOCK1_ADDR, ESP32S3_EFUSE_BLOCK1_ADDR, ESP32C2_EFUSE_BLOCK2_ADDR, ESP32C5_EFUSE_BLOCK1_ADDR, ESP32C6_EFUSE_BLOCK1_ADDR, ESP32C61_EFUSE_BLOCK1_ADDR, ESP32H2_EFUSE_BLOCK1_ADDR, ESP32P4_EFUSE_BLOCK1_ADDR, ESP32S31_EFUSE_BLOCK1_ADDR, ESP32C3_EFUSE_RD_MAC_SPI_SYS_3_REG, ESP32C3_EFUSE_RD_MAC_SPI_SYS_5_REG, ESP32C5_UART_CLKDIV_REG, ESP32C5_PCR_SYSCLK_CONF_REG, ESP32C5_PCR_SYSCLK_XTAL_FREQ_V, ESP32C5_PCR_SYSCLK_XTAL_FREQ_S, SlipReadError, ESP32P4_EFUSE_RD_REPEAT_DATA1_REG, ESP32P4_EFUSE_DOWNLOAD_MODE_XPD_ON_MASK, ESP32P4_LP_SYSTEM_REG_ANA_XPD_PAD_GROUP_REG, ESP32P4_PMU_EXT_LDO_P0_0P1A_ANA_REG, ESP32P4_PMU_ANA_0P1A_EN_CUR_LIM_0, ESP32P4_PMU_EXT_LDO_P0_0P1A_REG, ESP32P4_PMU_0P1A_TARGET0_0, ESP32P4_PMU_0P1A_FORCE_TIEH_SEL_0, ESP32P4_PMU_DATE_REG, } from "./const";
+ import { getStubCode } from "./stubs";
+ import { hexFormatter, padTo, sleep, slipEncode, toHex } from "./util";
+ // @ts-expect-error pako ESM module doesn't have proper type definitions
+@@ -321,8 +321,8 @@
+ await this.connectWithResetStrategies();
+ // Detect chip type
+ await this.detectChip();
+- // Power on flash for ESP32-P4 Rev 301 (must be done before loading stub)
+- if (this.chipFamily === CHIP_FAMILY_ESP32P4 && this.chipRevision === 301) {
++ // Power on flash for ESP32-P4 Rev 301/302 (must be done before loading stub)
++ if (this.chipFamily === CHIP_FAMILY_ESP32P4 && (this.chipRevision === 301 || this.chipRevision === 302)) {
+ await this.powerOnFlash();
+ }
+ // Read the OTP data for this chip and store into this.efuses array
+@@ -495,18 +495,30 @@
+ return major * 100 + minor;
+ }
+ /**
+- * Power on the flash chip for ESP32-P4 Rev 301 (ECO6)
+- * The flash chip is powered off by default on ECO6, when the default flash
++ * Power on the flash chip for ESP32-P4 Rev 301/302 (ECO6/ECO7)
++ * The flash chip is powered off by default on ECO6/ECO7, when the default flash
+ * voltage changed from 1.8V to 3.3V. This is to prevent damage to 1.8V flash chips.
+ */
+ async powerOnFlash() {
+ if (this.chipFamily !== CHIP_FAMILY_ESP32P4) {
+ return; // Only needed for ESP32-P4
+ }
+- if (this.chipRevision !== 301) {
+- return; // Only needed for Rev 301 (ECO6)
+- }
+- this.logger.debug("Powering on flash for ESP32-P4 Rev 301 (ECO6)");
++ if (this.chipRevision !== 301 && this.chipRevision !== 302) {
++ return; // Only needed for Rev 301/302 (ECO6/ECO7)
++ }
++ // Rev 302 ROM can already power flash in download mode. Release its
++ // force-on state before attaching flash; preserve unrelated PMU bits.
++ if (this.chipRevision === 302) {
++ const efuse = await this.readRegister(ESP32P4_EFUSE_RD_REPEAT_DATA1_REG);
++ if (efuse & ESP32P4_EFUSE_DOWNLOAD_MODE_XPD_ON_MASK) {
++ const date = await this.readRegister(ESP32P4_PMU_DATE_REG);
++ if ((date & 3) === 3) {
++ await this.writeRegister(ESP32P4_PMU_DATE_REG, date & ~3);
++ }
++ return;
++ }
++ }
++ this.logger.debug(`Powering on flash for ESP32-P4 Rev ${this.chipRevision}`);
+ // Power up pad group
+ await this.writeRegister(ESP32P4_LP_SYSTEM_REG_ANA_XPD_PAD_GROUP_REG, 1);
+ await sleep(10); // 0.01 seconds
+@@ -1625,6 +1637,9 @@
+ // We're transferring over USB, right?
+ const ramBlock = USB_RAM_BLOCK;
+ // Upload
++ if (this.chipFamily === CHIP_FAMILY_ESP32P4 && this.chipRevision === 302) {
++ this.logger.log("Using updated ESP32-P4 rev 3.2 stub (upstream 16624ad).");
++ }
+ this.logger.log("Uploading stub...");
+ for (const field of ["text", "data"]) {
+ const fieldData = stub[field];
+@@ -1879,9 +1894,9 @@
+ if (!this.port.writable || !this.port.readable) {
+ throw new Error("Port not ready after reconnect");
+ }
+- // Power on flash for ESP32-P4 Rev 301 (must be done before loading stub)
++ // Power on flash for ESP32-P4 Rev 301/302 (must be done before loading stub)
+ if (this.chipFamily === CHIP_FAMILY_ESP32P4 &&
+- this.chipRevision === 301) {
++ (this.chipRevision === 301 || this.chipRevision === 302)) {
+ await this.powerOnFlash();
+ }
+ // Load stub
+@@ -2002,7 +2017,9 @@
+ }
+ // Send read flash command for this chunk
+ // This must be inside the retry loop so we send a fresh command after errors
+- const pkt = pack("= 300) {
++ if (chipRevision === 302) {
++ stubcode = await import("./esp32p4r32.json");
++ }
++ else if (chipRevision !== null && chipRevision !== undefined && chipRevision >= 300) {
+ stubcode = await import("./esp32p4r3.json");
+ }
+ else {
+ stubcode = await import("./esp32p4.json");
+ }
++ }
++ else if (chipFamily == CHIP_FAMILY_ESP32S31) {
++ stubcode = await import("./esp32s31.json");
+ }
+ // Base64 decode the text and data
+ return {
diff --git a/src/App.vue b/src/App.vue
index 27dd5e7..fa0dd1e 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -4448,6 +4448,7 @@ const languageLabelKeys: Record = {
de: 'language.german',
cs: 'language.czech',
es: 'language.spanish',
+ vi: 'language.vietnamese',
};
const languageOptions = computed(() =>
supportedLocales.map(code => ({
diff --git a/src/locales/cs.ts b/src/locales/cs.ts
index 59b3eea..8ec62c2 100644
--- a/src/locales/cs.ts
+++ b/src/locales/cs.ts
@@ -629,6 +629,7 @@ const cs = {
german: 'German',
czech: 'Čeština',
spanish: 'Español',
+ vietnamese: 'Tiếng Việt',
switchTo: 'Přepnout do jazyka {language}',
},
theme: {
diff --git a/src/locales/de.ts b/src/locales/de.ts
index 787e9a2..5434de1 100644
--- a/src/locales/de.ts
+++ b/src/locales/de.ts
@@ -629,6 +629,7 @@ const de = {
german: 'Deutsch',
czech: 'Tschechisch',
spanish: 'Spanisch',
+ vietnamese: 'Vietnamesisch',
switchTo: 'Zu {language} wechseln',
},
theme: {
diff --git a/src/locales/en.ts b/src/locales/en.ts
index cddb6ff..11bf7bc 100644
--- a/src/locales/en.ts
+++ b/src/locales/en.ts
@@ -629,6 +629,7 @@ const en = {
german: 'German',
czech: 'Czech',
spanish: 'Spanish',
+ vietnamese: 'Vietnamese',
switchTo: 'Switch to {language}',
},
theme: {
diff --git a/src/locales/es.ts b/src/locales/es.ts
index 0f417fa..9cbe26a 100644
--- a/src/locales/es.ts
+++ b/src/locales/es.ts
@@ -641,6 +641,7 @@ const es = {
german: 'German',
czech: 'Czech',
spanish: 'Español',
+ vietnamese: 'Vietnamese',
switchTo: 'Cambiar a {language}',
},
theme: {
diff --git a/src/locales/fr.ts b/src/locales/fr.ts
index a13ee3b..c91914c 100644
--- a/src/locales/fr.ts
+++ b/src/locales/fr.ts
@@ -629,6 +629,7 @@ const fr = {
german: 'Allemand',
czech: 'Tchèque',
spanish: 'Espagnol',
+ vietnamese: 'Vietnamien',
switchTo: 'Passer à {language}',
},
theme: {
diff --git a/src/locales/tr.ts b/src/locales/tr.ts
index 945e7b1..a799df4 100644
--- a/src/locales/tr.ts
+++ b/src/locales/tr.ts
@@ -628,6 +628,7 @@ const tr = {
german: 'Almanca',
czech: 'Çekçe',
spanish: 'İspanyolca',
+ vietnamese: 'Vietnamca',
switchTo: '{language} diline geç',
},
theme: {
diff --git a/src/locales/vi.ts b/src/locales/vi.ts
new file mode 100644
index 0000000..0e170e0
--- /dev/null
+++ b/src/locales/vi.ts
@@ -0,0 +1,647 @@
+const vi = {
+ app: {
+ title: 'ESPConnect',
+ sectionsLabel: 'Các mục',
+ resourcesLabel: 'Tài nguyên',
+ },
+ navigation: {
+ deviceInfo: 'Thông tin thiết bị',
+ partitions: 'Phân vùng',
+ apps: 'Ứng dụng',
+ nvs: 'Trình kiểm tra NVS',
+ spiffs: 'Công cụ SPIFFS',
+ littlefs: 'Công cụ LittleFS',
+ fatfs: 'Công cụ FATFS',
+ flash: 'Công cụ Flash',
+ console: 'Trình giám sát serial',
+ log: 'Nhật ký phiên',
+ about: 'Giới thiệu',
+ makerTools: 'Maker Tools',
+ },
+ resources: {
+ tutorial: 'Hướng dẫn',
+ buyCoffee: 'Mua cho tôi một ly cà phê',
+ donatePaypal: 'Ủng hộ qua PayPal',
+ getHelp: 'Nhận trợ giúp',
+ },
+ makerTools: {
+ title: 'Maker Tools',
+ subtitle: 'Các tiện ích Maker từ The Last Outpost Workshop cho ESP32 và các dự án nhúng.',
+ thumbnailAlt: 'Ảnh thu nhỏ của hướng dẫn {title}',
+ support: {
+ title: 'Hỗ trợ dự án',
+ description:
+ 'Những tiện ích Maker này và ESPConnect đều miễn phí để sử dụng. Nếu chúng giúp ích cho bạn ở bàn làm việc, một ly cà phê sẽ hỗ trợ quá trình phát triển tiếp tục.',
+ action: 'Mua cho tôi một ly cà phê',
+ },
+ actions: {
+ openTool: 'Mở công cụ',
+ watchTutorial: 'Xem hướng dẫn',
+ watchSpecific: 'Xem hướng dẫn {title}',
+ },
+ items: {
+ espPinoutExplorer: {
+ title: 'ESP Pinout Explorer',
+ description:
+ 'Một tài liệu tham khảo tương tác ở bàn làm việc cho các bo mạch phát triển, mô-đun và chip packages. Tìm chân, chức năng, header labels và các ràng buộc về dây dẫn đã được ghi nhận trước khi kết nối phần cứng.',
+ sourceLabel: 'thelastoutpostworkshop/esp-pinout-explorer',
+ },
+ espBoardVault: {
+ title: 'ESP Board Vault',
+ description:
+ 'ESP Board Vault là ứng dụng quản lý bo mạch miễn phí trên máy tính, ưu tiên lưu trữ cục bộ, giúp người dùng ESP32 quét, nhận diện, sắp xếp và theo dõi các bo mạch của mình. Tạo hồ sơ bo mạch với thông tin phần cứng, sơ đồ phân vùng, ảnh, dự án, danh sách kiểm tra và bản sao lưu. Không cần tài khoản, không có máy chủ đám mây, không thu thập dữ liệu sử dụng - mọi thứ đều được lưu trên máy tính của bạn.',
+ sourceLabel: 'thelastoutpostworkshop/ESPVault',
+ },
+ partitionBuilder: {
+ title: 'ESP32 Partition Builder',
+ description:
+ 'ESP32 Partition Builder giúp lên kế hoạch và tạo bố cục phân vùng tùy chỉnh cho các bo mạch ESP32.',
+ sourceLabel: 'thelastoutpostworkshop/ESP32PartitionBuilder',
+ },
+ videoConversion: {
+ title: 'Video Conversion Studio',
+ description:
+ 'Video Conversion Studio chuyển đổi video assets cho màn hình nhúng. Sử dụng tệp video hoặc âm thanh thông thường và biến chúng thành đầu ra phù hợp với các dự án hiển thị ESP32.',
+ sourceLabel: 'thelastoutpostworkshop/video_conversion',
+ },
+ gpioViewer: {
+ title: 'GPIOViewer',
+ description:
+ 'GPIOViewer cung cấp trực quan hóa thời gian thực về hoạt động chân GPIO trực tiếp trong trình duyệt web, giúp dễ dàng kiểm tra trạng thái chân, xác nhận hành vi bo mạch và khắc phục lỗi dây dẫn hoặc hoạt động tín hiệu.',
+ sourceLabel: 'thelastoutpostworkshop/gpio_viewer',
+ },
+ arduinoMakerWorkshop: {
+ title: 'Arduino Maker Workshop',
+ description:
+ 'Arduino Maker Workshop là một tiện ích mở rộng của VS Code dành cho phát triển maker theo hướng Arduino, mang đến luồng làm việc tập trung trong trình soạn thảo cho các dự án dựa trên sketch và lặp lại theo hướng bo mạch.',
+ sourceLabel: 'Tiện ích mở rộng VS Code Marketplace',
+ },
+ },
+ },
+ actions: {
+ connect: 'Kết nối',
+ disconnect: 'Ngắt kết nối',
+ },
+ forms: {
+ baudRate: 'Tốc độ Baud',
+ },
+ tooltips: {
+ higherBaud: 'Có thể sử dụng baudrate cao hơn',
+ },
+ alerts: {
+ browserUnsupported:
+ 'Trình duyệt này không hỗ trợ Web Serial API. Hãy dùng Chrome, Edge hoặc một trình duyệt dựa trên Chromium khác.',
+ serialMonitorClosed: 'Trình giám sát nối tiếp đã đóng - nhấp Kết nối để quay lại chế độ bảo trì.',
+ },
+ sessionLog: {
+ title: 'Nhật ký phiên',
+ actions: {
+ copy: 'Sao chép',
+ clear: 'Xóa',
+ },
+ emptyState: 'Nhật ký sẽ xuất hiện ở đây sau khi có các hành động bắt đầu.',
+ copySuccess: 'Đã sao chép nhật ký phiên vào clipboard.',
+ copyError: 'Không thể sao chép nhật ký. Vui lòng thử lại.',
+ chipMetadata: 'Chip metadata: pkgVersion={pkgVersion}, chipRevision={chipRevision}',
+ chipId: 'ID chip: {chipId}',
+ },
+ serialMonitor: {
+ title: 'Serial Monitor',
+ status: {
+ live: 'Trực tiếp',
+ stopped: 'Đã dừng',
+ },
+ actions: {
+ start: 'Bắt đầu',
+ stop: 'Dừng',
+ pause: 'Tạm dừng',
+ resume: 'Tiếp tục',
+ copy: 'Sao chép',
+ clear: 'Xóa',
+ reset: 'Đặt lại',
+ send: 'Gửi',
+ },
+ filterPlaceholder: 'Lọc đầu ra',
+ inputPlaceholder: 'Gửi văn bản (Ctrl+C)',
+ subtitle:
+ 'Console chạy ở 115200 bps tự động để có đầu ra ổn định. Quá trình flash sẽ dùng tốc độ baud đã chọn trong thanh công cụ.',
+ info:
+ 'Bắt đầu trình giám sát nối tiếp sẽ đặt lại bo mạch vào chế độ firmware bình thường để bạn xem đầu ra UART. Dừng trình giám sát nối tiếp sẽ tự động quay lại chế độ bootloader (stub) để bảo trì (flash, công cụ phân vùng, v.v.).',
+ emptyState: 'Đầu ra của trình giám sát sẽ xuất hiện ở đây sau khi bắt đầu.',
+ copySuccess: 'Đã sao chép đầu ra trình giám sát nối tiếp vào clipboard.',
+ copyError: 'Không thể sao chép đầu ra của trình giám sát nối tiếp. Vui lòng thử lại.',
+ },
+ serialPortPicker: {
+ title: 'Chọn cổng nối tiếp',
+ subtitle: 'Chọn cổng được kết nối với thiết bị ESP của bạn.',
+ recommended: 'Khuyến nghị',
+ unknownPort: 'Cổng không xác định',
+ noDetails: 'Không có chi tiết thiết bị nào',
+ usbId: 'USB ID {vendorId}:{productId}',
+ serialNumber: 'Số seri {serialNumber}',
+ actions: {
+ select: 'Chọn',
+ },
+ },
+ dialogs: {
+ connecting: 'Đang kết nối',
+ littlefsBackup: 'Sao lưu LittleFS',
+ littlefsLoading: 'Đang tải LittleFS',
+ littlefsSaving: 'Đang lưu LittleFS',
+ preparingBackup: 'Đang chuẩn bị bản sao lưu...',
+ writingLittlefs: 'Đang ghi hình ảnh LittleFS...',
+ littlefsRestoring: 'Đang khôi phục LittleFS',
+ fatfsBackup: 'Sao lưu FATFS',
+ spiffsLoading: 'Đang đọc SPIFFS...',
+ spiffsSaving: 'Đang lưu SPIFFS...',
+ spiffsRestoring: 'Đang khôi phục hình ảnh SPIFFS...',
+ fatfsLoading: 'Đang đọc FATFS...',
+ fatfsSaving: 'Đang lưu FATFS...',
+ fatfsRestoring: 'Đang khôi phục hình ảnh FATFS...',
+ cancel: 'Hủy',
+ confirm: 'Xác nhận',
+ continue: 'Tiếp tục',
+ confirmationTitle: 'Vui lòng xác nhận',
+ returningToMaintenance: 'Đang quay lại chế độ bảo trì...',
+ reEnteringBootloader: 'Đang nhập lại ROM bootloader...',
+ connectingToDevice: 'Đang kết nối với thiết bị ESP...',
+ openingSerialPort: 'Đang mở cổng nối tiếp...',
+ restoringBaud: 'Đang khôi phục baud về {baud} bps...',
+ handshakingBootloader: 'Đang bắt tay với ROM bootloader...',
+ readingFlashSize: 'Đang đọc kích thước Flash...',
+ preparingInformation: 'Đang chuẩn bị thông tin...',
+ readingPartitionTable: 'Đang đọc bảng phân vùng...',
+ reconnectingStub: 'Kết nối lại và đồng bộ với stub',
+ loadingStubFlasher: 'Đang tải stub flasher...',
+ gettingSecurityInfo: 'Đang lấy thông tin bảo mật...',
+ },
+ disconnected: {
+ defaultTitle: 'Chưa có thiết bị nào được kết nối',
+ defaultSubtitle: 'Kết nối với ESP32 để tiếp tục.',
+ deviceInfo: 'Kết nối với ESP32 để xem thông tin thiết bị.',
+ nvs: 'Kết nối với ESP32 có phân vùng NVS để kiểm tra các cặp khóa/giá trị đã lưu.',
+ spiffs: 'Kết nối với ESP32 để duyệt và chỉnh sửa tệp SPIFFS.',
+ littlefs: 'Kết nối với ESP32 có phân vùng LittleFS để sử dụng các công cụ này.',
+ fatfs: 'Kết nối với ESP32 có phân vùng FATFS để sử dụng các công cụ này.',
+ apps: 'Kết nối với thiết bị để kiểm tra các khe ứng dụng OTA.',
+ flash: 'Kết nối với bảng mạch của bạn để flash firmware hoặc kiểm tra thanh ghi.',
+ },
+ deviceInfo: {
+ summary: {
+ flashClock: 'Flash & Đồng hồ',
+ featureSet: 'Tập tính năng',
+ capabilities: '{count} tính năng',
+ noFeatures: 'Không có tính năng nào được báo cáo',
+ more: '+{count} thêm',
+ noOptionalCapabilities: 'Không có tính năng tùy chọn nào.',
+ },
+ crystal: 'Crystal {crystal}',
+ unknown: 'Không xác định',
+ facts: {
+ groups: {
+ package: 'Gói & Phiên bản',
+ embeddedMemory: 'Bộ nhớ nhúng',
+ security: 'Bảo mật',
+ peripherals: 'Thiết bị ngoại vi',
+ connection: 'Kết nối',
+ documentation: 'Tài liệu',
+ },
+ labels: {
+ chipVariant: 'Biến thể Chip',
+ packageFormFactor: 'Hình thức Gói',
+ revision: 'Phiên bản',
+ cpuCores: 'Số lõi CPU',
+ maxCpuFrequency: 'Tần số CPU tối đa',
+ embeddedFlash: 'Embedded Flash',
+ embeddedPsr: 'Embedded PSRAM',
+ flashId: 'Flash ID',
+ flashManufacturer: 'Nhà sản xuất Flash',
+ flashDevice: 'Thiết bị Flash',
+ flashVendorEfuse: 'Nhà cung cấp Flash (eFuse)',
+ psramVendorEfuse: 'Nhà cung cấp PSRAM (eFuse)',
+ efuseBlockVersion: 'Phiên bản khối eFuse',
+ flashEncryption: 'Mã hóa Flash',
+ flashEncryptionDetails: 'Chi tiết Mã hóa Flash',
+ flashEncryptionMode: 'Chế độ Mã hóa Flash',
+ secureBoot: 'Secure Boot',
+ secureBootType: 'Loại Secure Boot',
+ jtagProtection: 'Bảo vệ JTAG',
+ usbProtection: 'Bảo vệ USB',
+ downloadModeCaches: 'Bộ nhớ cache Chế độ Tải xuống',
+ securityNote: 'Ghi chú Bảo mật',
+ pwmLedc: 'PWM/LEDC',
+ usbBridge: 'USB Bridge',
+ connectionBaud: 'Baud Kết nối',
+ hardwareReference: 'Tham chiếu Phần cứng',
+ datasheet: 'Datasheet',
+ technicalReferenceManual: 'Sổ tay Tham chiếu Kỹ thuật',
+ errata: 'Errata',
+ hardwareDesignGuidelines: 'Hướng dẫn Thiết kế Phần cứng',
+ },
+ pwmTooltip:
+ 'Tính năng PWM/LEDC dựa trên dòng chip, không phải trên dữ liệu thời gian thực được đọc từ thiết bị.',
+ },
+ },
+ about: {
+ title: 'Giới thiệu về ESPConnect',
+ description: {
+ intro:
+ 'ESPConnect là một bộ công cụ dựa trên trình duyệt cho các thiết bị ESP32. Nó kết hợp các tác vụ bảo trì phổ biến để bạn có thể kết nối, kiểm tra và cập nhật bo mạch mà không cần cài đặt các tiện ích trên máy tính.',
+ details:
+ 'Ứng dụng giao tiếp với thiết bị qua Web Serial API, sau đó hiển thị các công cụ cấp cao cho việc flash firmware, quản lý phân vùng, chỉnh sửa filesystem, quy trình sao lưu/khôi phục, cũng như nhật ký phiên và trình giám sát nối tiếp để khắc phục sự cố. Nó dựa trên',
+ reference: 'WebSerial ESPTool',
+ },
+ filesystems: {
+ title: 'Công cụ Filesystem',
+ spiFFS: {
+ label: 'SPIFFS',
+ description: 'Bộ mô phỏng TypeScript để chuẩn bị thay đổi cục bộ trước khi ghi vào flash.',
+ },
+ littleFS: {
+ label: 'LittleFS',
+ description: 'Dựa trên module littlefs-wasm để có độ tương đồng lưu trữ thật.',
+ },
+ fatFS: {
+ label: 'FATFS',
+ description: 'Công cụ mới dựa trên wasm cho các phân vùng FAT, phản ánh bố cục thiết bị của bạn.',
+ },
+ summary:
+ 'Tải lên bằng kéo-thả, xem trước/nghe các loại tệp phổ biến, bản tóm tắt diff và yêu cầu sao lưu bắt buộc trước khi lưu giúp quy trình an toàn hơn.',
+ },
+ firmware: {
+ title: 'Firmware & Bảo trì',
+ flash: 'Flash các tệp nhị phân tùy chỉnh với theo dõi tiến độ, preset và nhắc nhở an toàn.',
+ inspect: 'Kiểm tra bảng phân vùng, khe OTA, mô tả thiết bị và mức sử dụng flash chỉ trong nháy mắt.',
+ monitor: 'Trình giám sát nối tiếp với phím tắt lệnh và nhật ký phiên để khắc phục sự cố.',
+ },
+ safety: {
+ title: 'Quy trình ưu tiên an toàn',
+ body:
+ 'Mọi hành động phá hủy đều được chặn bởi lời nhắc xác nhận và bản sao lưu gần đây. ESPConnect giữ các thay đổi đã đưa vào staging trong bộ nhớ cho đến khi bạn chủ động ghi chúng vào flash, giúp dễ dàng xem lại sửa đổi hoặc khôi phục bằng cách đọc lại phân vùng.',
+ },
+ privacy: {
+ title: 'Quyền riêng tư & Bảo mật',
+ body:
+ 'ESPConnect chạy hoàn toàn trong trình duyệt của bạn -- không có backend, tài khoản hay telemetry nào. Tệp firmware, bản sao lưu và chẩn đoán đều ở cục bộ và chỉ di chuyển khi bạn tự tải xuống. Luôn flash firmware từ các nguồn đáng tin cậy.',
+ },
+ },
+ apps: {
+ alerts: {
+ readingMetadata: 'Đang đọc siêu dữ liệu ứng dụng…',
+ noApplications: 'Không phát hiện thấy phân vùng ứng dụng nào.',
+ partitionTableUnavailable: 'Không thể hiển thị các phân vùng ứng dụng vì không thể đọc bảng phân vùng.',
+ },
+ chips: {
+ active: 'Hoạt động',
+ invalid: 'Mã hóa/Không hợp lệ',
+ },
+ subtitle: 'Offset {offset} — Kích thước {size}',
+ details: {
+ project: 'Dự án',
+ version: 'Phiên bản',
+ built: 'Xây dựng',
+ core: 'IDF / Core',
+ entry: 'Địa chỉ đầu vào',
+ segments: 'Segments',
+ },
+ descriptorMissing: 'Không tìm thấy mô tả ứng dụng trong 64 KB đầu tiên.',
+ invalidImage: 'Tiêu đề hình ảnh không hợp lệ hoặc đã mã hóa.',
+ unknown: 'Không xác định',
+ facts: {
+ groups: {
+ package: 'Gói & Phiên bản',
+ embeddedMemory: 'Bộ nhớ nhúng',
+ security: 'Bảo mật',
+ peripherals: 'Thiết bị ngoại vi',
+ connection: 'Kết nối',
+ documentation: 'Tài liệu',
+ },
+ labels: {
+ chipVariant: 'Biến thể Chip',
+ packageFormFactor: 'Hình thức Gói',
+ revision: 'Phiên bản',
+ cpuCores: 'Số lõi CPU',
+ maxCpuFrequency: 'Tần số CPU tối đa',
+ embeddedFlash: 'Embedded Flash',
+ embeddedPsr: 'Embedded PSRAM',
+ flashId: 'Flash ID',
+ flashManufacturer: 'Nhà sản xuất Flash',
+ flashDevice: 'Thiết bị Flash',
+ flashVendorEfuse: 'Nhà cung cấp Flash (eFuse)',
+ psramVendorEfuse: 'Nhà cung cấp PSRAM (eFuse)',
+ efuseBlockVersion: 'Phiên bản khối eFuse',
+ flashEncryption: 'Mã hóa Flash',
+ flashEncryptionDetails: 'Chi tiết Mã hóa Flash',
+ flashEncryptionMode: 'Chế độ Mã hóa Flash',
+ secureBoot: 'Secure Boot',
+ secureBootType: 'Loại Secure Boot',
+ jtagProtection: 'Bảo vệ JTAG',
+ usbProtection: 'Bảo vệ USB',
+ downloadModeCaches: 'Bộ nhớ cache Chế độ Tải xuống',
+ securityNote: 'Ghi chú Bảo mật',
+ pwmLedc: 'PWM/LEDC',
+ usbBridge: 'USB Bridge',
+ connectionBaud: 'Baud Kết nối',
+ hardwareReference: 'Tham chiếu Phần cứng',
+ datasheet: 'Datasheet',
+ technicalReferenceManual: 'Sổ tay Tham chiếu Kỹ thuật',
+ errata: 'Errata',
+ hardwareDesignGuidelines: 'Hướng dẫn Thiết kế Phần cứng',
+ },
+ pwmTooltip:
+ 'Khả năng PWM/LEDC dựa trên họ chip, không phải trên dữ liệu thời gian thực được đọc từ thiết bị.',
+ },
+ },
+ flashFirmware: {
+ warning:
+ 'Các công cụ firmware chỉ dành cho người dùng nâng cao. Viết hoặc xóa flash có thể xóa vĩnh viễn dữ liệu hoặc làm cho vi điều khiển không thể khởi động lại. Kiểm tra kỹ cài đặt trước khi tiếp tục.',
+ backup: {
+ title: 'Sao lưu & Xóa Flash',
+ partitionLabel: 'Phân vùng',
+ downloadSelectedPartition: 'Tải xuống Phân vùng Đã chọn',
+ eraseSelectedPartition: 'Xóa Phân vùng Đã chọn',
+ downloadAllPartitions: 'Tải xuống Tất cả Phân vùng',
+ downloadFlashBackup: 'Tải xuống Sao lưu Flash',
+ startOffset: 'Offset bắt đầu',
+ lengthBytes: 'Độ dài (byte)',
+ downloadRegion: 'Tải xuống Vùng Flash',
+ eraseRegion: 'Xóa Vùng Flash',
+ eraseEntireFlash: 'Xóa Toàn bộ Flash',
+ eraseConfirmTitle: 'Xóa Toàn bộ Flash',
+ eraseConfirmMessage: 'Xóa toàn bộ flash? Thao tác này sẽ xóa tất cả dữ liệu và không thể hoàn tác.',
+ eraseConfirmButton: 'Xóa Flash',
+ erasePartitionConfirmTitle: 'Xóa Phân vùng',
+ erasePartitionConfirmMessage: 'Xóa phân vùng đã chọn ({label}) tại {offset} ({size})?',
+ eraseRegionConfirmTitle: 'Xóa Vùng Flash',
+ eraseRegionConfirmMessage: 'Xóa {label} tại {offset} với {length} byte?',
+ status: {
+ connectFirst: 'Kết nối với thiết bị trước.',
+ unsupported: 'Xóa toàn bộ flash không được bộ nạp này hỗ trợ.',
+ partitionRequired: 'Chọn một phân vùng để xóa.',
+ regionRequired: 'Nhập offset và độ dài để xóa.',
+ regionOutOfBounds: 'Vùng yêu cầu vượt quá kích thước flash đã phát hiện.',
+ cancelled: 'Đã hủy thao tác xóa flash.',
+ erasing: 'Đang xóa toàn bộ flash...',
+ erasingPartition: 'Đang xóa phân vùng...',
+ erasingRegion: 'Đang xóa vùng flash...',
+ complete: 'Đã xóa flash hoàn tất.',
+ partitionComplete: 'Đã xóa phân vùng.',
+ regionComplete: 'Đã xóa vùng flash.',
+ failed: 'Xóa không thành công: {error}',
+ },
+ },
+ firmware: {
+ title: 'Flash Firmware',
+ binaryLabel: 'Tệp nhị phân firmware (.bin)',
+ flashOffset: 'Offset flash',
+ recommendedOffsets: 'Offset đề xuất',
+ eraseBeforeFlash: 'Xóa toàn bộ flash trước khi ghi',
+ hint:
+ 'Quá trình flashing mặc định chạy ở 921,600 bps. Hạ thấp baud nếu thiết bị gặp khó khăn khi đồng bộ. Trình giám sát nối tiếp tự động chuyển sang 115,200 bps để ổn định.',
+ flashButton: 'Flash Firmware',
+ },
+ registerAccess: {
+ title: 'Truy cập Thanh ghi',
+ quickSelectLabel: 'Chọn nhanh thanh ghi',
+ registerAddress: 'Địa chỉ thanh ghi',
+ valueLabel: 'Giá trị',
+ readButton: 'Đọc Thanh ghi',
+ writeButton: 'Ghi Thanh ghi',
+ referenceLink: 'Xem tài liệu tham khảo thanh ghi',
+ technicalReferenceLink: 'Mở tài liệu kỹ thuật',
+ lastReadValue: 'Giá trị đọc gần nhất:',
+ },
+ integrity: {
+ title: 'Tính toàn vẹn Flash',
+ partitionLabel: 'Phân vùng',
+ helper: 'Chọn phân vùng sẽ tự điền các trường offset và độ dài bên dưới.',
+ startOffset: 'Offset bắt đầu',
+ lengthBytes: 'Độ dài (byte)',
+ computeButton: 'Tính MD5',
+ md5Label: 'Checksum MD5:',
+ },
+ progress: {
+ flashTitle: 'Đang flash',
+ downloadTitle: 'Đang tải xuống flash',
+ preparingFlash: 'Đang chuẩn bị flash...',
+ preparingDownload: 'Đang chuẩn bị tải xuống...',
+ erasingFlash: 'Đang xóa flash...',
+ stop: 'Dừng',
+ },
+ },
+ partitions: {
+ cardTitle: 'Phân vùng',
+ cardTitleWithSize: 'Phân vùng — {size}',
+ usageSummary: 'Phân vùng sử dụng {size}',
+ empty: {
+ title: 'Chưa có dữ liệu phân vùng',
+ subtitle: 'Kết nối với ESP32 để tải bảng phân vùng của nó (ESP8266 không được hỗ trợ).',
+ subtitleConnected: 'Không phát hiện bảng phân vùng trên ESP32 này.',
+ },
+ error: {
+ title: 'Không thể đọc bảng phân vùng',
+ message:
+ 'ESPConnect không thể đọc bảng phân vùng. Điều này thường có nghĩa là giao tiếp nối tiếp không đáng tin cậy trong trình duyệt này hoặc chip/transport không được hỗ trợ đầy đủ.',
+ detail: 'Chi tiết: {error}',
+ },
+ alerts: {
+ unusedFlash: {
+ detected: 'Phát hiện flash chưa sử dụng - khoảng {amount} ({bytes} byte) có thể thu hồi.',
+ learn: 'Xem',
+ or: 'hoặc thử',
+ },
+ customizePrompt: 'Bạn muốn tùy chỉnh bố cục này?',
+ customizeOr: 'hoặc mở',
+ resources: {
+ tutorial: 'hướng dẫn phân vùng',
+ builder: 'ESP32 partition builder',
+ },
+ },
+ table: {
+ label: 'Nhãn',
+ type: 'Loại',
+ subtype: 'Phân loại',
+ offset: 'Offset',
+ size: 'Kích thước',
+ },
+ unnamed: 'Không có tên',
+ },
+ filesystem: {
+ partitionTitle: 'Phân vùng {fs}',
+ partitionTitleWithSize: '{base} — {size}',
+ partitionSelectLabel: 'Tên phân vùng',
+ controls: {
+ read: 'Đọc',
+ backup: 'Sao lưu',
+ restore: 'Khôi phục Ảnh',
+ format: 'Định dạng',
+ save: 'Lưu vào Flash',
+ newFolder: 'Thư mục mới',
+ },
+ backupReminder:
+ 'Tải xuống hình ảnh sao lưu trước (dùng nút "{backup}" một lần cho mỗi phiên). "{save}" sẽ có sẵn sau khi có bất kỳ bản sao lưu thành công nào trong kết nối này.',
+ saveHint: 'Các thay đổi được staging cục bộ cho đến khi bạn nhấn "{save}". Một bản sao lưu gần đây đảm bảo bạn có thể khôi phục nếu có vấn đề xảy ra.',
+ filesTitle: 'Tệp',
+ unsavedChanges: 'Có thay đổi chưa lưu',
+ usage: {
+ used: 'Đã dùng {percent}% ({used} / {capacity})',
+ free: 'Còn trống {free}',
+ },
+ upload: {
+ selectFile: 'Chọn tệp',
+ uploadButton: 'Tải lên',
+ dropHint: 'Thả tệp để thêm',
+ dropHintFolder: 'Thả tệp hoặc thư mục để thêm',
+ },
+ filter: {
+ search: 'Lọc tệp',
+ type: 'Loại tệp',
+ },
+ noMatches: 'Không có tệp nào phù hợp với bộ lọc hiện tại.',
+ table: {
+ name: 'Tên',
+ size: 'Kích thước',
+ actions: 'Hành động',
+ },
+ pagination: {
+ all: 'Tất cả',
+ itemsPerPage: 'Số mục trên mỗi trang:',
+ },
+ fileCategory: {
+ all: 'Tất cả các loại',
+ text: 'Văn bản',
+ image: 'Hình ảnh',
+ audio: 'Âm thanh',
+ other: 'Khác',
+ },
+ filterCount: {
+ noFiles: 'Không có tệp',
+ all: '{count} tệp',
+ partial: '{filtered} trên {total} tệp',
+ },
+ emptyState: 'Không phát hiện tệp nào. Tải lên hoặc khôi phục hình ảnh {fs} để bắt đầu.',
+ loadCancelled: 'Đã hủy tải {fs}. {action} để lấy lại phân vùng.',
+ readOnly: '{fs} đang ở chế độ chỉ đọc. {detail}',
+ readOnlyDetail: 'Không thể lưu thay đổi.',
+ versionLabel: 'LittleFS v{version}',
+ dialog: {
+ newFolderTitle: 'Thư mục mới',
+ folderName: 'Tên thư mục',
+ cancel: 'Hủy',
+ create: 'Tạo',
+ },
+ preview: {
+ listen: 'Nghe',
+ view: 'Xem',
+ },
+ actions: {
+ download: 'Tải xuống {file}',
+ delete: 'Xóa {file}',
+ },
+ },
+ filesystemStatus: {
+ spiffs: 'Tải một phân vùng SPIFFS để bắt đầu.',
+ littlefs: 'Tải một phân vùng LittleFS để bắt đầu.',
+ fatfs: 'Tải một phân vùng FATFS để bắt đầu.',
+ },
+ nvsInspector: {
+ title: 'Trình kiểm tra NVS',
+ experimentalTooltip: 'Tính năng thử nghiệm: đầu ra có thể chưa đầy đủ hoặc không chính xác.',
+ experimentalBadge: 'Thử nghiệm',
+ partitionSelectLabel: 'Tên phân vùng',
+ readButton: 'Đọc NVS',
+ versionLabel: 'NVS v{version}',
+ pageStatsTooltip: 'tổng: {total} · đang dùng: {inUse} · trống: {blank} · xấu: {bad}',
+ pagesCount: '{count} trang',
+ namespacesCount: '{count} không gian tên',
+ entriesCount: '{count} mục',
+ usage: {
+ label: 'Đã dùng {used} / {total}',
+ tooltip:
+ 'Bộ nhớ mục: {percent}% phân vùng · đã ghi: {written} · đã xóa: {erased} · trống: {free} · không hợp lệ: {illegal} · dung lượng mục: {capacity}',
+ },
+ tabs: {
+ keys: 'Khóa',
+ pages: 'Trang',
+ },
+ keys: {
+ emptyState: 'Đọc NVS để xem khóa.',
+ title: 'Khóa',
+ pageFilter: 'Trang p{page}',
+ filters: {
+ namespace: 'Không gian tên',
+ key: 'Khóa',
+ type: 'Loại',
+ value: 'Xem trước giá trị',
+ },
+ table: {
+ namespace: 'Không gian tên',
+ key: 'Khóa',
+ type: 'Loại',
+ value: 'Giá trị',
+ length: 'Độ dài',
+ crc: 'CRC',
+ location: 'Vị trí',
+ issues: 'Vấn đề',
+ },
+ noMatches: 'Không có mục nào khớp với bộ lọc hiện tại.',
+ },
+ filteredCount: '{count} đang hiển thị',
+ warningsErrors: {
+ title: 'Cảnh báo / Lỗi',
+ errors: 'Lỗi',
+ warnings: 'Cảnh báo',
+ },
+ status: {
+ blank: 'Trống',
+ ok: 'OK',
+ bad: 'BAD',
+ },
+ pages: {
+ emptyState: 'Đọc NVS để xem các trang.',
+ title: 'Trang',
+ summary: 'tổng: {total} · đang dùng: {inUse} · trống: {blank} · xấu: {bad}',
+ table: {
+ page: 'Trang #',
+ state: 'Trạng thái',
+ seq: 'Seq',
+ status: 'Trạng thái',
+ map: 'Bản đồ',
+ errors: 'Lỗi',
+ },
+ },
+ pageTooltipTitle: 'Trang {index} ({state})',
+ pageTooltipWritten: 'Đã ghi: {written} / {total} ({percent}%)',
+ pageTooltipDistribution: 'Đã xóa: {erased}, Trống: {empty}, Không hợp lệ: {illegal}',
+ entryMapUnavailable: 'Bản đồ mục không khả dụng.',
+ pageTooltipErrors: 'Lỗi: {count}',
+ entryCellTitle: 'Mục #{entry} (từ {word} / bit {bitStart}-{bitEnd}): {state}',
+ filters: {
+ all: 'Tất cả',
+ },
+ },
+ language: {
+ english: 'English',
+ french: 'Français',
+ chinese: '简体中文',
+ turkish: 'Türkçe',
+ german: 'German',
+ czech: 'Czech',
+ spanish: 'Spanish',
+ vietnamese: 'Tiếng Việt',
+ switchTo: 'Chuyển sang {language}',
+ },
+ theme: {
+ light: 'sáng',
+ dark: 'tối',
+ switch: 'Chuyển sang giao diện {theme}',
+ },
+ status: {
+ connected: 'Đã kết nối',
+ disconnected: 'Đã ngắt kết nối',
+ preparingBackup: 'Đang chuẩn bị bản sao lưu...',
+ writingLittlefs: 'Đang ghi hình ảnh LittleFS...',
+ },
+};
+
+export default vi;
diff --git a/src/locales/zh.ts b/src/locales/zh.ts
index 6256538..5ec0c83 100644
--- a/src/locales/zh.ts
+++ b/src/locales/zh.ts
@@ -627,6 +627,7 @@ const zh = {
german: '德语',
czech: '捷克语',
spanish: '西班牙语',
+ vietnamese: '越南语',
switchTo: '切换到{language}',
},
theme: {
diff --git a/src/plugins/i18n.test.ts b/src/plugins/i18n.test.ts
index cea9940..2476416 100644
--- a/src/plugins/i18n.test.ts
+++ b/src/plugins/i18n.test.ts
@@ -55,6 +55,15 @@ describe('i18n locale selection', () => {
expect(i18n.global.locale.value).toBe('es');
});
+ it('uses vi when navigator languages include Vietnamese', async () => {
+ setNavigator(['vi-VN', 'en-US']);
+ setWindowWithLocalStorage(null);
+
+ const { i18n } = await loadI18n();
+
+ expect(i18n.global.locale.value).toBe('vi');
+ });
+
it('falls back to en when navigator languages are unsupported', async () => {
setNavigator(['ja-JP']);
setWindowWithLocalStorage(null);
diff --git a/src/plugins/i18n.ts b/src/plugins/i18n.ts
index 789e7a0..3bc3500 100644
--- a/src/plugins/i18n.ts
+++ b/src/plugins/i18n.ts
@@ -6,10 +6,11 @@ import tr from '../locales/tr';
import de from '../locales/de';
import cs from '../locales/cs';
import es from '../locales/es';
-import { en as vuetifyEn, fr as vuetifyFr, zhHans as vuetifyZhHans, tr as vuetifyTr, de as vuetifyDe, cs as vuetifyCs, es as vuetifyEs } from 'vuetify/locale';
+import vi from '../locales/vi';
+import { en as vuetifyEn, fr as vuetifyFr, zhHans as vuetifyZhHans, tr as vuetifyTr, de as vuetifyDe, cs as vuetifyCs, es as vuetifyEs, vi as vuetifyVi } from 'vuetify/locale';
const STORAGE_KEY = 'espconnect-language';
-export const supportedLocales = ['en', 'fr', 'zh', 'tr', 'de', 'cs', 'es'] as const;
+export const supportedLocales = ['en', 'fr', 'zh', 'tr', 'de', 'cs', 'es', 'vi'] as const;
export type SupportedLocale = (typeof supportedLocales)[number];
function normalizeLocale(value: unknown): SupportedLocale {
@@ -87,6 +88,10 @@ export const i18n = createI18n({
...es,
$vuetify: vuetifyEs,
},
+ vi: {
+ ...vi,
+ $vuetify: vuetifyVi,
+ },
},
});
diff --git a/src/services/chipMetadata/esp32s31.ts b/src/services/chipMetadata/esp32s31.ts
index 0760556..723ab89 100644
--- a/src/services/chipMetadata/esp32s31.ts
+++ b/src/services/chipMetadata/esp32s31.ts
@@ -1,7 +1,7 @@
import type { ChipMetadata } from './types';
import type { ESPLoader } from 'tasmota-webserial-esptool';
-// Minimal ESP32-S31 metadata helper (constants mirrored from legacy target for reference)
+// ESP32-S31 metadata helper. Variant-specific memory details require eFuse decoding.
export const CHIP_NAME = 'ESP32-S31';
export const IMAGE_CHIP_ID = 32;
export const EFUSE_BASE = 0x60008800;
@@ -19,7 +19,15 @@ export async function readEsp32S31Metadata(loader: ESPLoader): Promise;
@@ -248,8 +251,8 @@ export function createEsptoolClient({
};
const logger = createLogger(terminal, debugLogging);
- const esp_loader = new ESPLoader(port, logger);
- let loader = esp_loader;
+ const esp_loader = new P4CompatibleLoader(port, logger);
+ let loader: ESPLoader = esp_loader;
loader.debug = debugLogging;
const loaderProxy = new Proxy({} as ESPLoader, {
@@ -287,7 +290,10 @@ export function createEsptoolClient({
const transport = new CompatibleTransport(port, debugSerial ?? false, loader, isBusy);
const status = (payload: StatusPayload) => onStatus?.(payload);
- status({ message: `tasmota-webserial-esptool v(${tasmotaEsptoolVersion})`, showInDialog: false });
+ status({
+ message: `tasmota-webserial-esptool v(${tasmotaEsptoolVersion}) [${ESPTOOL_BACKPORT_LABEL}]`,
+ showInDialog: false,
+ });
let client: EsptoolClient;
diff --git a/src/services/p4CompatibleLoader.ts b/src/services/p4CompatibleLoader.ts
new file mode 100644
index 0000000..4ddce0f
--- /dev/null
+++ b/src/services/p4CompatibleLoader.ts
@@ -0,0 +1,65 @@
+import { ESPLoader } from 'tasmota-webserial-esptool';
+import { CHIP_FAMILY_ESP32P4, USB_JTAG_SERIAL_PID } from 'tasmota-webserial-esptool/dist/const.js';
+
+// Keep the initial reset unchanged: the chip is identified only after ROM sync.
+export class P4CompatibleLoader extends ESPLoader {
+ private unixTightAttempted = false;
+ private preferUnixTight = false;
+
+ private canUseUnixTight(): boolean {
+ const info = this.port.getInfo();
+ return this.chipFamily === CHIP_FAMILY_ESP32P4 && this.chipRevision === 302 &&
+ info.usbVendorId !== 0x303a && info.usbProductId !== USB_JTAG_SERIAL_PID;
+ }
+
+ // Signal sequence from WebSerial_ESPTool commit 16624ad, hardResetUnixTight().
+ private async resetUnixTight(): Promise {
+ await this.setDTRandRTS(true, true);
+ await this.setDTRandRTS(false, false);
+ await this.setDTRandRTS(false, true);
+ await this.sleep(100);
+ await this.setDTRandRTS(true, false);
+ await this.sleep(50);
+ await this.setDTRandRTS(false, false);
+ await this.setDTR(false);
+ await this.sleep(200);
+ }
+
+ override async runStub(skipFlashDetection = false) {
+ try {
+ return await super.runStub(skipFlashDetection);
+ } catch (initialError) {
+ if (!this.canUseUnixTight() || this.unixTightAttempted ||
+ !this.connected || !this.port.readable || !this.port.writable) {
+ throw initialError;
+ }
+ this.unixTightAttempted = true;
+ const initialMessage = initialError instanceof Error ? initialError.message : String(initialError);
+ this.logger.log(`ESP32-P4 rev 3.2 stub initialization failed (${initialMessage}); retrying once with UnixTight reset.`);
+ try {
+ await this.resetUnixTight();
+ await this.flushSerialBuffers();
+ await this.sync();
+ await this.powerOnFlash();
+ // Call the base implementation directly so a second failure cannot recurse.
+ const stub = await super.runStub(skipFlashDetection);
+ this.preferUnixTight = true;
+ this.logger.log('ESP32-P4 rev 3.2 stub initialized after UnixTight reset.');
+ return stub;
+ } catch (retryError) {
+ const retryMessage = retryError instanceof Error ? retryError.message : String(retryError);
+ throw new Error(`ESP32-P4 rev 3.2 stub initialization failed: ${initialMessage}. UnixTight retry failed: ${retryMessage}`, { cause: retryError });
+ }
+ }
+ }
+
+ override async hardReset(bootloader = false): Promise {
+ if (bootloader && this.preferUnixTight && this.canUseUnixTight()) {
+ this.logger.log('Using UnixTight reset for ESP32-P4 rev 3.2 reconnect.');
+ await this.resetUnixTight();
+ // reconnect() handles ROM sync and flash power initialization after reset.
+ return;
+ }
+ await super.hardReset(bootloader);
+ }
+}
diff --git a/tests/esp32s31-metadata.test.ts b/tests/esp32s31-metadata.test.ts
new file mode 100644
index 0000000..3bd67ec
--- /dev/null
+++ b/tests/esp32s31-metadata.test.ts
@@ -0,0 +1,25 @@
+import { describe, expect, it } from 'vitest';
+import type { ESPLoader } from 'tasmota-webserial-esptool';
+import { readEsp32S31Metadata } from '../src/services/chipMetadata/esp32s31';
+
+describe('ESP32-S31 metadata', () => {
+ it('reports the documented radio and CPU capabilities without inferring variant-specific memory', async () => {
+ const loader = {
+ chipName: 'ESP32-S31',
+ chipRevision: 0,
+ } as unknown as ESPLoader;
+
+ const metadata = await readEsp32S31Metadata(loader);
+
+ expect(metadata.features).toEqual([
+ 'Wi-Fi 6',
+ 'Bluetooth 5.4 LE',
+ 'Bluetooth Classic',
+ 'IEEE 802.15.4 (Thread / Zigbee)',
+ 'Dual-core 32-bit RISC-V high-performance CPU',
+ 'Low-power RISC-V core',
+ '320 MHz',
+ ]);
+ expect(metadata.psramCap).toBeUndefined();
+ });
+});
diff --git a/tests/esptool-p4-power.test.ts b/tests/esptool-p4-power.test.ts
new file mode 100644
index 0000000..8c18c40
--- /dev/null
+++ b/tests/esptool-p4-power.test.ts
@@ -0,0 +1,88 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { ESPLoader } from "tasmota-webserial-esptool";
+import { CHIP_FAMILY_ESP32P4, CHIP_FAMILY_ESP32S3 } from "tasmota-webserial-esptool/dist/const.js";
+
+// Backport: https://github.com/Jason2866/WebSerial_ESPTool/pull/376
+// PMU bit preservation follows Espressif power_on_flash():
+// https://github.com/espressif/esptool/blob/master/esptool/targets/esp32p4.py
+// Hardware addresses from ESP32-P4's flash power initialization sequence.
+const EFUSE = 0x5012d034;
+const XPD_ON = 1 << 16;
+const PMU_DATE = 0x501153fc;
+const PAD_POWER = 0x5011010c;
+
+function setup(revision = 302, efuse = 0, date = 0) {
+ const port = { getInfo: () => ({}), open: vi.fn(), close: vi.fn(), readable: {}, writable: {} };
+ const loader = new ESPLoader(port as unknown as SerialPort, {
+ log: vi.fn(), debug: vi.fn(), error: vi.fn(),
+ });
+ loader.chipFamily = CHIP_FAMILY_ESP32P4;
+ loader.chipRevision = revision;
+ const registers = new Map([[EFUSE, efuse], [PMU_DATE, date]]);
+ const read = vi.spyOn(loader, "readRegister").mockImplementation(async address => registers.get(address) ?? 0);
+ const write = vi.spyOn(loader, "writeRegister").mockResolvedValue(undefined);
+ return { loader, read, write };
+}
+
+afterEach(() => vi.restoreAllMocks());
+
+describe("ESP32-P4 revision 3.2 power backport", () => {
+ it.each([301, 302])("powers flash when ROM has not done so (revision %i)", async revision => {
+ const { loader, read, write } = setup(revision);
+ await loader.powerOnFlash();
+ expect(write).toHaveBeenCalledWith(PAD_POWER, 1);
+ expect(write).toHaveBeenCalledWith(PMU_DATE, 3);
+ if (revision === 301) expect(read).not.toHaveBeenCalledWith(EFUSE);
+ });
+
+ it("releases ROM force-on bits without overwriting other PMU bits", async () => {
+ const { loader, write } = setup(302, XPD_ON, 0x12340003);
+ await loader.powerOnFlash();
+ expect(write.mock.calls).toEqual([[PMU_DATE, 0x12340000]]);
+ });
+
+ it.each([0, 1, 2])("skips the power sequence when ROM powers flash and force-on is %i", async bits => {
+ const { loader, write } = setup(302, XPD_ON, 0x12340000 | bits);
+ await loader.powerOnFlash();
+ expect(write).not.toHaveBeenCalled();
+ });
+
+ it.each([100, 300, 303])("leaves other P4 revisions untouched (%i)", async revision => {
+ const { loader, read, write } = setup(revision);
+ await loader.powerOnFlash();
+ expect(read).not.toHaveBeenCalled();
+ expect(write).not.toHaveBeenCalled();
+ });
+
+ it("leaves other chip families untouched", async () => {
+ const { loader, read, write } = setup();
+ loader.chipFamily = CHIP_FAMILY_ESP32S3;
+ await loader.powerOnFlash();
+ expect(read).not.toHaveBeenCalled();
+ expect(write).not.toHaveBeenCalled();
+ });
+
+ it("initializes revision 3.2 flash after chip detection", async () => {
+ const { loader, write } = setup(302, XPD_ON, 3);
+ loader.chipRevision = null;
+ vi.spyOn(loader, "readLoop").mockResolvedValue(undefined);
+ vi.spyOn(loader, "connectWithResetStrategies").mockResolvedValue(undefined);
+ vi.spyOn(loader, "detectChip").mockImplementation(async () => { loader.chipRevision = 302; });
+ await loader.initialize();
+ expect(write).toHaveBeenCalledWith(PMU_DATE, 0);
+ });
+
+ it("releases ROM force-on state before reloading the stub on reconnect", async () => {
+ const { loader, write } = setup(302, XPD_ON, 3);
+ vi.spyOn(loader, "hardReset").mockResolvedValue(undefined);
+ vi.spyOn(loader, "readLoop").mockResolvedValue(undefined);
+ vi.spyOn(loader, "flushSerialBuffers").mockResolvedValue(undefined);
+ vi.spyOn(loader, "sync").mockResolvedValue(true);
+ const stub = vi.spyOn(loader, "runStub").mockImplementation(async () => {
+ expect(write).toHaveBeenCalledWith(PMU_DATE, 0);
+ return loader;
+ });
+ await loader.reconnect();
+ expect(stub).toHaveBeenCalledWith(true);
+ });
+});
diff --git a/tests/esptool-wrapper-contract.test.ts b/tests/esptool-wrapper-contract.test.ts
index 8a8da4e..8969245 100644
--- a/tests/esptool-wrapper-contract.test.ts
+++ b/tests/esptool-wrapper-contract.test.ts
@@ -72,6 +72,11 @@ describe("tasmota-webserial-esptool wrapper contract", () => {
skipStub: true,
});
+ expect(statuses[0]).toMatchObject({
+ message: "tasmota-webserial-esptool v(7.3.10) [ESP32-S31 and ESP32-P4 rev 3.2 compatibility patches active]",
+ showInDialog: false,
+ });
+
const result = await client.connectAndHandshake();
const statusKeys = statuses
diff --git a/tests/p4-compatible-loader.test.ts b/tests/p4-compatible-loader.test.ts
new file mode 100644
index 0000000..9f332c8
--- /dev/null
+++ b/tests/p4-compatible-loader.test.ts
@@ -0,0 +1,144 @@
+import { createHash } from 'node:crypto';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { ESPLoader } from 'tasmota-webserial-esptool';
+import { CHIP_FAMILY_ESP32P4, CHIP_FAMILY_ESP32S3 } from 'tasmota-webserial-esptool/dist/const.js';
+import { getStubCode } from 'tasmota-webserial-esptool/dist/stubs/index.js';
+import { P4CompatibleLoader } from '../src/services/p4CompatibleLoader';
+
+function setup(revision = 302, vendor = 0x1a86) {
+ const signals = vi.fn().mockResolvedValue(undefined);
+ const port = {
+ getInfo: () => ({ usbVendorId: vendor, usbProductId: 0x55d3 }),
+ setSignals: signals, readable: {}, writable: {}, open: vi.fn(), close: vi.fn(),
+ };
+ const log = vi.fn();
+ const loader = new P4CompatibleLoader(port as unknown as SerialPort, { log, debug: vi.fn(), error: vi.fn() });
+ loader.chipFamily = CHIP_FAMILY_ESP32P4;
+ loader.chipRevision = revision;
+ loader.connected = true;
+ vi.spyOn(loader, 'sleep').mockResolvedValue(undefined);
+ const flush = vi.spyOn(loader, 'flushSerialBuffers').mockResolvedValue(undefined);
+ const sync = vi.spyOn(loader, 'sync').mockResolvedValue(true);
+ const power = vi.spyOn(loader, 'powerOnFlash').mockResolvedValue(undefined);
+ return { loader, port, signals, log, flush, sync, power };
+}
+
+afterEach(() => vi.restoreAllMocks());
+
+describe('P4 revision 3.2 UnixTight fallback', () => {
+ it('does not reset if the updated stub succeeds on the first attempt', async () => {
+ const { loader, signals } = setup();
+ vi.spyOn(ESPLoader.prototype, 'runStub').mockResolvedValue(loader);
+ const reset = vi.spyOn(ESPLoader.prototype, 'hardReset').mockResolvedValue(undefined);
+ await loader.runStub();
+ expect(signals).not.toHaveBeenCalled();
+ await loader.hardReset(true);
+ expect(reset).toHaveBeenCalledWith(true);
+ });
+
+ it('retries in order and remembers a successful reset for reconnects', async () => {
+ const { loader, signals, flush, sync, power } = setup();
+ const attempt = vi.spyOn(ESPLoader.prototype, 'runStub')
+ .mockRejectedValueOnce(new Error('Invalid head of packet (0x47)'))
+ .mockResolvedValue(loader);
+ const reset = vi.spyOn(ESPLoader.prototype, 'hardReset').mockResolvedValue(undefined);
+ await expect(loader.runStub()).resolves.toBe(loader);
+ expect(attempt).toHaveBeenCalledTimes(2);
+ expect(signals.mock.calls.map(([value]) => value)).toEqual([
+ { dataTerminalReady: true, requestToSend: true },
+ { dataTerminalReady: false, requestToSend: false },
+ { dataTerminalReady: false, requestToSend: true },
+ { dataTerminalReady: true, requestToSend: false },
+ { dataTerminalReady: false, requestToSend: false },
+ { dataTerminalReady: false },
+ ]);
+ expect(signals.mock.invocationCallOrder.at(-1)).toBeLessThan(flush.mock.invocationCallOrder[0]!);
+ expect(flush.mock.invocationCallOrder[0]).toBeLessThan(sync.mock.invocationCallOrder[0]!);
+ expect(sync.mock.invocationCallOrder[0]).toBeLessThan(power.mock.invocationCallOrder[0]!);
+ expect(power.mock.invocationCallOrder[0]).toBeLessThan(attempt.mock.invocationCallOrder[1]!);
+
+ signals.mockClear();
+ vi.spyOn(loader, 'readLoop').mockResolvedValue(undefined);
+ await loader.reconnect();
+ expect(signals).toHaveBeenCalledTimes(6);
+ expect(attempt).toHaveBeenLastCalledWith(true);
+ expect(power).toHaveBeenCalledTimes(2);
+ expect(reset).not.toHaveBeenCalled();
+ // Returning to firmware must retain the original reset behavior.
+ await loader.hardReset(false);
+ expect(reset).toHaveBeenCalledWith(false);
+ });
+
+ it.each([100, 300, 301, 303])('does not retry other P4 revisions (%i)', async revision => {
+ const { loader, signals } = setup(revision);
+ const error = new Error('stub failed');
+ const attempt = vi.spyOn(ESPLoader.prototype, 'runStub').mockRejectedValue(error);
+ await expect(loader.runStub()).rejects.toBe(error);
+ expect(attempt).toHaveBeenCalledTimes(1);
+ expect(signals).not.toHaveBeenCalled();
+ });
+
+ it.each(['other chip', 'native USB', 'disconnected'])('does not retry %s', async scenario => {
+ const { loader, signals } = setup(302, scenario === 'native USB' ? 0x303a : 0x1a86);
+ if (scenario === 'other chip') loader.chipFamily = CHIP_FAMILY_ESP32S3;
+ if (scenario === 'disconnected') loader.connected = false;
+ vi.spyOn(ESPLoader.prototype, 'runStub').mockRejectedValue(new Error('stub failed'));
+ await expect(loader.runStub()).rejects.toThrow('stub failed');
+ expect(signals).not.toHaveBeenCalled();
+ });
+
+ it('preserves both errors and limits recovery to one attempt per session', async () => {
+ const { loader, signals } = setup();
+ const attempt = vi.spyOn(ESPLoader.prototype, 'runStub')
+ .mockRejectedValueOnce(new Error('original failure'))
+ .mockRejectedValue(new Error('retry failure'));
+ const reset = vi.spyOn(ESPLoader.prototype, 'hardReset').mockResolvedValue(undefined);
+ await expect(loader.runStub()).rejects.toThrow('original failure. UnixTight retry failed: retry failure');
+ expect(attempt).toHaveBeenCalledTimes(2);
+ await expect(loader.runStub()).rejects.toThrow('retry failure');
+ expect(attempt).toHaveBeenCalledTimes(3);
+ expect(signals).toHaveBeenCalledTimes(6);
+ await loader.hardReset(true);
+ expect(reset).toHaveBeenCalledWith(true);
+ });
+
+ it('stops recovery if resynchronization fails', async () => {
+ const { loader, sync, power } = setup();
+ sync.mockRejectedValue(new Error('sync failed'));
+ const attempt = vi.spyOn(ESPLoader.prototype, 'runStub').mockRejectedValue(new Error('stub failed'));
+ await expect(loader.runStub()).rejects.toThrow('UnixTight retry failed: sync failed');
+ expect(attempt).toHaveBeenCalledTimes(1);
+ expect(power).not.toHaveBeenCalled();
+ });
+});
+
+describe('P4 stub backport', () => {
+ it('selects the pinned upstream stub only for revision 3.2', async () => {
+ // SHA-256 of decoded upstream text at commit 16624ad (not the JSON formatting).
+ const current = await getStubCode(CHIP_FAMILY_ESP32P4, 302);
+ expect(createHash('sha256').update(Uint8Array.from(current!.text)).digest('hex'))
+ .toBe('994233fd1966e1c3175445bebf5c6c6f2862d77b22ce7451466e0f5dc36b1f9b');
+ for (const revision of [300, 301, 303]) {
+ const previous = await getStubCode(CHIP_FAMILY_ESP32P4, revision);
+ expect(createHash('sha256').update(Uint8Array.from(previous!.text)).digest('hex'))
+ .toBe('dde6e950e0fabf297497b781331b204d3eb355b1b5e733b62587585b98f0248e');
+ }
+ });
+
+ it.each([301, 302])('reads flash and acknowledges every packet (revision %i)', async revision => {
+ const { loader } = setup(revision);
+ loader.IS_STUB = true;
+ const command = vi.spyOn(loader, 'checkCommand').mockResolvedValue([0, []]);
+ const packet = Array.from({ length: 4096 }, (_, i) => i % 256);
+ vi.spyOn(loader, 'readPacket').mockResolvedValue(packet);
+ const ack = vi.spyOn(loader, 'writeToStream').mockResolvedValue(undefined);
+ const data = await loader.readFlash(0, 8192);
+ expect(data).toEqual(Uint8Array.from([...packet, ...packet]));
+ const request = Uint8Array.from(command.mock.calls[0]![1]!);
+ expect(new DataView(request.buffer).getUint32(12, true)).toBe(revision === 302 ? 64 : 1024);
+ expect(ack.mock.calls).toEqual([
+ [[0xc0, 0, 0x10, 0, 0, 0xc0]],
+ [[0xc0, 0, 0x20, 0, 0, 0xc0]],
+ ]);
+ });
+});