@esp-vision/device-core 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/README.md +50 -7
- package/package.json +12 -6
- package/schemas/device-actions-v1.json +1 -1
- package/scripts/verify-registry.mjs +2 -2
- package/src/browser.js +61 -0
- package/src/browser_rom.js +9 -0
- package/src/browser_rom_compat.js +65 -0
- package/src/cli.js +14 -8
- package/src/firmware.js +4 -0
- package/src/firmware_feed.js +117 -3
- package/src/firmware_flash.js +39 -11
- package/src/firmware_io.js +68 -0
- package/src/firmware_release.js +47 -0
- package/src/firmware_updates.js +31 -24
- package/src/node.js +5 -0
- package/src/node_rom.js +1 -0
- package/src/node_rom.py +2 -0
- package/tests/check-browser.mjs +151 -0
- package/tests/check-package.mjs +6 -2
- package/tests/test-browser-rom.mjs +85 -0
- package/tests/test-firmware-install.mjs +126 -0
- package/tests/test-firmware-source.mjs +143 -0
- package/tests/test-release-manifest.mjs +214 -0
- package/tests/test-release-verification.mjs +12 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.0 (unreleased)
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- Reuse browser cache only for hash-pinned release manifests and complete-BIN sources; retry a fetch-stage TypeError once with no-store for cached CORS recovery, while hash/HTTP/metadata failures and legacy app-only downloads retain strict failure/cache behavior.
|
|
8
|
+
|
|
9
|
+
- Export immutable official release-manifest readers with publication/version/board/hash pinning and bounded, source-constrained validation of firmware, website, models and MCP snapshots; model files are unique by path while a group may share one slug, and normalized paths cannot escape the pinned source commit.
|
|
10
|
+
- Support application updates from the qualified complete BIN through `image.source`, verifying the whole container and embedded segments before extracting the application; preserve explicit full installation and legacy app-only descriptors.
|
|
11
|
+
|
|
12
|
+
- Export passive `openBrowserRom({ port, logger?, signal? })` at `/browser` with a lazily loaded, pinned tasmota browser backend; share verified Core firmware install/update workflows without host-maintained ROM patches.
|
|
13
|
+
- Validate the real browser bundle, P4/S31 stub assets, adapter cleanup/cancellation and isolated package imports.
|
|
14
|
+
|
|
15
|
+
## 0.3.0
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- Export the passive built-in `openNodeRom({ port, python?, signal? })` adapter from `/node` so Node SDK hosts and the CLI share the same ROM implementation.
|
|
20
|
+
|
|
21
|
+
- Explicit `mode: 'install'` on firmware check/update and CLI `--mode install` for blank-device ROM installation over USJ/UART, using qualified complete images with bootloader/partition/application hashes and flash-capacity checks. Default application-only updates retain their existing data-preservation contract; no automatic fallback or TCP OTA is introduced.
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- Allow up to 151 seconds of bounded metadata propagation backoff after npm accepts publication, with ten uncached verification reads and no repeated publish.
|
|
26
|
+
|
|
3
27
|
## 0.2.0
|
|
4
28
|
|
|
5
29
|
### Changed
|
package/README.md
CHANGED
|
@@ -19,10 +19,10 @@
|
|
|
19
19
|
| Visibility | Public; installation needs no npm login |
|
|
20
20
|
| Source location | `host/device-core` in the ESP-VISION firmware repository |
|
|
21
21
|
|
|
22
|
-
The `/node`
|
|
22
|
+
This source prepares **0.4.0 (not published by this change)** with browser ROM flashing, immutable release-manifest readers and application updates from a single complete BIN. npm 0.3.0 does not contain these APIs. The `/node` byte transports and bundled checks require 0.1.1; complete installation and `/node` `openNodeRom` are introduced in 0.3.0. Check the registry before using the 0.4.0 install examples; until publication, use a tarball packed from an exact reviewed source commit. CI authenticates with the protected `NPM_PUBLISH_TOKEN` and verifies that `npm whoami` returns `esp-vision` before publishing. Tokens are not part of this package or its documentation.
|
|
23
23
|
|
|
24
24
|
```sh
|
|
25
|
-
npm install --save-exact @esp-vision/device-core@0.
|
|
25
|
+
npm install --save-exact @esp-vision/device-core@0.4.0 --registry=https://registry.npmjs.org/
|
|
26
26
|
```
|
|
27
27
|
|
|
28
28
|
EV-MUX v4 firmware is required. A v3 hello is rejected with `VERSION_MISMATCH`; applications should ask the user to update firmware. Core has no runtime dependency on the IDE, Agent, DOM or Node serial libraries. The host supplies the physical byte transport and discovery adapter; Core owns protocol parsing, session negotiation, REPL coordination and device operations.
|
|
@@ -31,7 +31,7 @@ EV-MUX v4 firmware is required. A v3 hello is rejected with `VERSION_MISMATCH`;
|
|
|
31
31
|
|
|
32
32
|
Read the Device Core guide in the **ESP-VISION Documentation Center**: [English](https://docs.espressif.com/projects/esp-vision/en/latest/esp32p4/device-core/index.html) | [简体中文](https://docs.espressif.com/projects/esp-vision/zh_CN/latest/esp32p4/device-core/index.html).
|
|
33
33
|
|
|
34
|
-
- [All application APIs](https://docs.espressif.com/projects/esp-vision/en/latest/esp32p4/device-core/api.html), including firmware version checks and
|
|
34
|
+
- [All application APIs](https://docs.espressif.com/projects/esp-vision/en/latest/esp32p4/device-core/api.html), including firmware version checks, application updates and blank-device installation.
|
|
35
35
|
- [Advanced exports](https://docs.espressif.com/projects/esp-vision/en/latest/esp32p4/device-core/advanced.html) and [TCP/Web Serial integration](https://docs.espressif.com/projects/esp-vision/en/latest/esp32p4/device-core/integration.html).
|
|
36
36
|
|
|
37
37
|
Documentation source lives in this repository under `docs/en/device-core/` and `docs/zh_CN/device-core/` and changes alongside SDK APIs in the same MR. The documentation site renders the RST as HTML; it does not redirect readers to npm or require downloading documentation. The npm package contains the runtime library and this README, not a second RST documentation distribution. Use the documentation center's version selector when reading a published firmware/documentation version; `latest` follows the current development documentation. These new chapter links become available after the corresponding documentation is deployed.
|
|
@@ -41,7 +41,7 @@ Documentation source lives in this repository under `docs/en/device-core/` and `
|
|
|
41
41
|
The npm package installs `esp-vision-device`. `inspect` combines passive wired/LAN enumeration and targeted queries. By default it lists USB serial candidates; `--all-ports` also includes native UART/Bluetooth ports. Explicit `--port` bypasses discovery filtering; `configure`, `control`, and `firmware check/update` cover management. See the [CLI guide](https://docs.espressif.com/projects/esp-vision/en/latest/esp32p4/device-core/cli.html) ([中文](https://docs.espressif.com/projects/esp-vision/zh_CN/latest/esp32p4/device-core/cli.html)).
|
|
42
42
|
|
|
43
43
|
```sh
|
|
44
|
-
npm install -g @esp-vision/device-core@0.
|
|
44
|
+
npm install -g @esp-vision/device-core@0.4.0
|
|
45
45
|
esp-vision-device inspect
|
|
46
46
|
esp-vision-device inspect --all-ports
|
|
47
47
|
esp-vision-device inspect --interface usj,usb --probe --json
|
|
@@ -49,7 +49,7 @@ esp-vision-device inspect --port COM3 --fields identity,capabilities
|
|
|
49
49
|
esp-vision-device inspect --tcp 192.168.1.100:3232 --fields network,storage --json
|
|
50
50
|
```
|
|
51
51
|
|
|
52
|
-
Wired access/enumeration requires Python with pyserial (`--python` overrides the interpreter); LAN discovery uses optional `multicast-dns`, normally installed by npm. Windows/Linux/macOS paths are accepted. Passive enumeration never opens a serial port; `--probe` and targeted queries acquire a session and may interrupt Python. Unidentified ports, ROM candidates and USB-to-UART adapters remain numbered and visible. Board names are unknown until firmware identification; USB descriptors are hints only. Firmware versions are fetched automatically from dl.espressif.com. Firmware flashing uses the built-in serial adapter with separately installed Python esptool>=5.3,<6; no user loader module is required. Historical releases without qualified
|
|
52
|
+
Wired access/enumeration requires Python with pyserial (`--python` overrides the interpreter); LAN discovery uses optional `multicast-dns`, normally installed by npm. Windows/Linux/macOS paths are accepted. Passive enumeration never opens a serial port; `--probe` and targeted queries acquire a session and may interrupt Python. Unidentified ports, ROM candidates and USB-to-UART adapters remain numbered and visible. Board names are unknown until firmware identification; USB descriptors are hints only. Firmware versions are fetched automatically from dl.espressif.com. Firmware flashing uses the built-in serial adapter with separately installed Python esptool>=5.3,<6; no user loader module is required. Use `firmware update --mode install --board BOARD --port PORT --version VERSION` for blank-device installation over a ROM-capable USJ/UART port. Install replaces data within the complete image range; default updates preserve the existing layout. Historical releases without qualified metadata remain listed but cannot be installed by this flow. Check the registry version before installing a newly prepared release.
|
|
53
53
|
|
|
54
54
|
## Public device API
|
|
55
55
|
|
|
@@ -65,7 +65,7 @@ Import `createDevice` from the package root. Application code uses one device in
|
|
|
65
65
|
| Interactive Python | `python.input(data)` | Send terminal input without uploading a file |
|
|
66
66
|
| Python execution | `python.execute({ source, path, completion })` | Execute source or a device-side script |
|
|
67
67
|
| Files | `files.list/read/write/remove/mkdir(request)` | File and directory operations, with one object argument |
|
|
68
|
-
| Firmware | `firmware.check(request)`, `firmware.update(request)` | List board firmware versions, select an exact version and perform verified ROM application updates |
|
|
68
|
+
| Firmware | `firmware.check(request)`, `firmware.update(request)` | List board firmware versions, select an exact version and perform verified ROM application updates or explicit complete installation on blank devices |
|
|
69
69
|
| Capture | `capture({ source, format, quality })` | Capture an image from a supported source |
|
|
70
70
|
| Events | `on(event, handler)` | Subscribe; returns an unsubscribe function |
|
|
71
71
|
|
|
@@ -110,6 +110,47 @@ try {
|
|
|
110
110
|
}
|
|
111
111
|
```
|
|
112
112
|
|
|
113
|
+
### Immutable release snapshots and a single firmware BIN (0.4.0, unpublished)
|
|
114
|
+
|
|
115
|
+
The `/firmware` entry exports `loadOfficialFirmware({ board?, version?, fetch?, signal? })` and `loadReleaseManifest({ url, sha256, version?, sourceCommit?, boards?, fetch?, signal? })`. The official reader prioritizes `[publication]` in `config.toml`, selects exactly one version reference and verifies its raw manifest SHA-256 before returning `{ schemaVersion: 1, version, sourceCommit, pipelineId, layout, releases, website, mcp?, publication }`. History references include exact board sets; unsupported board/version combinations and malformed explicit publication data reject without falling back to guessed URLs or old metadata. Configs without `[publication]` keep the legacy feed; an explicit version limits old metadata requests too.
|
|
116
|
+
|
|
117
|
+
For a website built from a verified snapshot, keep its selected publication reference rather than re-reading mutable `config.toml` when the user clicks:
|
|
118
|
+
|
|
119
|
+
```js
|
|
120
|
+
import { loadReleaseManifest } from '@esp-vision/device-core/firmware';
|
|
121
|
+
const snapshot = await loadReleaseManifest({
|
|
122
|
+
url: reference.manifestUrl, sha256: reference.manifestSha256,
|
|
123
|
+
version: reference.version, boards: reference.boards,
|
|
124
|
+
});
|
|
125
|
+
const release = snapshot.releases.find(entry => entry.board === selectedBoard);
|
|
126
|
+
// Pass release to createDevice().firmware.update(); the website never slices BINs.
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
In `layout: 'single-bin'`, `image.source = { url, bytes, sha256, offset }` describes the complete BIN; `image.url` equals its URL, while `image.bytes/sha256` still describe only the application. The source URL/length/hash must match `install`, and its offset must equal the application's flash offset. Core downloads the full container once (no Range request), validates its whole hash and embedded bootloader/partition/application hashes, then extracts and writes only the application in default update mode. Existing partition checks and full application readback remain mandatory; `mode: 'install'` writes the qualified complete BIN. With `image.source`, supplied `data` must also be the complete BIN; old application-only metadata keeps its existing raw-app `data` semantics. Manifests are bounded at 4 MiB, firmware downloads at 256 MiB and their approved exact size. Official manifest/artifact sources are confined to the ESP-VISION CDN root; model URLs use their full source commit and MCP has a version/commit/hash descriptor. No npm, firmware tag or CDN publication is performed by preparing this source version.
|
|
130
|
+
|
|
131
|
+
To validate before npm publication, check out an exact reviewed commit, run `npm install --ignore-scripts --no-audit --no-fund --package-lock=false` and `npm run release:check` in `host/device-core`, then `npm pack --ignore-scripts --pack-destination <project-local-output>`. Install that locally produced tarball or use a source archive pinned to that exact commit. Do not claim that an unpublished 0.4.0 is available in the registry.
|
|
132
|
+
|
|
133
|
+
### Browser ROM flashing (unreleased)
|
|
134
|
+
|
|
135
|
+
`@esp-vision/device-core/browser` exports `openBrowserRom({ port, logger?, signal? })`. Build from this branch until a new SDK release is published; installing 0.3.0 does not enable this API. The constructor is passive, and `initialize()` lazily loads the pinned `tasmota-webserial-esptool` browser backend. Use a browser bundler such as webpack, Vite or esbuild; direct native ESM loading of the upstream backend is not supported. No Python installation or consumer-side tasmota patch is needed. The root and `/node` entry points do not load browser modules.
|
|
136
|
+
|
|
137
|
+
```js
|
|
138
|
+
import { createDevice } from '@esp-vision/device-core';
|
|
139
|
+
import { openBrowserRom } from '@esp-vision/device-core/browser';
|
|
140
|
+
|
|
141
|
+
// Request permission directly in the user's click handler (HTTPS or localhost).
|
|
142
|
+
const port = await navigator.serial.requestPort();
|
|
143
|
+
// release comes from firmware.check(), selected for the physical board.
|
|
144
|
+
const result = await createDevice().firmware.update({
|
|
145
|
+
release, board: release.board, mode: 'update', // Explicit 'install' for complete images.
|
|
146
|
+
openLoader: ({ signal }) => openBrowserRom({ port, signal, logger: console }),
|
|
147
|
+
onProgress: percent => updateProgress(percent),
|
|
148
|
+
});
|
|
149
|
+
if (result.needsReset) showManualReset();
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Core owns downloads, chip/revision/security and partition/capacity checks, write, full readback and reset. The adapter owns the selected port until Core disconnects it; the UI must first release any runtime serial connection. It starts no stub during construction or chip initialization: Core checks the approved image and ROM security before calling `runStub`. P4 revision 301/302 flash power and S31 support come from pinned upstream 9.4.2; Core retains bounded SLIP-prefix tolerance, native USB stub startup confirmation and size-scaled erase timeouts. Unsupported stubs fail explicitly. Keep the page and port alive until the update Promise settles; cancellation is accepted before writing, never used to interrupt a started write/readback. This ROM adapter does not provide runtime EV-MUX serial transport or Wi-Fi/TCP OTA.
|
|
153
|
+
|
|
113
154
|
### Official Node adapters
|
|
114
155
|
|
|
115
156
|
The Node-only `@esp-vision/device-core/node` subpath exports `tcpTransport({ host, port })` and `serialTransport({ serial, python })`. Constructors are passive; Core opens/closes the link during `connect()`/`disconnect()`. Serial needs pyserial in the selected Python interpreter, and uses role `usj`, `usb` or `uart` for the actual interface. The root/browser entry points do not load Node networking/process modules.
|
|
@@ -220,7 +261,7 @@ const image = await device.capture({ source: 'camera', format: 'jpeg', timeout:
|
|
|
220
261
|
|
|
221
262
|
Paths are absolute device paths. `files.list({ path: '/', recursive: false })` returns entries `{ name, path, type, size }`. `files.read({ path })` returns a `Uint8Array`; use `encoding: 'utf-8'` to return text, and `maxBytes` to bound a read. Writes default to `overwrite: true`; set it to `false` to require a new destination. Uploading and executing are separate operations; create an empty file with `files.write({ path, data: new Uint8Array() })` rather than a second touch API. Remove an empty directory with `files.remove({ path: '/models', type: 'directory' })`; there is no implicit recursive deletion. `files.mkdir({ path, recursive: true })` creates missing parents.
|
|
222
263
|
|
|
223
|
-
**Binary uploads (SDK 0.
|
|
264
|
+
**Binary uploads (SDK 0.3.0):** text, model and image uploads share reliable raw binary `file.write` frames with 16 KiB blocks, continuous native VFS writes and SHA-256 commit. Matching firmware must advertise `file.write.binary`; missing support rejects with a firmware-upgrade message, without a slow upload fallback. File progress is not a durable commit. FAT replacement preserves an old-file backup until ordinary publication succeeds, with rollback on ordinary rename failure; it is not a power-loss-atomic journal. The filesystem must be mounted and allow writing, with room for the temporary upload and any existing destination file. File reads and directory operations currently use the REPL.
|
|
224
265
|
|
|
225
266
|
`capture({ source: 'screen', format: 'jpeg' })` uses the dedicated `debug.capture_screen` RPC to encode the LCD's last submitted RGB565 framebuffer, including the display's scaling and letterboxing. It does not read the panel electronics or capture the camera. Camera capture uses `debug.capture_frame`. Both return binary JPEG responses correlated to the request; optional `quality` is an integer from 1 to 100. Missing firmware capabilities reject with `UNSUPPORTED_OPERATION`; an uninitialized display returns `DISPLAY_NOT_READY`, and a board without an LCD returns `NOT_SUPPORTED`. Preview is a separate real-time stream and may drop frames under congestion.
|
|
226
267
|
|
|
@@ -343,3 +384,5 @@ TCP needs only Node.js. Serial additionally needs a Python interpreter with the
|
|
|
343
384
|
## License
|
|
344
385
|
|
|
345
386
|
The npm package and ESP-VISION contributions use [Apache-2.0](LICENSE), matching the firmware. [NOTICE](NOTICE) retains the original copyright and MIT permission text for historical and upstream-derived portions. Third-party dependencies retain their own licenses; neither package metadata nor the project license overrides them.
|
|
387
|
+
|
|
388
|
+
Hash-pinned manifests and complete-BIN sources use the browser HTTP cache (`cache: default`); mutable config and legacy app-only BINs retain `no-store`. A fetch-stage TypeError on a pinned asset gets one `no-store` retry to recover from an older cached response lacking CORS headers. HTTP, body-read, SHA-256 and metadata failures are not retried or downgraded.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@esp-vision/device-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Shared EV-MUX, framed REPL, device session, and action contracts for ESP Vision clients.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -10,10 +10,11 @@
|
|
|
10
10
|
"./session": "./src/device_session.js",
|
|
11
11
|
"./actions": "./src/device_actions.js",
|
|
12
12
|
"./bindings": "./src/device_bindings.js",
|
|
13
|
-
"./firmware": "./src/
|
|
13
|
+
"./firmware": "./src/firmware.js",
|
|
14
14
|
"./discovery": "./src/network_discovery.js",
|
|
15
15
|
"./discovery/node": "./src/network_discovery_node.js",
|
|
16
|
-
"./node": "./src/
|
|
16
|
+
"./node": "./src/node.js",
|
|
17
|
+
"./browser": "./src/browser.js"
|
|
17
18
|
},
|
|
18
19
|
"files": [
|
|
19
20
|
"src/*.js",
|
|
@@ -33,10 +34,11 @@
|
|
|
33
34
|
"check:schema": "node scripts/generate-action-schema.mjs --check",
|
|
34
35
|
"schema:actions": "node scripts/generate-action-schema.mjs",
|
|
35
36
|
"check:package": "node tests/check-package.mjs",
|
|
36
|
-
"release:check": "npm test && npm run check:schema && npm run check:package && node scripts/release-npm.mjs --dry-run",
|
|
37
|
+
"release:check": "npm test && npm run check:schema && npm run check:browser && npm run check:package && node scripts/release-npm.mjs --dry-run",
|
|
37
38
|
"release:npm": "node scripts/release-npm.mjs",
|
|
38
39
|
"prepublishOnly": "npm run release:check",
|
|
39
|
-
"test:hardware": "node tests/hardware-check.mjs"
|
|
40
|
+
"test:hardware": "node tests/hardware-check.mjs",
|
|
41
|
+
"check:browser": "node tests/check-browser.mjs"
|
|
40
42
|
},
|
|
41
43
|
"repository": {
|
|
42
44
|
"type": "git",
|
|
@@ -74,6 +76,10 @@
|
|
|
74
76
|
},
|
|
75
77
|
"dependencies": {
|
|
76
78
|
"smol-toml": "^1.7.0",
|
|
77
|
-
"string-width": "^7.2.0"
|
|
79
|
+
"string-width": "^7.2.0",
|
|
80
|
+
"tasmota-webserial-esptool": "9.4.2"
|
|
81
|
+
},
|
|
82
|
+
"devDependencies": {
|
|
83
|
+
"esbuild": "0.25.12"
|
|
78
84
|
}
|
|
79
85
|
}
|
|
@@ -8,7 +8,7 @@ export async function verifyPublishedIntegrity(url, integrity, {
|
|
|
8
8
|
wait = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds)),
|
|
9
9
|
report = message => console.log(message),
|
|
10
10
|
} = {}) {
|
|
11
|
-
const attempts =
|
|
11
|
+
const attempts = 10
|
|
12
12
|
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
13
13
|
let response, transient
|
|
14
14
|
try {
|
|
@@ -29,7 +29,7 @@ export async function verifyPublishedIntegrity(url, integrity, {
|
|
|
29
29
|
transient = `HTTP ${response.status}`
|
|
30
30
|
}
|
|
31
31
|
assert.ok(attempt + 1 < attempts, `Published metadata verification unavailable after ${attempts} reads (${transient}); retry the CI job, not publication manually`)
|
|
32
|
-
const delay = 1000 * 2 ** attempt
|
|
32
|
+
const delay = Math.min(30000, 1000 * 2 ** attempt)
|
|
33
33
|
report(`Registry verification ${transient}; retrying read ${attempt + 2}/${attempts} in ${delay}ms`)
|
|
34
34
|
await wait(delay)
|
|
35
35
|
}
|
package/src/browser.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
/** Passive browser ROM adapter. Request the SerialPort in the UI's click handler. */
|
|
5
|
+
export function openBrowserRom({ port, logger = {}, signal } = {}) {
|
|
6
|
+
if (!port || typeof port.open !== 'function' || typeof port.close !== 'function') throw new TypeError('A browser SerialPort is required')
|
|
7
|
+
if (signal !== undefined && (!signal || typeof signal.addEventListener !== 'function' || typeof signal.aborted !== 'boolean')) throw new TypeError('signal must be an AbortSignal')
|
|
8
|
+
let raw, initialized, closing = false, cleanup
|
|
9
|
+
const closed = () => Object.assign(new Error('Browser ROM adapter is closed'), { code: 'ROM_CLOSED' })
|
|
10
|
+
const cancelled = () => signal.reason instanceof Error ? signal.reason : Object.assign(new Error('Firmware operation cancelled'), { name: 'AbortError' })
|
|
11
|
+
const check = () => { if (closing) throw closed(); if (signal?.aborted) throw cancelled() }
|
|
12
|
+
const disconnect = () => {
|
|
13
|
+
closing = true
|
|
14
|
+
// Opening may still be in flight. initialize closes again after acquiring it.
|
|
15
|
+
if (!raw) return Promise.resolve()
|
|
16
|
+
return cleanup ??= raw.disconnect()
|
|
17
|
+
}
|
|
18
|
+
const ready = () => {
|
|
19
|
+
if (closing || !raw) throw closed()
|
|
20
|
+
return raw
|
|
21
|
+
}
|
|
22
|
+
const adapter = {
|
|
23
|
+
get chipName() { return raw?.chipName ?? null },
|
|
24
|
+
get chipFamily() { return raw?.chipFamily ?? null },
|
|
25
|
+
get chipRevision() { return raw?.chipRevision ?? null },
|
|
26
|
+
initialize() {
|
|
27
|
+
if (closing) return Promise.reject(closed())
|
|
28
|
+
if (initialized) return initialized
|
|
29
|
+
initialized = (async () => {
|
|
30
|
+
check()
|
|
31
|
+
const { createBrowserLoader } = await import('./browser_rom.js')
|
|
32
|
+
check()
|
|
33
|
+
let rejectAbort
|
|
34
|
+
const aborted = new Promise((_, reject) => { rejectAbort = reject })
|
|
35
|
+
const abort = () => { rejectAbort(cancelled()); void disconnect().catch(() => {}) }
|
|
36
|
+
signal?.addEventListener('abort', abort, { once: true })
|
|
37
|
+
const opening = (async () => {
|
|
38
|
+
raw = await createBrowserLoader(port, {
|
|
39
|
+
log: (...args) => logger.log?.(...args),
|
|
40
|
+
error: (...args) => logger.error?.(...args),
|
|
41
|
+
debug: (...args) => logger.debug?.(...args),
|
|
42
|
+
})
|
|
43
|
+
if (closing || signal?.aborted) { await disconnect(); check() }
|
|
44
|
+
await raw.initialize()
|
|
45
|
+
check()
|
|
46
|
+
})()
|
|
47
|
+
try {
|
|
48
|
+
if (signal?.aborted) abort()
|
|
49
|
+
await Promise.race([opening, aborted])
|
|
50
|
+
} catch (error) { await disconnect().catch(() => {}); throw error }
|
|
51
|
+
finally { signal?.removeEventListener('abort', abort) }
|
|
52
|
+
})()
|
|
53
|
+
return initialized
|
|
54
|
+
},
|
|
55
|
+
getSecurityInfo: () => ready().getSecurityInfo(),
|
|
56
|
+
runStub: skip => ready().runStub(skip),
|
|
57
|
+
hardReset: () => ready().hardReset(),
|
|
58
|
+
disconnect,
|
|
59
|
+
}
|
|
60
|
+
return adapter
|
|
61
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
// Loaded only by openBrowserRom.initialize(), never by the root/Node entry point.
|
|
4
|
+
import { connectWithPort } from 'tasmota-webserial-esptool'
|
|
5
|
+
import { browserRomCompatibility } from './browser_rom_compat.js'
|
|
6
|
+
|
|
7
|
+
export async function createBrowserLoader(port, logger) {
|
|
8
|
+
return browserRomCompatibility(await connectWithPort(port, logger))
|
|
9
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
// Compatibility for pinned tasmota-webserial-esptool 9.4.2. Keep changes per
|
|
4
|
+
// instance: consumers need neither package-manager patches nor global prototypes.
|
|
5
|
+
export function browserRomCompatibility(loader) {
|
|
6
|
+
const readPacket = loader.readPacket
|
|
7
|
+
loader.readPacket = async function (timeout) {
|
|
8
|
+
const deadline = Date.now() + timeout
|
|
9
|
+
for (;;) {
|
|
10
|
+
try { return await readPacket.call(this, Math.max(1, deadline - Date.now())) }
|
|
11
|
+
catch (error) {
|
|
12
|
+
// Only a byte before the SLIP header is safe to discard. Preserve invalid
|
|
13
|
+
// escapes, mid-packet errors, I/O failures and the overall timeout.
|
|
14
|
+
if (!/^Invalid head of packet \(/.test(error.message) || Date.now() >= deadline) throw error
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
const runStub = loader.runStub
|
|
19
|
+
loader.runStub = async function (skipFlashDetection = false) {
|
|
20
|
+
const memFinish = this.memFinish, packet = this.readPacket
|
|
21
|
+
const nativeUsb = this.isUsbJtagOrOtg === true
|
|
22
|
+
if (nativeUsb) {
|
|
23
|
+
// USB can reset before ROM sends MEM_END's reply. Do not claim success:
|
|
24
|
+
// upstream must still receive and validate OHAI from the running stub.
|
|
25
|
+
this.memFinish = async (...args) => {
|
|
26
|
+
try { return await memFinish.apply(this, args) }
|
|
27
|
+
catch (error) {
|
|
28
|
+
if (!/timed?\s*out|timeout/i.test(error.message)) throw error
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
this.readPacket = timeout => packet.call(this, Math.max(timeout, 5000))
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
const stub = await runStub.call(this, skipFlashDetection)
|
|
35
|
+
if (!stub.IS_STUB) throw new Error('This device has no supported browser flash stub')
|
|
36
|
+
browserStubCompatibility(stub)
|
|
37
|
+
return stub
|
|
38
|
+
} finally { this.memFinish = memFinish; this.readPacket = packet }
|
|
39
|
+
}
|
|
40
|
+
return loader
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function browserStubCompatibility(stub) {
|
|
44
|
+
// Official esp-flasher-stub erases the entire region in FLASH_BEGIN. Its erase
|
|
45
|
+
// duration must scale with image size rather than the short command timeout.
|
|
46
|
+
const command = stub.checkCommand
|
|
47
|
+
stub.checkCommand = function (opcode, buffer, checksum, timeout) {
|
|
48
|
+
if (opcode === 0x02 && buffer?.length >= 4) {
|
|
49
|
+
const size = (buffer[0] | buffer[1] << 8 | buffer[2] << 16 | buffer[3] << 24) >>> 0
|
|
50
|
+
timeout = Math.max(timeout ?? 0, 3000, 30000 * size / 1000000)
|
|
51
|
+
}
|
|
52
|
+
return command.call(this, opcode, buffer, checksum, timeout)
|
|
53
|
+
}
|
|
54
|
+
// Stub commands use their own loader instance and inherit the same SLIP issue.
|
|
55
|
+
const readPacket = stub.readPacket
|
|
56
|
+
stub.readPacket = async function (timeout) {
|
|
57
|
+
const deadline = Date.now() + timeout
|
|
58
|
+
for (;;) {
|
|
59
|
+
try { return await readPacket.call(this, Math.max(1, deadline - Date.now())) }
|
|
60
|
+
catch (error) {
|
|
61
|
+
if (!/^Invalid head of packet \(/.test(error.message) || Date.now() >= deadline) throw error
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
package/src/cli.js
CHANGED
|
@@ -21,9 +21,9 @@ esp-vision-device configure (--port PATH | --tcp HOST[:PORT])
|
|
|
21
21
|
esp-vision-device control (--port PATH | --tcp HOST[:PORT])
|
|
22
22
|
--action stop|reset|bootloader [--mode soft|hard]
|
|
23
23
|
esp-vision-device firmware check [--port PATH | --tcp HOST[:PORT]]
|
|
24
|
-
[--catalog FILE|HTTPS_URL] [--board BOARD --current-version VERSION] [--version VERSION]
|
|
24
|
+
[--catalog FILE|HTTPS_URL] [--board BOARD --current-version VERSION] [--version VERSION] [--mode update|install]
|
|
25
25
|
esp-vision-device firmware update [--port PATH | --tcp HOST[:PORT]]
|
|
26
|
-
[--catalog FILE|HTTPS_URL] --version VERSION [--image
|
|
26
|
+
[--catalog FILE|HTTPS_URL] --version VERSION [--image FIRMWARE.bin] [--mode update|install]
|
|
27
27
|
[--board BOARD --current-version VERSION]
|
|
28
28
|
Common: --timeout MS (default 3000 enumeration, 30000 command),
|
|
29
29
|
--probe-timeout MS (default 5000 per device), --role usb|usj|uart|unknown,
|
|
@@ -33,7 +33,10 @@ also includes native UART/Bluetooth ports. No serial
|
|
|
33
33
|
port is opened. --probe and targeted commands acquire a session and can interrupt
|
|
34
34
|
Python. Rows with unknown firmware remain visible; numbering is per invocation.
|
|
35
35
|
Firmware versions come from dl.espressif.com by default; local catalogs are advanced overrides.
|
|
36
|
-
Firmware update uses the built-in serial flasher (Python esptool>=5.3,<6). TCP is not OTA
|
|
36
|
+
Firmware update uses the built-in serial flasher (Python esptool>=5.3,<6). TCP is not OTA.
|
|
37
|
+
Default mode update preserves the existing layout/data; explicit mode install writes
|
|
38
|
+
a complete image, replacing data in its written range. Blank boards require --board
|
|
39
|
+
and a ROM-capable USJ/UART port, not an application USB-OTG interface.`
|
|
37
40
|
const error = (code, message) => Object.assign(new Error(message), { code })
|
|
38
41
|
const fields = ['identity', 'capabilities', 'runtime', 'network', 'storage']
|
|
39
42
|
export function parseCli(args) {
|
|
@@ -43,7 +46,7 @@ export function parseCli(args) {
|
|
|
43
46
|
if (v.help) return { help: true }
|
|
44
47
|
const command = p[0] ?? 'inspect', action = command === 'firmware' ? p[1] : null
|
|
45
48
|
if (!['inspect', 'configure', 'control', 'firmware'].includes(command) || p.length > (command === 'firmware' ? 2 : 1) || (command === 'firmware' && !['check', 'update'].includes(action))) throw error('ARGUMENT', 'Unknown command; use --help')
|
|
46
|
-
const allowed = { inspect: ['all-ports', 'select', 'fields', 'interface', 'probe', 'probe-timeout'], configure: ['ssid', 'forget-wifi', 'password-stdin', 'network-enabled'], control: ['action', 'mode'], firmware: ['catalog', 'board', 'current-version', 'version', 'image'] }[command]
|
|
49
|
+
const allowed = { inspect: ['all-ports', 'select', 'fields', 'interface', 'probe', 'probe-timeout'], configure: ['ssid', 'forget-wifi', 'password-stdin', 'network-enabled'], control: ['action', 'mode'], firmware: ['mode', 'catalog', 'board', 'current-version', 'version', 'image'] }[command]
|
|
47
50
|
for (const key of Object.keys(v)) if (!['json', 'port', 'tcp', 'python', 'timeout', 'role', ...allowed].includes(key)) throw error('ARGUMENT', `Option --${key} is not valid for ${command}`)
|
|
48
51
|
if ([v.port, v.tcp, v.select].filter(value => value !== undefined).length > 1) throw error('ARGUMENT', 'Select exactly one device address or number')
|
|
49
52
|
if (v.role && !['usb', 'usj', 'uart', 'unknown'].includes(v.role)) throw error('ARGUMENT', 'Invalid serial role')
|
|
@@ -74,6 +77,8 @@ export function parseCli(args) {
|
|
|
74
77
|
}
|
|
75
78
|
if (command === 'control' && (!['stop', 'reset', 'bootloader'].includes(v.action) || (v.action === 'reset' ? !['soft', 'hard'].includes(v.mode) : v.mode !== undefined))) throw error('ARGUMENT', 'Control requires action; reset requires mode soft or hard')
|
|
76
79
|
if (command === 'firmware') {
|
|
80
|
+
if (v.mode !== undefined && !['update', 'install'].includes(v.mode)) throw error('ARGUMENT', 'Firmware mode must be update or install')
|
|
81
|
+
if (v.mode === 'install' && !v.board) throw error('ARGUMENT', 'Firmware installation requires explicit --board')
|
|
77
82
|
if (!endpoint && !v.board) throw error('ARGUMENT', 'Firmware requires a connected device or --board')
|
|
78
83
|
if (action === 'update' && (!v.port || !v.version)) throw error('ARGUMENT', 'Firmware update requires local --port and explicit --version')
|
|
79
84
|
if (action === 'check' && v.image) throw error('ARGUMENT', 'Image applies only to firmware update')
|
|
@@ -108,9 +113,9 @@ export async function runCli(options, dependencies = {}) {
|
|
|
108
113
|
endpoint = listing.devices.find(row => row.number === options.select)
|
|
109
114
|
if (!endpoint) throw error('DEVICE_NOT_FOUND', 'Selected result number is unavailable in this enumeration')
|
|
110
115
|
}
|
|
111
|
-
const
|
|
116
|
+
const offline = options.command === 'firmware' && options.board
|
|
117
|
+
const device = makeDevice(endpoint && !offline ? { transport: (dependencies.transportFor ?? transportFor)(endpoint, options) } : {})
|
|
112
118
|
try {
|
|
113
|
-
const offline = options.command === 'firmware' && options.board
|
|
114
119
|
if (endpoint && !offline) await device.connect({ role: endpoint.interface === 'serial' ? 'unknown' : endpoint.interface, timeout: options.timeout, signal, ...(endpoint.advertisement ? { expectedDevice: endpoint.advertisement } : {}) })
|
|
115
120
|
if (options.command === 'inspect') return await device.inspect({ fields: options.fields, timeout: options.timeout, signal })
|
|
116
121
|
if (options.command === 'configure') {
|
|
@@ -123,10 +128,11 @@ export async function runCli(options, dependencies = {}) {
|
|
|
123
128
|
}
|
|
124
129
|
if (options.command === 'control') return await device.control({ action: options.action, ...(options.mode ? { mode: options.mode } : {}), timeout: options.timeout, signal })
|
|
125
130
|
let catalog = options.catalog
|
|
131
|
+
const mode = options.mode ?? 'update'
|
|
126
132
|
if (catalog !== undefined && !/^https:\/\//.test(catalog)) catalog = JSON.parse(await (dependencies.readFile ?? readFile)(catalog, 'utf8'))
|
|
127
|
-
const checked = await device.firmware.check({ catalog, ...(endpoint && !offline ? {} : { identity: { board: options.board, firmwareVersion: options['current-version'] ?? 'unknown' } }), ...(options.version ? { version: options.version } : {}), signal })
|
|
133
|
+
const checked = await device.firmware.check({ catalog, mode, ...(endpoint && !offline ? {} : { identity: { board: options.board, firmwareVersion: options['current-version'] ?? 'unknown' } }), ...(options.version ? { version: options.version } : {}), signal })
|
|
128
134
|
if (options.action === 'check') return checked
|
|
129
|
-
return await device.firmware.update({ release: checked.release, board: options.board, ...(options.image ? { data: new Uint8Array(await (dependencies.readFile ?? readFile)(options.image)) } : {}), openLoader: ({ signal }) => (dependencies.openLoader ?? openNodeRom)({ port: options.port, python: options.python, signal }), onPhase: dependencies.onPhase ?? (() => {}), signal })
|
|
135
|
+
return await device.firmware.update({ mode, release: checked.release, board: options.board, ...(options.image ? { data: new Uint8Array(await (dependencies.readFile ?? readFile)(options.image)) } : {}), openLoader: ({ signal }) => (dependencies.openLoader ?? openNodeRom)({ port: options.port, python: options.python, signal }), onPhase: dependencies.onPhase ?? (() => {}), signal })
|
|
130
136
|
} finally { await device.disconnect() }
|
|
131
137
|
}
|
|
132
138
|
const safe = value => String(value ?? '—').replace(/[\x00-\x1f\x7f-\x9f]/g, '?')
|
package/src/firmware.js
ADDED
package/src/firmware_feed.js
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
// SPDX-FileCopyrightText: 2026 ESP-VISION-IDE
|
|
2
2
|
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
import { firmwareSha256 } from './firmware_flash.js'
|
|
4
|
+
import { validateFirmwareRelease } from './firmware_release.js'
|
|
5
|
+
import { OFFICIAL_ROOT as base, MAX_MANIFEST_BYTES, MAX_FIRMWARE_BYTES, SHA256, SOURCE_COMMIT, firmwareUrl, downloadBytes } from './firmware_io.js'
|
|
6
|
+
export async function loadOfficialFirmware({ board, version, fetch: fetcher = globalThis.fetch, signal } = {}) {
|
|
7
|
+
if (board !== undefined && !validName(board, 96)) throw new TypeError('board must be a non-empty board string')
|
|
8
|
+
if (version !== undefined && !validName(version)) throw new TypeError('version must be a non-empty firmware version string')
|
|
5
9
|
const { parse } = await import('smol-toml')
|
|
6
10
|
const get = async (url, optional = false) => {
|
|
7
11
|
signal?.throwIfAborted()
|
|
@@ -11,10 +15,23 @@ export async function loadOfficialFirmware({ board, fetch: fetcher = globalThis.
|
|
|
11
15
|
return response
|
|
12
16
|
}
|
|
13
17
|
const config = parse(await (await get(base + 'config.toml')).text())
|
|
18
|
+
if (Object.hasOwn(config, 'publication')) {
|
|
19
|
+
const publication = validatePublication(config.publication)
|
|
20
|
+
const selected = publication.releases.find(entry => entry.version === (version ?? publication.version))
|
|
21
|
+
if (!selected || (board && !selected.boards.includes(board))) throw unavailable()
|
|
22
|
+
const manifest = await loadReleaseManifest({
|
|
23
|
+
url: selected.manifestUrl, sha256: selected.manifestSha256,
|
|
24
|
+
version: selected.version, boards: selected.boards, sourceCommit: selected.version === publication.version ? publication.sourceCommit : undefined,
|
|
25
|
+
fetch: fetcher, signal,
|
|
26
|
+
})
|
|
27
|
+
return { ...manifest, releases: manifest.releases.filter(entry => !board || entry.board === board), publication }
|
|
28
|
+
}
|
|
14
29
|
const app = config['esp-vision'], web = config.website
|
|
15
30
|
if (!app?.image || !Array.isArray(web?.releases) || typeof web.binaryNameTemplate !== 'string') throw new TypeError('Invalid official firmware index')
|
|
16
31
|
const boards = Object.keys(app.image).filter(name => !board || name === board)
|
|
17
|
-
const
|
|
32
|
+
const knownVersions = [...new Set([web.release, ...web.releases].filter(value => typeof value === 'string' && value))]
|
|
33
|
+
if (version !== undefined && !knownVersions.includes(version)) throw unavailable()
|
|
34
|
+
const versions = version === undefined ? knownVersions : [version]
|
|
18
35
|
if (versions.length > 256 || boards.length > 64) throw new RangeError('Official firmware index exceeds limits')
|
|
19
36
|
const releases = []
|
|
20
37
|
// Sequential metadata requests keep the official server load bounded.
|
|
@@ -23,6 +40,7 @@ export async function loadOfficialFirmware({ board, fetch: fetcher = globalThis.
|
|
|
23
40
|
if (metadata) {
|
|
24
41
|
const entry = await metadata.json()
|
|
25
42
|
if (entry.board !== name || entry.version !== version || new URL(entry.image?.url).origin !== 'https://dl.espressif.com') throw new TypeError('Official firmware metadata identity/URL mismatch')
|
|
43
|
+
if (entry.install && new URL(entry.install.url).origin !== 'https://dl.espressif.com') throw new TypeError('Official install URL mismatch')
|
|
26
44
|
releases.push(entry)
|
|
27
45
|
} else {
|
|
28
46
|
const file = web.binaryNameTemplate.replace('{board}', name).replace('{tag}', version)
|
|
@@ -33,3 +51,99 @@ export async function loadOfficialFirmware({ board, fetch: fetcher = globalThis.
|
|
|
33
51
|
}
|
|
34
52
|
return { schemaVersion: 1, releases }
|
|
35
53
|
}
|
|
54
|
+
|
|
55
|
+
function validName(value, max = 255) { return typeof value === 'string' && value.length > 0 && value.length <= max && value.trim() === value }
|
|
56
|
+
function unavailable() { return Object.assign(new Error('Requested firmware version is unavailable'), { code: 'FIRMWARE_VERSION_UNAVAILABLE' }) }
|
|
57
|
+
function descriptor(value) {
|
|
58
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)
|
|
59
|
+
|| !validName(value.version) || !SHA256.test(value.manifestSha256 ?? '')
|
|
60
|
+
|| !Array.isArray(value.boards) || !value.boards.length || value.boards.length > 64
|
|
61
|
+
|| value.boards.some(board => !validName(board, 96)) || new Set(value.boards).size !== value.boards.length) throw new TypeError('Invalid publication manifest reference')
|
|
62
|
+
firmwareUrl(value.manifestUrl, { official: true })
|
|
63
|
+
}
|
|
64
|
+
function validatePublication(value) {
|
|
65
|
+
if (!value || value.schemaVersion !== 1 || !SOURCE_COMMIT.test(value.sourceCommit ?? '')
|
|
66
|
+
|| !Array.isArray(value.releases) || !value.releases.length || value.releases.length > 256) throw new TypeError('Invalid official publication index')
|
|
67
|
+
if (!validName(value.version) || !SHA256.test(value.manifestSha256 ?? '')) throw new TypeError('Invalid publication current manifest reference')
|
|
68
|
+
firmwareUrl(value.manifestUrl, { official: true })
|
|
69
|
+
const seen = new Set()
|
|
70
|
+
for (const item of value.releases) {
|
|
71
|
+
descriptor(item)
|
|
72
|
+
if (seen.has(item.version)) throw new TypeError('Duplicate publication version')
|
|
73
|
+
seen.add(item.version)
|
|
74
|
+
}
|
|
75
|
+
const current = value.releases.find(item => item.version === value.version)
|
|
76
|
+
if (!current || current.manifestUrl !== value.manifestUrl || current.manifestSha256 !== value.manifestSha256) throw new TypeError('Publication current manifest reference differs from its history')
|
|
77
|
+
return structuredClone(value)
|
|
78
|
+
}
|
|
79
|
+
function validateWebsite(website, version, sourceCommit) {
|
|
80
|
+
if (!website || typeof website !== 'object' || Array.isArray(website)
|
|
81
|
+
|| typeof website.schemaVersion !== 'string' || !/^\d+\.\d+\.\d+$/.test(website.schemaVersion)
|
|
82
|
+
|| website.release !== version || !Array.isArray(website.releases) || website.releases.length > 256
|
|
83
|
+
|| !website.releases.includes(version) || website.releases.some(item => !validName(item))
|
|
84
|
+
|| typeof website.binaryNameTemplate !== 'string' || website.binaryNameTemplate.length > 255
|
|
85
|
+
|| !website.binaryNameTemplate.includes('{board}') || !website.binaryNameTemplate.includes('{tag}')
|
|
86
|
+
|| !Array.isArray(website.boards) || website.boards.length > 64
|
|
87
|
+
|| !Array.isArray(website.models) || website.models.length > 256) throw new TypeError('Invalid release manifest website snapshot')
|
|
88
|
+
const boards = new Set(), models = new Set()
|
|
89
|
+
for (const board of website.boards) {
|
|
90
|
+
if (!board || !validName(board.board, 96) || !validName(board.slug, 96) || !validName(board.name)
|
|
91
|
+
|| typeof board.flashable !== 'boolean' || boards.has(board.board)) throw new TypeError('Invalid or duplicate manifest website board')
|
|
92
|
+
if (board.docsUrl !== undefined) firmwareUrl(board.docsUrl)
|
|
93
|
+
boards.add(board.board)
|
|
94
|
+
}
|
|
95
|
+
for (const model of website.models) {
|
|
96
|
+
if (!model || !validName(model.slug, 96) || models.has(model.path)
|
|
97
|
+
|| !Number.isSafeInteger(model.sizeBytes) || model.sizeBytes <= 0 || model.sizeBytes > MAX_FIRMWARE_BYTES
|
|
98
|
+
|| typeof model.path !== 'string' || !model.path.startsWith('models/') || model.path.length > 512
|
|
99
|
+
|| model.path.split('/').some(part => !part || part === '.' || part === '..')
|
|
100
|
+
|| model.downloadUrl !== `https://raw.githubusercontent.com/espressif/esp-vision/${sourceCommit}/${model.path}`) throw new TypeError('Invalid manifest model or unpinned model URL')
|
|
101
|
+
const modelUrl = firmwareUrl(model.downloadUrl)
|
|
102
|
+
if (modelUrl.search || !modelUrl.pathname.startsWith(`/espressif/esp-vision/${sourceCommit}/models/`)
|
|
103
|
+
|| /%2f|%5c|%2e/i.test(modelUrl.pathname)) throw new TypeError('Invalid manifest model path or source commit boundary')
|
|
104
|
+
models.add(model.path)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
function validateManifest(value, { version, sourceCommit, boards } = {}) {
|
|
108
|
+
if (!value || value.schemaVersion !== 1 || !validName(value.version)
|
|
109
|
+
|| !['legacy', 'single-bin'].includes(value.layout)
|
|
110
|
+
|| !SOURCE_COMMIT.test(value.sourceCommit ?? '') || !Number.isSafeInteger(value.pipelineId) || value.pipelineId <= 0
|
|
111
|
+
|| (version !== undefined && value.version !== version) || (sourceCommit !== undefined && value.sourceCommit !== sourceCommit)
|
|
112
|
+
|| !Array.isArray(value.releases) || !value.releases.length || value.releases.length > 64) throw new TypeError('Invalid release manifest identity or bounds')
|
|
113
|
+
const seen = new Set()
|
|
114
|
+
for (const entry of value.releases) {
|
|
115
|
+
validateFirmwareRelease(entry)
|
|
116
|
+
if (entry.image.bytes > MAX_FIRMWARE_BYTES || (entry.install && entry.install.bytes > MAX_FIRMWARE_BYTES)) throw new RangeError('Manifest firmware image exceeds byte limit')
|
|
117
|
+
if (entry.version !== value.version || (entry.sourceCommit !== undefined && entry.sourceCommit !== value.sourceCommit) || seen.has(entry.board)) throw new TypeError('Release manifest board/version mismatch or duplicate board')
|
|
118
|
+
seen.add(entry.board)
|
|
119
|
+
if ((value.layout === 'single-bin') !== (entry.image.source !== undefined)) throw new TypeError('Release manifest BIN layout/source mismatch')
|
|
120
|
+
firmwareUrl(entry.image.url, { official: true })
|
|
121
|
+
if (entry.image.source) firmwareUrl(entry.image.source.url, { official: true })
|
|
122
|
+
if (entry.install) { validateFirmwareRelease(entry, 'install'); firmwareUrl(entry.install.url, { official: true }) }
|
|
123
|
+
}
|
|
124
|
+
if (boards !== undefined && (boards.length !== seen.size || boards.some(board => !seen.has(board)))) throw new TypeError('Release manifest board set differs from publication reference')
|
|
125
|
+
validateWebsite(value.website, value.version, value.sourceCommit)
|
|
126
|
+
if (value.website.boards.some(board => board.flashable && !seen.has(board.board))) throw new TypeError('Manifest website advertises an unqualified board')
|
|
127
|
+
if (value.mcp !== undefined && value.mcp !== null) {
|
|
128
|
+
const mcp = value.mcp
|
|
129
|
+
if (!mcp || typeof mcp !== 'object' || Array.isArray(mcp)
|
|
130
|
+
|| !Number.isSafeInteger(mcp.bytes) || mcp.bytes <= 0 || mcp.bytes > 16 * 1024 * 1024
|
|
131
|
+
|| !SHA256.test(mcp.sha256 ?? '') || mcp.version !== value.version || mcp.sourceCommit !== value.sourceCommit) throw new TypeError('Invalid release manifest MCP descriptor')
|
|
132
|
+
firmwareUrl(mcp.url, { official: true })
|
|
133
|
+
const mcpPath = new URL(mcp.url).pathname
|
|
134
|
+
if (!mcpPath.startsWith('/AE/esp-vision/bin/mcp/') || decodeURIComponent(mcpPath.slice(mcpPath.lastIndexOf('/') + 1)) !== `mcp-pack-${value.version}.json`) throw new TypeError('Invalid release manifest MCP source')
|
|
135
|
+
}
|
|
136
|
+
return value
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Read exactly one immutable official release snapshot, verified over raw bytes. */
|
|
140
|
+
export async function loadReleaseManifest({ url, sha256, version, sourceCommit, boards, fetch: fetcher = globalThis.fetch, signal } = {}) {
|
|
141
|
+
if (!SHA256.test(sha256 ?? '') || (version !== undefined && !validName(version))
|
|
142
|
+
|| (sourceCommit !== undefined && !SOURCE_COMMIT.test(sourceCommit))
|
|
143
|
+
|| (boards !== undefined && (!Array.isArray(boards) || !boards.length || boards.length > 64 || boards.some(board => !validName(board, 96)) || new Set(boards).size !== boards.length))) throw new TypeError('Manifest requires a qualified SHA-256, version and source commit')
|
|
144
|
+
const bytes = await downloadBytes(url, { fetch: fetcher, signal, maxBytes: MAX_MANIFEST_BYTES, official: true, immutable: true })
|
|
145
|
+
if (await firmwareSha256(bytes) !== sha256) throw new Error('Release manifest SHA-256 does not match publication')
|
|
146
|
+
signal?.throwIfAborted()
|
|
147
|
+
const value = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes))
|
|
148
|
+
return validateManifest(value, { version, sourceCommit, boards })
|
|
149
|
+
}
|