@esp-vision/device-core 0.1.1 → 0.3.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 +22 -0
- package/README.md +7 -7
- package/package.json +2 -2
- package/schemas/device-actions-v1.json +2 -2
- package/scripts/release-npm.mjs +4 -3
- package/scripts/verify-registry.mjs +36 -0
- package/src/cli.js +14 -8
- package/src/device_actions.js +1 -1
- package/src/device_bindings.js +3 -12
- package/src/device_session.js +56 -63
- package/src/evmux.js +4 -3
- package/src/firmware_feed.js +1 -0
- package/src/firmware_flash.js +39 -11
- package/src/firmware_updates.js +35 -9
- package/src/node.js +5 -0
- package/src/node_rom.js +1 -0
- package/src/node_rom.py +2 -0
- package/tests/check-package.mjs +2 -1
- package/tests/test-device-execution.mjs +13 -3
- package/tests/test-file-binary.mjs +112 -0
- package/tests/test-firmware-install.mjs +126 -0
- package/tests/test-release-verification.mjs +66 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- Export the passive built-in `openNodeRom({ port, python?, signal? })` adapter from `/node` so Node SDK hosts and the CLI share the same ROM implementation.
|
|
8
|
+
|
|
9
|
+
- 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.
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- Allow up to 151 seconds of bounded metadata propagation backoff after npm accepts publication, with ten uncached verification reads and no repeated publish.
|
|
14
|
+
|
|
15
|
+
## 0.2.0
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- File and large-script uploads use one reliable binary `file.write` engine with 16 KiB blocks, integrity validation and cancellation cleanup; no Base64/JSON or REPL upload fallback. Public `files.write` and Python API signatures stay unchanged. Uploads require matching firmware capability `file.write.binary`; older v4 devices can still be inspected but must update firmware before upload.
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- Verify newly published tarballs using bounded uncached registry reads instead of a single immediate npm view; retry transient metadata propagation/network failures without repeating publication, while failing immediately on access denial or integrity mismatch.
|
|
24
|
+
|
|
3
25
|
## 0.1.1
|
|
4
26
|
|
|
5
27
|
### Fixed
|
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
|
+
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 for the published version. 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.3.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.3.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
|
|
|
@@ -220,7 +220,7 @@ const image = await device.capture({ source: 'camera', format: 'jpeg', timeout:
|
|
|
220
220
|
|
|
221
221
|
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
222
|
|
|
223
|
-
**
|
|
223
|
+
**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
224
|
|
|
225
225
|
`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
226
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@esp-vision/device-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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": {
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"./firmware": "./src/firmware_flash.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
17
|
},
|
|
18
18
|
"files": [
|
|
19
19
|
"src/*.js",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
3
|
"title": "ESP Vision Device Actions",
|
|
4
|
-
"deviceCoreVersion": "0.
|
|
4
|
+
"deviceCoreVersion": "0.3.0",
|
|
5
5
|
"schemaVersion": 1,
|
|
6
6
|
"actions": [
|
|
7
7
|
{
|
|
@@ -169,7 +169,7 @@
|
|
|
169
169
|
},
|
|
170
170
|
{
|
|
171
171
|
"name": "python.execute",
|
|
172
|
-
"description": "Execute MicroPython through the shared interpreter using bounded REPL or
|
|
172
|
+
"description": "Execute MicroPython through the shared interpreter using bounded REPL or binary file staging.",
|
|
173
173
|
"inputSchema": {
|
|
174
174
|
"type": "object",
|
|
175
175
|
"properties": {
|
package/scripts/release-npm.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// SPDX-FileCopyrightText: 2026 ESP-VISION-IDE
|
|
2
2
|
// SPDX-License-Identifier: Apache-2.0
|
|
3
3
|
import assert from 'node:assert/strict'
|
|
4
|
+
import { verifyPublishedIntegrity } from './verify-registry.mjs'
|
|
4
5
|
import { spawnSync } from 'node:child_process'
|
|
5
6
|
import { mkdtemp, readFile, writeFile, rm } from 'node:fs/promises'
|
|
6
7
|
import { tmpdir } from 'node:os'
|
|
@@ -48,7 +49,7 @@ try {
|
|
|
48
49
|
console.log(`Npm publisher: ${publisher}`)
|
|
49
50
|
const url = new URL(encodeURIComponent(manifest.name), registry)
|
|
50
51
|
url.pathname += '/' + encodeURIComponent(manifest.version)
|
|
51
|
-
const response = await fetch(url, { signal: AbortSignal.timeout(15000) })
|
|
52
|
+
const response = await fetch(url, { cache: 'no-store', headers: { 'Cache-Control': 'no-cache' }, signal: AbortSignal.timeout(15000) })
|
|
52
53
|
if (response.ok) {
|
|
53
54
|
const existing = await response.json()
|
|
54
55
|
assert.equal(existing.dist?.integrity, packed.integrity,
|
|
@@ -57,8 +58,8 @@ try {
|
|
|
57
58
|
} else {
|
|
58
59
|
assert.equal(response.status, 404, 'Registry lookup failed; do not attempt publication')
|
|
59
60
|
npm(['publish', join(temporary, packed.filename), '--ignore-scripts', '--access', 'public', '--registry', registry])
|
|
60
|
-
|
|
61
|
-
|
|
61
|
+
console.log('npm publish accepted; verifying registry metadata')
|
|
62
|
+
await verifyPublishedIntegrity(url, packed.integrity)
|
|
62
63
|
console.log(`Published and verified ${packed.name}@${packed.version}`)
|
|
63
64
|
}
|
|
64
65
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
import assert from 'node:assert/strict'
|
|
4
|
+
|
|
5
|
+
// Publication is one write. Only reads are retried while registry metadata propagates.
|
|
6
|
+
export async function verifyPublishedIntegrity(url, integrity, {
|
|
7
|
+
fetcher = globalThis.fetch,
|
|
8
|
+
wait = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds)),
|
|
9
|
+
report = message => console.log(message),
|
|
10
|
+
} = {}) {
|
|
11
|
+
const attempts = 10
|
|
12
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
13
|
+
let response, transient
|
|
14
|
+
try {
|
|
15
|
+
response = await fetcher(url, { cache: 'no-store', headers: { 'Cache-Control': 'no-cache' }, signal: AbortSignal.timeout(15000) })
|
|
16
|
+
} catch (error) {
|
|
17
|
+
if (!(error instanceof TypeError) && !['TimeoutError', 'AbortError'].includes(error.name)) throw error
|
|
18
|
+
transient = 'network timeout or unavailable'
|
|
19
|
+
}
|
|
20
|
+
if (response?.ok) {
|
|
21
|
+
const metadata = await response.json()
|
|
22
|
+
assert.equal(metadata.dist?.integrity, integrity, 'Published tarball integrity mismatch')
|
|
23
|
+
return
|
|
24
|
+
}
|
|
25
|
+
if (response) {
|
|
26
|
+
const retryable = response.status === 404 || response.status === 429 || response.status >= 500 && response.status <= 599
|
|
27
|
+
await response.body?.cancel()
|
|
28
|
+
assert.ok(retryable, `Registry verification failed: HTTP ${response.status}`)
|
|
29
|
+
transient = `HTTP ${response.status}`
|
|
30
|
+
}
|
|
31
|
+
assert.ok(attempt + 1 < attempts, `Published metadata verification unavailable after ${attempts} reads (${transient}); retry the CI job, not publication manually`)
|
|
32
|
+
const delay = Math.min(30000, 1000 * 2 ** attempt)
|
|
33
|
+
report(`Registry verification ${transient}; retrying read ${attempt + 2}/${attempts} in ${delay}ms`)
|
|
34
|
+
await wait(delay)
|
|
35
|
+
}
|
|
36
|
+
}
|
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/device_actions.js
CHANGED
|
@@ -74,7 +74,7 @@ export const DEVICE_ACTION_DESCRIPTORS = Object.freeze([
|
|
|
74
74
|
includeFiles: { type: 'boolean', default: true },
|
|
75
75
|
logLimit: { type: 'integer', minimum: 0, maximum: 500, default: 100 },
|
|
76
76
|
}), { readOnly: true, concurrency: 'shared-read' }),
|
|
77
|
-
action('python.execute', 'Execute MicroPython through the shared interpreter using bounded REPL or
|
|
77
|
+
action('python.execute', 'Execute MicroPython through the shared interpreter using bounded REPL or binary file staging.', objectSchema({
|
|
78
78
|
code: { type: 'string' },
|
|
79
79
|
timeoutMs: { type: 'integer', minimum: 100, maximum: 120000, default: 8000 },
|
|
80
80
|
}, ['code']), { destructive: true, openWorld: true }),
|
package/src/device_bindings.js
CHANGED
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
* SPDX-License-Identifier: Apache-2.0
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { EV_REPL_FILE_CHUNK_BYTES } from './evrepl.js'
|
|
7
6
|
|
|
8
7
|
export const EPHEMERAL_PYTHON_PATH = '/.espvision_agent_run.py'
|
|
9
8
|
|
|
@@ -306,17 +305,9 @@ export function bindSessionDeviceActions(registry, {
|
|
|
306
305
|
registry.bind('deviceFile.write', async (input, { session, signal }) => {
|
|
307
306
|
const path = validateDevicePath(input.path)
|
|
308
307
|
const bytes = input.encoding === 'base64' ? base64ToBytes(input.content) : null
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
} else {
|
|
313
|
-
await session.repl.writeFile(path, bytes, EV_REPL_FILE_CHUNK_BYTES, false, { signal })
|
|
314
|
-
}
|
|
315
|
-
} else if (session.hasFeature('script.write')) {
|
|
316
|
-
await session.writeScript(path, input.content, { runAfterWrite: input.runAfterWrite, signal })
|
|
317
|
-
} else {
|
|
318
|
-
await session.repl.writeFile(path, input.content, EV_REPL_FILE_CHUNK_BYTES, false, { signal })
|
|
319
|
-
}
|
|
308
|
+
await session.writeFile(path, bytes ?? input.content, {
|
|
309
|
+
encoding: bytes ? 'binary' : 'utf-8', runAfterWrite: input.runAfterWrite, signal,
|
|
310
|
+
})
|
|
320
311
|
return { ok: true, path, bytes: bytes?.length ?? new TextEncoder().encode(input.content).length }
|
|
321
312
|
})
|
|
322
313
|
registry.bind('deviceFile.remove', async (input, { session, signal }) => {
|
package/src/device_session.js
CHANGED
|
@@ -47,6 +47,7 @@ const EV_MUX_USER_RPC_METHODS = new Set([
|
|
|
47
47
|
'capabilities',
|
|
48
48
|
'session.keepalive',
|
|
49
49
|
'session.release',
|
|
50
|
+
'file.write',
|
|
50
51
|
'script.write',
|
|
51
52
|
'script.run',
|
|
52
53
|
'device.control',
|
|
@@ -146,7 +147,7 @@ function isNetworkAction(method, payload) {
|
|
|
146
147
|
}
|
|
147
148
|
|
|
148
149
|
function isVmRpc(method, payload) {
|
|
149
|
-
if (method === 'script.write' || method === 'script.run' || method === 'debug.capture_frame' || method === 'debug.capture_screen') return true
|
|
150
|
+
if (method === 'file.write' || method === 'script.write' || method === 'script.run' || method === 'debug.capture_frame' || method === 'debug.capture_screen') return true
|
|
150
151
|
if (method === 'debug.info') return payload?.scope !== 'transport.stats'
|
|
151
152
|
return false
|
|
152
153
|
}
|
|
@@ -155,30 +156,6 @@ function payloadBytes(payload) {
|
|
|
155
156
|
return payload instanceof Uint8Array ? payload : utf8Bytes(payload)
|
|
156
157
|
}
|
|
157
158
|
|
|
158
|
-
function maxScriptChunkBytes({ path, encoding, mode, runAfterWrite, rxPayloadMax, totalBytes }) {
|
|
159
|
-
let low = 1
|
|
160
|
-
let high = Math.max(1, Math.min(EV_MUX_SCRIPT_CHUNK_BYTES, rxPayloadMax))
|
|
161
|
-
while (low < high) {
|
|
162
|
-
const candidate = Math.ceil((low + high) / 2)
|
|
163
|
-
const payload = {
|
|
164
|
-
path, encoding, mode, offset: Math.max(0, totalBytes - 1), totalBytes,
|
|
165
|
-
contentBase64: 'A'.repeat(Math.ceil(candidate / 3) * 4), runAfterWrite,
|
|
166
|
-
}
|
|
167
|
-
if (utf8Bytes(JSON.stringify(payload)).length <= rxPayloadMax) low = candidate
|
|
168
|
-
else high = candidate - 1
|
|
169
|
-
}
|
|
170
|
-
return low
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
function bytesToBase64(bytes) {
|
|
174
|
-
let binary = ''
|
|
175
|
-
const chunkSize = 0x8000
|
|
176
|
-
for (let i = 0; i < bytes.length; i += chunkSize) {
|
|
177
|
-
binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize))
|
|
178
|
-
}
|
|
179
|
-
return btoa(binary)
|
|
180
|
-
}
|
|
181
|
-
|
|
182
159
|
function defaultSleep(ms) {
|
|
183
160
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
184
161
|
}
|
|
@@ -262,6 +239,11 @@ export function validateEvmuxCapabilities(payload) {
|
|
|
262
239
|
}
|
|
263
240
|
|
|
264
241
|
for (const key of Object.keys(EV_MUX_DEFAULT_LIMITS)) validateLimit(payload.limits, key)
|
|
242
|
+
if (payload.features.includes('file.write.binary') && (!Number.isSafeInteger(payload.limits.fileWriteChunkMax)
|
|
243
|
+
|| payload.limits.fileWriteChunkMax < 4096 || payload.limits.fileWriteChunkMax > 16384
|
|
244
|
+
|| payload.limits.fileWriteChunkMax % 4096 !== 0)) {
|
|
245
|
+
throw new Error('EV-MUX v4 capabilities are missing a valid limits.fileWriteChunkMax')
|
|
246
|
+
}
|
|
265
247
|
if (payload.session?.policy !== 'firstCome'
|
|
266
248
|
|| !Number.isSafeInteger(payload.session.leaseMs)
|
|
267
249
|
|| !Number.isSafeInteger(payload.session.keepaliveMs)
|
|
@@ -622,6 +604,7 @@ export class DeviceSession {
|
|
|
622
604
|
redactSsid = true,
|
|
623
605
|
forceNetwork = false,
|
|
624
606
|
} = {}) {
|
|
607
|
+
if (payload instanceof Uint8Array) return { binaryBytes: payload.byteLength }
|
|
625
608
|
if (!forceNetwork && !isNetworkAction(method, payload) && !isNetworkAction(method, requestPayload)) return payload
|
|
626
609
|
const secrets = [requestPayload?.ssid, requestPayload?.password]
|
|
627
610
|
.filter((value) => typeof value === 'string' && value.length > 0)
|
|
@@ -687,6 +670,7 @@ export class DeviceSession {
|
|
|
687
670
|
seq: requestedSeq = null,
|
|
688
671
|
signal = null,
|
|
689
672
|
writeTimeout = null,
|
|
673
|
+
encoding = undefined,
|
|
690
674
|
} = {}) {
|
|
691
675
|
if (this.state === 'disconnected') {
|
|
692
676
|
throw new Error('EV-MUX is not connected')
|
|
@@ -715,7 +699,9 @@ export class DeviceSession {
|
|
|
715
699
|
&& (payloadData.length !== 1 || payloadData[0] !== 0x03)) {
|
|
716
700
|
throw new Error('EV-MUX repl.signal only accepts the single-byte Ctrl-C signal 0x03')
|
|
717
701
|
}
|
|
718
|
-
|
|
702
|
+
const payloadLimit = method === 'file.write' && encoding === 'binary' && this.hasFeature('file.write.binary')
|
|
703
|
+
? this.limits.fileWriteChunkMax + 20 : this.limits.rxPayloadMax
|
|
704
|
+
if (this.capabilities && payloadData.length > payloadLimit) {
|
|
719
705
|
throw new Error(`EV-MUX payload is ${payloadData.length} bytes; device limit is ${this.limits.rxPayloadMax}`)
|
|
720
706
|
}
|
|
721
707
|
if ((channel === 'repl.stdin' || channel === 'repl.signal') && payloadData.length > this.limits.replInputMax) {
|
|
@@ -727,7 +713,7 @@ export class DeviceSession {
|
|
|
727
713
|
seq,
|
|
728
714
|
channel,
|
|
729
715
|
type: frameType,
|
|
730
|
-
encoding: channel.endsWith('.rpc') ? 'json' : 'text',
|
|
716
|
+
encoding: encoding ?? (channel.endsWith('.rpc') ? 'json' : 'text'),
|
|
731
717
|
...(method ? { method } : {}),
|
|
732
718
|
...(needsAuthorization ? { sessionId: this.sessionId } : {}),
|
|
733
719
|
...(usesVm && needsAuthorization ? { vmEpoch: this.vmEpoch } : {}),
|
|
@@ -738,6 +724,7 @@ export class DeviceSession {
|
|
|
738
724
|
const frame = this.makeEvMuxFrame({
|
|
739
725
|
channel,
|
|
740
726
|
type: frameType,
|
|
727
|
+
encoding,
|
|
741
728
|
method,
|
|
742
729
|
payload,
|
|
743
730
|
seq,
|
|
@@ -764,6 +751,7 @@ export class DeviceSession {
|
|
|
764
751
|
throw new Error('EV-MUX is not connected')
|
|
765
752
|
}
|
|
766
753
|
if (signal?.aborted) throw abortError(signal)
|
|
754
|
+
if (payload instanceof Uint8Array && method !== 'file.write') throw new TypeError('Raw binary requests are only supported for file.write')
|
|
767
755
|
if (method === 'script.write' || method === 'script.run') validateRpcPath(payload?.path)
|
|
768
756
|
if (method === 'debug.info' && (payload?.scope === 'fs.list' || payload?.scope === 'fs.read')) {
|
|
769
757
|
validateRpcPath(payload.path ?? (payload.scope === 'fs.list' ? '/' : undefined))
|
|
@@ -816,7 +804,8 @@ export class DeviceSession {
|
|
|
816
804
|
this.handlers.rpcRequest?.({ channel: rpcChannel, method, seq, timeout, payload: safePayload })
|
|
817
805
|
this._diagnostic('rpc.request', { channel: rpcChannel, method, seq })
|
|
818
806
|
try {
|
|
819
|
-
await this.writeFrame(rpcChannel, 'req', method, JSON.stringify(payload), {
|
|
807
|
+
await this.writeFrame(rpcChannel, 'req', method, payload instanceof Uint8Array ? payload : JSON.stringify(payload), {
|
|
808
|
+
encoding: payload instanceof Uint8Array ? 'binary' : undefined,
|
|
820
809
|
vmRequest,
|
|
821
810
|
seq,
|
|
822
811
|
signal,
|
|
@@ -1089,41 +1078,45 @@ export class DeviceSession {
|
|
|
1089
1078
|
: content instanceof Uint8Array
|
|
1090
1079
|
? content
|
|
1091
1080
|
: new Uint8Array(content)
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
}
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1081
|
+
if (!this.hasFeature('file.write.binary')) {
|
|
1082
|
+
throw Object.assign(new Error('File upload requires firmware with file.write.binary; update the device firmware'), { code: 'UNSUPPORTED_OPERATION' })
|
|
1083
|
+
}
|
|
1084
|
+
const limit = this.limits.fileWriteChunkMax
|
|
1085
|
+
if (!Number.isSafeInteger(limit) || limit < 4096 || limit > 16384 || limit % 4096 !== 0 || bytes.length > 0xffffffff) {
|
|
1086
|
+
throw new Error('Invalid negotiated binary file transfer limit or file size')
|
|
1087
|
+
}
|
|
1088
|
+
const digest = new Uint8Array(await globalThis.crypto.subtle.digest('SHA-256', bytes))
|
|
1089
|
+
if (signal?.aborted) throw abortError(signal)
|
|
1090
|
+
const sha256 = Array.from(digest, byte => byte.toString(16).padStart(2, '0')).join('')
|
|
1091
|
+
const uploadId = randomHex()
|
|
1092
|
+
let committed = false
|
|
1093
|
+
try {
|
|
1094
|
+
const begin = await this.rpc('file.write', { action: 'begin', path, mode, totalBytes: bytes.length, sha256, uploadId }, { timeout: 8000, signal })
|
|
1095
|
+
if (begin.uploadId !== uploadId || begin.chunkBytes !== limit) throw new Error('Invalid binary file transfer acknowledgement')
|
|
1096
|
+
const token = Uint8Array.from(begin.uploadId.match(/../g), byte => parseInt(byte, 16))
|
|
1097
|
+
for (let offset = 0; offset < bytes.length; offset += limit) {
|
|
1098
1098
|
if (signal?.aborted) throw abortError(signal)
|
|
1099
|
-
const chunk = bytes.subarray(offset, Math.min(offset +
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
contentBase64: bytesToBase64(chunk),
|
|
1107
|
-
runAfterWrite: runAfterWrite && offset + chunk.length >= bytes.length,
|
|
1108
|
-
}, { timeout: 8000, signal })
|
|
1099
|
+
const chunk = bytes.subarray(offset, Math.min(offset + limit, bytes.length))
|
|
1100
|
+
const payload = new Uint8Array(20 + chunk.length)
|
|
1101
|
+
payload.set(token)
|
|
1102
|
+
new DataView(payload.buffer).setUint32(16, offset, true)
|
|
1103
|
+
payload.set(chunk, 20)
|
|
1104
|
+
const response = await this.rpc('file.write', payload, { timeout: 8000, signal })
|
|
1105
|
+
if (response.writtenBytes !== offset + chunk.length) throw new Error('Incomplete binary file transfer acknowledgement')
|
|
1109
1106
|
onProgress?.({ writtenBytes: offset + chunk.length, totalBytes: bytes.length })
|
|
1110
1107
|
}
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
path,
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
throw new Error(`script.write payload exceeds the device RX limit ${this.limits.rxPayloadMax}; firmware must support script.write.chunked.`)
|
|
1108
|
+
const result = await this.rpc('file.write', { action: 'commit', uploadId: begin.uploadId }, { timeout: 8000, signal })
|
|
1109
|
+
if (result.complete !== true || result.bytes !== bytes.length) throw new Error('Incomplete binary file commit acknowledgement')
|
|
1110
|
+
committed = true
|
|
1111
|
+
if (bytes.length === 0) onProgress?.({ writtenBytes: 0, totalBytes: 0 })
|
|
1112
|
+
if (runAfterWrite) await this.rpc('script.run', { path }, { timeout: 8000, signal })
|
|
1113
|
+
return { ...result, path, runAfterWrite }
|
|
1114
|
+
} finally {
|
|
1115
|
+
if (!committed) {
|
|
1116
|
+
// Cancellation does not cancel cleanup, and cleanup cannot mask the primary error.
|
|
1117
|
+
await this.rpc('file.write', { action: 'abort', uploadId }, { timeout: 1500 }).catch(() => {})
|
|
1118
|
+
}
|
|
1123
1119
|
}
|
|
1124
|
-
const response = await this.rpc('script.write', payload, { timeout: 8000, signal })
|
|
1125
|
-
onProgress?.({ writtenBytes: bytes.length, totalBytes: bytes.length })
|
|
1126
|
-
return response
|
|
1127
1120
|
}
|
|
1128
1121
|
|
|
1129
1122
|
async writeScript(path, content, { runAfterWrite = false, signal = null } = {}) {
|
|
@@ -1217,10 +1210,10 @@ export class DeviceSession {
|
|
|
1217
1210
|
if (commandBytes <= inputLimit) {
|
|
1218
1211
|
return this.repl.execPy(source, { timeout, signal })
|
|
1219
1212
|
}
|
|
1220
|
-
if (!this.hasFeature('
|
|
1213
|
+
if (!this.hasFeature('file.write.binary') || !this.hasFeature('script.run')) {
|
|
1221
1214
|
throw new Error(
|
|
1222
1215
|
`Python source requires ${commandBytes} REPL bytes, above the firmware limit ${inputLimit}; ` +
|
|
1223
|
-
'upgrade firmware with
|
|
1216
|
+
'upgrade firmware with file.write.binary and script.run support.',
|
|
1224
1217
|
)
|
|
1225
1218
|
}
|
|
1226
1219
|
|
|
@@ -1365,8 +1358,8 @@ export class DeviceSession {
|
|
|
1365
1358
|
if (timeout !== null && (!Number.isFinite(timeout) || timeout <= 0)) {
|
|
1366
1359
|
throw new RangeError('Script timeout must be a positive number or null for an interactive run')
|
|
1367
1360
|
}
|
|
1368
|
-
if ((content !== undefined && !this.hasFeature('
|
|
1369
|
-
throw new Error('Running scripts requires script.run and uploading source also requires
|
|
1361
|
+
if ((content !== undefined && !this.hasFeature('file.write.binary')) || !this.hasFeature('script.run')) {
|
|
1362
|
+
throw new Error('Running scripts requires script.run and uploading source also requires file.write.binary')
|
|
1370
1363
|
}
|
|
1371
1364
|
if (this.runCapture || this.runStarting) {
|
|
1372
1365
|
throw new Error('A script is already running')
|
package/src/evmux.js
CHANGED
|
@@ -389,14 +389,14 @@ export class EvMuxParser {
|
|
|
389
389
|
}
|
|
390
390
|
}
|
|
391
391
|
|
|
392
|
-
export function makeEvMuxFrame({ channel, type = 'data', method = null, payload = '', seq = 0, sid = 'web-ide', sessionId = undefined, vmEpoch = undefined, contentType = undefined, width = undefined, height = undefined, route = undefined }) {
|
|
392
|
+
export function makeEvMuxFrame({ channel, type = 'data', method = null, payload = '', seq = 0, sid = 'web-ide', sessionId = undefined, vmEpoch = undefined, contentType = undefined, width = undefined, height = undefined, route = undefined, encoding = undefined }) {
|
|
393
393
|
const payloadBytes = payload instanceof Uint8Array ? payload : new TextEncoder().encode(payload)
|
|
394
394
|
const metadata = {
|
|
395
395
|
sid,
|
|
396
396
|
seq,
|
|
397
397
|
channel,
|
|
398
398
|
type,
|
|
399
|
-
encoding: channel.endsWith('.rpc') ? 'json' : 'text',
|
|
399
|
+
encoding: encoding ?? (channel.endsWith('.rpc') ? 'json' : 'text'),
|
|
400
400
|
...(sessionId ? { sessionId } : {}),
|
|
401
401
|
...(vmEpoch !== undefined ? { vmEpoch } : {}),
|
|
402
402
|
...(contentType ? { contentType } : {}),
|
|
@@ -408,7 +408,8 @@ export function makeEvMuxFrame({ channel, type = 'data', method = null, payload
|
|
|
408
408
|
metadata.method = method
|
|
409
409
|
}
|
|
410
410
|
const metadataBytes = new TextEncoder().encode(JSON.stringify(metadata))
|
|
411
|
-
const
|
|
411
|
+
const checksum = encoding === 'binary' ? crc32(payloadBytes).toString(16).padStart(8, '0') : '00000000'
|
|
412
|
+
const header = new TextEncoder().encode(`\x1eEVMUX/1 h=${metadataBytes.length} p=${payloadBytes.length} c=${checksum}\r\n`)
|
|
412
413
|
const out = new Uint8Array(header.length + metadataBytes.length + payloadBytes.length + 1)
|
|
413
414
|
out.set(header, 0)
|
|
414
415
|
out.set(metadataBytes, header.length)
|
package/src/firmware_feed.js
CHANGED
|
@@ -23,6 +23,7 @@ export async function loadOfficialFirmware({ board, fetch: fetcher = globalThis.
|
|
|
23
23
|
if (metadata) {
|
|
24
24
|
const entry = await metadata.json()
|
|
25
25
|
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')
|
|
26
|
+
if (entry.install && new URL(entry.install.url).origin !== 'https://dl.espressif.com') throw new TypeError('Official install URL mismatch')
|
|
26
27
|
releases.push(entry)
|
|
27
28
|
} else {
|
|
28
29
|
const file = web.binaryNameTemplate.replace('{board}', name).replace('{tag}', version)
|
package/src/firmware_flash.js
CHANGED
|
@@ -6,18 +6,37 @@ export async function firmwareSha256(bytes) {
|
|
|
6
6
|
return Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, '0')).join('')
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
-
export
|
|
9
|
+
export function firmwareMode(mode = 'update') {
|
|
10
|
+
if (!['update', 'install'].includes(mode)) throw new TypeError('Firmware mode must be update or install')
|
|
11
|
+
return mode
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function validateFirmwareImage(image, bytes, { mode = 'update' } = {}) {
|
|
15
|
+
firmwareMode(mode)
|
|
10
16
|
if (image.evMuxVersion !== 4) throw new Error('Only approved EV-MUX v4 firmware can be installed')
|
|
11
17
|
if (!(bytes instanceof Uint8Array) || bytes.byteLength !== image.bytes) throw new Error('Firmware image size does not match the approved build')
|
|
12
|
-
|
|
13
|
-
|
|
18
|
+
const limit = mode === 'install' ? image.flashSize : image.partitionSize
|
|
19
|
+
if (!Number.isSafeInteger(image.offset) || !Number.isSafeInteger(limit)
|
|
20
|
+
|| image.offset < 0 || image.bytes <= 0 || image.bytes > limit || (mode === 'install' && image.offset !== 0)) throw new Error('Invalid firmware image bounds')
|
|
14
21
|
if (await firmwareSha256(bytes) !== image.sha256) throw new Error('Firmware SHA-256 does not match the approved build')
|
|
22
|
+
if (mode === 'install') {
|
|
23
|
+
const segments = [image.bootloader, image.partitionTable, image.application]
|
|
24
|
+
let end = 0
|
|
25
|
+
for (const segment of segments) {
|
|
26
|
+
if (!segment || !Number.isSafeInteger(segment.offset) || !Number.isSafeInteger(segment.bytes)
|
|
27
|
+
|| segment.offset < end || segment.bytes <= 0 || segment.offset + segment.bytes > bytes.byteLength
|
|
28
|
+
|| !/^[a-f0-9]{64}$/.test(segment.sha256 ?? '')) throw new Error('Complete image requires ordered, non-overlapping bootloader, partition table and application metadata')
|
|
29
|
+
if (await firmwareSha256(bytes.subarray(segment.offset, segment.offset + segment.bytes)) !== segment.sha256) throw new Error('Complete image does not contain the approved bootloader, partition table and application')
|
|
30
|
+
end = segment.offset + segment.bytes
|
|
31
|
+
}
|
|
32
|
+
}
|
|
15
33
|
return bytes
|
|
16
34
|
}
|
|
17
35
|
|
|
18
36
|
/** Loader injected by the browser host; no Web Serial or ROM protocol duplicated. */
|
|
19
37
|
export class FirmwareFlashSession {
|
|
20
|
-
constructor(loader, image, { reset = () => loader.hardReset() } = {}) {
|
|
38
|
+
constructor(loader, image, { reset = () => loader.hardReset(), mode = 'update' } = {}) {
|
|
39
|
+
this.mode = firmwareMode(mode)
|
|
21
40
|
this.loader = loader
|
|
22
41
|
this.reset = reset
|
|
23
42
|
this.image = image
|
|
@@ -29,7 +48,7 @@ export class FirmwareFlashSession {
|
|
|
29
48
|
if (this.state !== 'idle') throw new Error('Firmware loader is already in use')
|
|
30
49
|
this.state = 'connecting'
|
|
31
50
|
try {
|
|
32
|
-
await validateFirmwareImage(this.image, bytes)
|
|
51
|
+
await validateFirmwareImage(this.image, bytes, { mode: this.mode })
|
|
33
52
|
await this.loader.initialize()
|
|
34
53
|
const revisionMatches = this.image.chipRevision !== undefined ? this.loader.chipRevision === this.image.chipRevision
|
|
35
54
|
: Number.isInteger(this.image.chipRevisionMin) && Number.isInteger(this.image.chipRevisionMax) && this.loader.chipRevision >= this.image.chipRevisionMin && this.loader.chipRevision <= this.image.chipRevisionMax
|
|
@@ -43,9 +62,18 @@ export class FirmwareFlashSession {
|
|
|
43
62
|
const encrypted = security.flashCryptCnt.toString(2).replace(/0/g, '').length % 2 === 1
|
|
44
63
|
if ((security.flags & 1) !== 0 || encrypted) throw new Error('Secure boot or encrypted devices require their signed flashing workflow')
|
|
45
64
|
this.stub = await this.loader.runStub(true)
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
65
|
+
if (this.mode === 'install') {
|
|
66
|
+
// JEDEC capacity byte encodes log2(bytes); shared with browser loaders.
|
|
67
|
+
if (typeof this.stub.flashId !== 'function') throw new Error('Installation requires ROM flash capacity detection (flashId)')
|
|
68
|
+
const id = await this.stub.flashId()
|
|
69
|
+
const capacity = (id >>> 16) & 0xff
|
|
70
|
+
if (!Number.isSafeInteger(id) || id <= 0 || id > 0xffffff || capacity < 18 || capacity > 30
|
|
71
|
+
|| 2 ** capacity < this.image.flashSize) throw new Error('Detected flash is unknown or smaller than the approved complete image layout')
|
|
72
|
+
} else {
|
|
73
|
+
const table = this.image.partitionTable
|
|
74
|
+
const existing = await this.stub.readFlash(table.offset, table.bytes)
|
|
75
|
+
if (await firmwareSha256(existing) !== table.sha256) throw new Error('Partition layout differs; select explicit install mode to replace the layout')
|
|
76
|
+
}
|
|
49
77
|
this.state = 'connected'
|
|
50
78
|
} catch (error) { await this.disconnect(); throw error }
|
|
51
79
|
}
|
|
@@ -54,11 +82,11 @@ export class FirmwareFlashSession {
|
|
|
54
82
|
if (this.state !== 'connected') throw new Error('Connect and validate the device before flashing')
|
|
55
83
|
this.state = 'flashing'
|
|
56
84
|
try {
|
|
57
|
-
await validateFirmwareImage(this.image, bytes)
|
|
58
|
-
onPhase('Writing application; bootloader, partition table, NVS and user files are preserved')
|
|
85
|
+
await validateFirmwareImage(this.image, bytes, { mode: this.mode })
|
|
86
|
+
onPhase(this.mode === 'install' ? 'Installing complete firmware; existing data in the written range will be replaced' : 'Writing application; bootloader, partition table, NVS and user files are preserved')
|
|
59
87
|
await this.stub.flashData(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength),
|
|
60
88
|
(written, total) => onProgress(total ? Math.min(80, Math.round(written / total * 80)) : 0), this.image.offset, false)
|
|
61
|
-
onPhase('Verifying the complete application image')
|
|
89
|
+
onPhase(this.mode === 'install' ? 'Verifying the complete installation image' : 'Verifying the complete application image')
|
|
62
90
|
// maxInFlight is a packet count: one 1 KiB packet bounds USB-JTAG backlog.
|
|
63
91
|
const readback = new Uint8Array(bytes.byteLength)
|
|
64
92
|
for (let offset = 0; offset < bytes.byteLength; offset += 65536) {
|
package/src/firmware_updates.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* SPDX-License-Identifier: Apache-2.0
|
|
3
3
|
*/
|
|
4
4
|
import { loadOfficialFirmware } from './firmware_feed.js'
|
|
5
|
-
import { FirmwareFlashSession, validateFirmwareImage } from './firmware_flash.js'
|
|
5
|
+
import { FirmwareFlashSession, validateFirmwareImage, firmwareMode } from './firmware_flash.js'
|
|
6
6
|
|
|
7
7
|
function record(value, keys, name) {
|
|
8
8
|
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new TypeError(`${name} must be an object`)
|
|
@@ -16,7 +16,7 @@ function identity(value) {
|
|
|
16
16
|
if (!value || typeof value.board !== 'string' || !value.board || typeof value.firmwareVersion !== 'string' || !value.firmwareVersion) throw new TypeError('An inspected identity with board and firmwareVersion is required')
|
|
17
17
|
return structuredClone(value)
|
|
18
18
|
}
|
|
19
|
-
function release(value) {
|
|
19
|
+
function release(value, mode = 'update') {
|
|
20
20
|
if (!value || typeof value.board !== 'string' || !value.board || typeof value.version !== 'string' || !value.version) throw new TypeError('Release requires board and version')
|
|
21
21
|
const image = value.image
|
|
22
22
|
const revisionValid = image?.chipRevision !== undefined
|
|
@@ -29,6 +29,23 @@ function release(value) {
|
|
|
29
29
|
|| !/^[a-f0-9]{64}$/.test(image.sha256 ?? '') || !Number.isSafeInteger(image.partitionTable?.offset) || image.partitionTable.offset < 0
|
|
30
30
|
|| !Number.isSafeInteger(image.partitionTable?.bytes) || image.partitionTable.bytes <= 0
|
|
31
31
|
|| !/^[a-f0-9]{64}$/.test(image.partitionTable?.sha256 ?? '')) throw new TypeError('Release requires complete EV-MUX v4 application/chip/partition/SHA-256 metadata')
|
|
32
|
+
if (mode === 'install') {
|
|
33
|
+
const install = value.install
|
|
34
|
+
if (!install || install.offset !== 0 || !Number.isSafeInteger(install.bytes) || install.bytes <= 0
|
|
35
|
+
|| !Number.isSafeInteger(install.flashSize) || install.flashSize < install.bytes || install.flashSize > 2 ** 30
|
|
36
|
+
|| !Number.isInteger(Math.log2(install.flashSize)) || install.flashSize < 2 ** 18
|
|
37
|
+
|| !/^[a-f0-9]{64}$/.test(install.sha256 ?? '') || !install.bootloader
|
|
38
|
+
|| !Number.isSafeInteger(install.chipRevisionMin) || !Number.isSafeInteger(install.chipRevisionMax)
|
|
39
|
+
|| install.chipRevisionMin < (image.chipRevision ?? image.chipRevisionMin) || install.chipRevisionMax > (image.chipRevision ?? image.chipRevisionMax)
|
|
40
|
+
|| install.chipRevisionMin < 0 || install.chipRevisionMax < install.chipRevisionMin) throw Object.assign(new TypeError('Release lacks qualified complete-install metadata'), { code: 'FIRMWARE_INSTALL_UNAVAILABLE' })
|
|
41
|
+
let end = 0
|
|
42
|
+
for (const part of [install.bootloader, image.partitionTable, image]) {
|
|
43
|
+
if (!Number.isSafeInteger(part.offset) || !Number.isSafeInteger(part.bytes) || part.bytes <= 0
|
|
44
|
+
|| part.offset < end || part.offset + part.bytes > install.bytes
|
|
45
|
+
|| !/^[a-f0-9]{64}$/.test(part.sha256 ?? '')) throw new TypeError('Invalid complete-install segment bounds or hashes')
|
|
46
|
+
end = part.offset + part.bytes
|
|
47
|
+
}
|
|
48
|
+
}
|
|
32
49
|
return structuredClone(value)
|
|
33
50
|
}
|
|
34
51
|
async function download(url, fetcher, signal) {
|
|
@@ -41,10 +58,11 @@ async function download(url, fetcher, signal) {
|
|
|
41
58
|
}
|
|
42
59
|
|
|
43
60
|
export async function checkFirmware(request = {}, connectedIdentity) {
|
|
44
|
-
record(request, ['catalog', 'identity', 'version', 'fetch', 'signal'], 'firmware.check')
|
|
61
|
+
record(request, ['catalog', 'identity', 'version', 'mode', 'fetch', 'signal'], 'firmware.check')
|
|
45
62
|
const { signal, fetch: fetcher = globalThis.fetch } = request
|
|
46
63
|
abort(signal)
|
|
47
64
|
if (request.version !== undefined && (typeof request.version !== 'string' || !request.version.trim())) throw new TypeError('version must be a non-empty firmware version string')
|
|
65
|
+
const mode = firmwareMode(request.mode)
|
|
48
66
|
const current = identity(connectedIdentity ?? request.identity)
|
|
49
67
|
const catalog = request.catalog === undefined ? await loadOfficialFirmware({ board: current.board, fetch: fetcher, signal }) : typeof request.catalog === 'string' ? await (await download(request.catalog, fetcher, signal)).json() : structuredClone(request.catalog)
|
|
50
68
|
abort(signal)
|
|
@@ -56,22 +74,30 @@ export async function checkFirmware(request = {}, connectedIdentity) {
|
|
|
56
74
|
return structuredClone(item)
|
|
57
75
|
})
|
|
58
76
|
if (new Set(candidates.map(item => item.version)).size !== candidates.length) throw new TypeError('Catalog must not contain duplicate versions for one board')
|
|
59
|
-
const selected = request.version === undefined ? candidates.find(item => item.compatible !== false) ?? null : candidates.find(item => item.version === request.version)
|
|
77
|
+
const selected = request.version === undefined ? candidates.find(item => item.compatible !== false && (mode !== 'install' || item.install)) ?? null : candidates.find(item => item.version === request.version)
|
|
60
78
|
if (request.version !== undefined && !selected) throw Object.assign(new Error('Requested firmware version is unavailable for this board'), { code: 'FIRMWARE_VERSION_UNAVAILABLE' })
|
|
61
79
|
if (selected?.compatible === false) throw Object.assign(new Error('This published version lacks approved v4 update metadata'), { code: 'FIRMWARE_INCOMPATIBLE' })
|
|
80
|
+
if (selected) release(selected, mode)
|
|
62
81
|
return { available: selected !== null && (selected.firmwareVersion ?? selected.version) !== current.firmwareVersion, currentVersion: current.firmwareVersion, release: selected, releases: candidates }
|
|
63
82
|
}
|
|
64
83
|
|
|
65
84
|
export async function updateFirmware(request, connectedIdentity, disconnect) {
|
|
66
|
-
record(request, ['release', 'board', 'data', 'fetch', 'openLoader', 'reset', 'onProgress', 'onPhase', 'signal'], 'firmware.update')
|
|
85
|
+
record(request, ['release', 'board', 'mode', 'data', 'fetch', 'openLoader', 'reset', 'onProgress', 'onPhase', 'signal'], 'firmware.update')
|
|
67
86
|
const { signal, fetch: fetcher = globalThis.fetch, openLoader, reset, onProgress = () => {}, onPhase = () => {} } = request
|
|
68
87
|
abort(signal)
|
|
69
88
|
if (typeof openLoader !== 'function' || typeof onProgress !== 'function' || typeof onPhase !== 'function' || (reset !== undefined && typeof reset !== 'function')) throw new TypeError('Firmware update requires openLoader and valid callbacks')
|
|
70
|
-
const
|
|
89
|
+
const mode = firmwareMode(request.mode)
|
|
90
|
+
const target = release(request.release, mode)
|
|
91
|
+
const image = mode === 'install' ? {
|
|
92
|
+
...target.image, offset: target.install.offset, bytes: target.install.bytes,
|
|
93
|
+
flashSize: target.install.flashSize, sha256: target.install.sha256, url: target.install.url,
|
|
94
|
+
bootloader: target.install.bootloader, application: target.image,
|
|
95
|
+
chipRevision: undefined, chipRevisionMin: target.install.chipRevisionMin, chipRevisionMax: target.install.chipRevisionMax,
|
|
96
|
+
} : target.image
|
|
71
97
|
const board = connectedIdentity?.board ?? request.board
|
|
72
98
|
if (!board || board !== target.board || (request.board !== undefined && request.board !== board)) throw new TypeError('Firmware release does not match the connected or explicitly selected board')
|
|
73
|
-
const bytes = request.data === undefined ? new Uint8Array(await (await download(
|
|
74
|
-
await validateFirmwareImage(
|
|
99
|
+
const bytes = request.data === undefined ? new Uint8Array(await (await download(image.url, fetcher, signal)).arrayBuffer()) : request.data instanceof Uint8Array ? request.data.slice() : request.data
|
|
100
|
+
await validateFirmwareImage(image, bytes, { mode })
|
|
75
101
|
abort(signal)
|
|
76
102
|
// Validate everything before releasing EV-MUX or acquiring a download port.
|
|
77
103
|
await disconnect()
|
|
@@ -80,7 +106,7 @@ export async function updateFirmware(request, connectedIdentity, disconnect) {
|
|
|
80
106
|
let session
|
|
81
107
|
try {
|
|
82
108
|
abort(signal)
|
|
83
|
-
session = new FirmwareFlashSession(loader,
|
|
109
|
+
session = new FirmwareFlashSession(loader, image, { mode, reset: reset ? () => reset(loader) : undefined })
|
|
84
110
|
await session.connect(bytes)
|
|
85
111
|
abort(signal)
|
|
86
112
|
// No interruption after writing starts: finish verification and reset policy.
|
package/src/node.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
// Node-only adapters. Creating them does not acquire a session or serial port.
|
|
4
|
+
export { tcpTransport, serialTransport } from './node_transport.js'
|
|
5
|
+
export { openNodeRom } from './node_rom.js'
|
package/src/node_rom.js
CHANGED
|
@@ -41,6 +41,7 @@ export function openNodeRom({ port, python = defaultPython, signal } = {}) {
|
|
|
41
41
|
try { Object.assign(loader, await rpc('initialize')) }
|
|
42
42
|
finally { signal?.removeEventListener('abort', cancelled) }
|
|
43
43
|
},
|
|
44
|
+
flashId: () => rpc('flashId'),
|
|
44
45
|
getSecurityInfo: () => rpc('security'),
|
|
45
46
|
async runStub() { await rpc('stub'); return loader },
|
|
46
47
|
async readFlash(offset, bytes) { return new Uint8Array(Buffer.from(await rpc('read', [offset, bytes]), 'base64')) },
|
package/src/node_rom.py
CHANGED
|
@@ -32,6 +32,8 @@ def main():
|
|
|
32
32
|
esp = esptool.run_stub(esp)
|
|
33
33
|
esptool.attach_flash(esp)
|
|
34
34
|
esp.change_baud(460800)
|
|
35
|
+
elif method == 'flashId':
|
|
36
|
+
result = esp.flash_id()
|
|
35
37
|
elif method == 'read':
|
|
36
38
|
result = base64.b64encode(esp.read_flash(*args)).decode('ascii')
|
|
37
39
|
elif method == 'write':
|
package/tests/check-package.mjs
CHANGED
|
@@ -38,7 +38,7 @@ try {
|
|
|
38
38
|
if (Object.keys(api).sort().join(',') !== 'createDevice') throw new Error('Root must expose only the application API');
|
|
39
39
|
const { DeviceSession } = await import('@esp-vision/device-core/session');
|
|
40
40
|
const { FirmwareFlashSession } = await import('@esp-vision/device-core/firmware');
|
|
41
|
-
const { tcpTransport, serialTransport } = await import('@esp-vision/device-core/node');
|
|
41
|
+
const { tcpTransport, serialTransport, openNodeRom } = await import('@esp-vision/device-core/node');
|
|
42
42
|
if (typeof tcpTransport !== 'function' || typeof serialTransport !== 'function') throw new Error('Missing public Node transports');
|
|
43
43
|
const { createNodeDiscovery } = await import('@esp-vision/device-core/discovery/node');
|
|
44
44
|
const { EventEmitter } = await import('node:events');
|
|
@@ -49,6 +49,7 @@ try {
|
|
|
49
49
|
const device = api.createDevice({ discovery: provider });
|
|
50
50
|
if (typeof provider.list !== 'function') throw new Error('Discovery providers must expose list()');
|
|
51
51
|
if ((await device.discover({ timeout: 50 })).length !== 0 || !queries) throw new Error('Device discovery did not call its provider');
|
|
52
|
+
if (typeof openNodeRom !== 'function' || typeof openNodeRom({ port: 'not-opened' }).initialize !== 'function') throw new Error('Missing passive Node ROM adapter');
|
|
52
53
|
// Constructors must be passive: these endpoints need not exist to create adapters.
|
|
53
54
|
api.createDevice({ transport: tcpTransport({ host: '127.0.0.1', port: 1 }) });
|
|
54
55
|
api.createDevice({ transport: serialTransport({ python: 'python3', serial: 'not-opened' }) });
|
|
@@ -13,6 +13,7 @@ function executionFixture({ short = false, failWrite = false, failStop = false,
|
|
|
13
13
|
let session
|
|
14
14
|
let resetRequested = false
|
|
15
15
|
let resetPolls = 0
|
|
16
|
+
let uploaded = 0
|
|
16
17
|
const stdout = (payload) => session.feedBytes(makeEvMuxFrame({
|
|
17
18
|
channel: 'repl.stdout', type: 'data', sessionId: session.sessionId, vmEpoch: session.vmEpoch, payload,
|
|
18
19
|
}))
|
|
@@ -26,7 +27,7 @@ function executionFixture({ short = false, failWrite = false, failStop = false,
|
|
|
26
27
|
const parser = new EvMuxParser({
|
|
27
28
|
filterFrame: (metadata, bytes) => {
|
|
28
29
|
const text = new TextDecoder().decode(bytes)
|
|
29
|
-
const payload = metadata.channel.endsWith('.rpc') ? JSON.parse(text) : text
|
|
30
|
+
const payload = metadata.encoding === 'binary' ? bytes : metadata.channel.endsWith('.rpc') ? JSON.parse(text) : text
|
|
30
31
|
requests.push({ metadata, payload })
|
|
31
32
|
if (metadata.channel === 'repl.signal') return
|
|
32
33
|
if (metadata.channel === 'repl.stdin') {
|
|
@@ -51,7 +52,15 @@ function executionFixture({ short = false, failWrite = false, failStop = false,
|
|
|
51
52
|
response = { ok: true, sessionId: session.sessionId, vmEpoch: responseEpoch,
|
|
52
53
|
vmReady: stalledReset || resetPolls === 1 || resetPolls >= 3 }
|
|
53
54
|
}
|
|
54
|
-
if (metadata.method === '
|
|
55
|
+
if (metadata.method === 'file.write') {
|
|
56
|
+
if (payload instanceof Uint8Array) {
|
|
57
|
+
uploaded = new DataView(payload.buffer, payload.byteOffset, payload.byteLength).getUint32(16, true) + payload.length - 20
|
|
58
|
+
response = { ok: true, writtenBytes: uploaded }
|
|
59
|
+
} else if (payload.action === 'begin') {
|
|
60
|
+
uploaded = 0; response = { ok: true, uploadId: payload.uploadId, chunkBytes: 16384 }
|
|
61
|
+
} else response = { ok: true, bytes: uploaded, complete: payload.action === 'commit' }
|
|
62
|
+
}
|
|
63
|
+
if (metadata.method === 'file.write' && failWrite) {
|
|
55
64
|
response = { ok: false, error: { code: 'WRITE_FAILED', message: 'simulated write failure' } }
|
|
56
65
|
} else if (metadata.method === 'script.run') {
|
|
57
66
|
running = true
|
|
@@ -75,7 +84,8 @@ function executionFixture({ short = false, failWrite = false, failStop = false,
|
|
|
75
84
|
session.sessionId = 'a'.repeat(32)
|
|
76
85
|
session.vmEpoch = 1
|
|
77
86
|
session.vmReady = true
|
|
78
|
-
session.
|
|
87
|
+
session.limits.fileWriteChunkMax = 16384
|
|
88
|
+
session.capabilities = { features: ['file.write.binary', 'script.run', 'device.control'] }
|
|
79
89
|
return { session, requests, finish, get terminal() { return terminal }, get running() { return running } }
|
|
80
90
|
}
|
|
81
91
|
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
import assert from 'node:assert/strict'
|
|
4
|
+
import { test } from 'node:test'
|
|
5
|
+
import { DeviceSession } from '../src/device_session.js'
|
|
6
|
+
import { EvMuxParser, makeEvMuxFrame } from '../src/evmux.js'
|
|
7
|
+
import { EvRepl } from '../src/evrepl.js'
|
|
8
|
+
|
|
9
|
+
function fixture(options = {}) {
|
|
10
|
+
const requests = [], chunks = []; let session, began, finished
|
|
11
|
+
let token
|
|
12
|
+
const peer = new EvMuxParser({ filterFrame(metadata, bytes) {
|
|
13
|
+
const payload = metadata.encoding === 'binary' ? bytes.slice() : JSON.parse(new TextDecoder().decode(bytes))
|
|
14
|
+
requests.push({ metadata, payload })
|
|
15
|
+
let reply = { ok: true }
|
|
16
|
+
if (payload instanceof Uint8Array) {
|
|
17
|
+
assert.equal(metadata.method, 'file.write'); assert.equal(metadata.encoding, 'binary')
|
|
18
|
+
assert.equal(metadata.vmEpoch, 7); assert.equal(metadata.sessionId, 'a'.repeat(32))
|
|
19
|
+
assert.equal(Buffer.from(payload.slice(0, 16)).toString('hex'), token)
|
|
20
|
+
const offset = new DataView(payload.buffer).getUint32(16, true)
|
|
21
|
+
assert.equal(offset, chunks.reduce((sum, chunk) => sum + chunk.length, 0))
|
|
22
|
+
chunks.push(payload.slice(20)); reply.writtenBytes = offset + payload.length - 20
|
|
23
|
+
if (options.badAck) reply.writtenBytes--
|
|
24
|
+
if (options.abort) options.abort.abort()
|
|
25
|
+
} else if (payload.action === 'begin') {
|
|
26
|
+
began = payload; token = payload.uploadId; reply.uploadId = token; reply.chunkBytes = 16384
|
|
27
|
+
if (options.abortBegin) options.abortBegin.abort()
|
|
28
|
+
if (options.failBegin) { options.failBegin = false; throw new Error('begin response lost') }
|
|
29
|
+
} else if (payload.action === 'commit') {
|
|
30
|
+
finished = true; reply.bytes = chunks.reduce((sum, chunk) => sum + chunk.length, 0); reply.complete = true
|
|
31
|
+
if (options.badCommit) reply.bytes++
|
|
32
|
+
if (options.failCommit) reply = { ok: false, error: { code: 'HASH_MISMATCH' } }
|
|
33
|
+
}
|
|
34
|
+
session.feedBytes(makeEvMuxFrame({ channel: 'user.rpc', type: 'rsp', method: metadata.method, seq: metadata.seq,
|
|
35
|
+
sessionId: 'a'.repeat(32), vmEpoch: 7, payload: JSON.stringify(reply) }))
|
|
36
|
+
return false
|
|
37
|
+
} })
|
|
38
|
+
session = new DeviceSession({ transport: { async writeBytes(bytes) { peer.feedBytes(bytes) } }, EvMuxParser, makeEvMuxFrame, EvRepl })
|
|
39
|
+
Object.assign(session, { state: 'ready', sessionId: 'a'.repeat(32), vmEpoch: 7, vmReady: true,
|
|
40
|
+
capabilities: { features: ['file.write.binary', 'script.run'] } })
|
|
41
|
+
session.limits.fileWriteChunkMax = 16384
|
|
42
|
+
return { session, requests, chunks, get began() { return began }, get finished() { return finished } }
|
|
43
|
+
}
|
|
44
|
+
for (const size of [0, 1, 4096, 16384, 16385, 2 * 1024 * 1024]) {
|
|
45
|
+
test(`binary upload ${size} bytes is bounded, exact, hashed and committed`, async () => {
|
|
46
|
+
const f = fixture(), data = Uint8Array.from({ length: size }, (_, i) => i & 255), progress = []
|
|
47
|
+
try {
|
|
48
|
+
const result = await f.session.writeFile('/model.bin', data, { encoding: 'binary', onProgress: value => progress.push(value.writtenBytes) })
|
|
49
|
+
assert.equal(result.complete, true); assert.equal(result.bytes, size)
|
|
50
|
+
assert.deepEqual(Buffer.concat(f.chunks), Buffer.from(data)); assert.equal(f.finished, true)
|
|
51
|
+
assert.equal(f.began.sha256, Buffer.from(await crypto.subtle.digest('SHA-256', data)).toString('hex'))
|
|
52
|
+
assert.ok(f.chunks.every(chunk => chunk.length <= 16384)); assert.equal(f.chunks.length, Math.ceil(size / 16384))
|
|
53
|
+
assert.equal(progress.at(-1), size)
|
|
54
|
+
assert.ok(!f.requests.some(request => request.metadata.method === 'script.write'))
|
|
55
|
+
} finally { f.session.close() }
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
test('Python upload uses the same byte engine and requests execution only after commit', async () => {
|
|
59
|
+
const f = fixture()
|
|
60
|
+
try {
|
|
61
|
+
await f.session.writeScript('/script.py', "print('中文')", { runAfterWrite: true })
|
|
62
|
+
assert.deepEqual(Buffer.concat(f.chunks), Buffer.from("print('中文')"))
|
|
63
|
+
assert.equal(f.requests.at(-1).metadata.method, 'script.run')
|
|
64
|
+
assert.equal(f.requests.at(-2).payload.action, 'commit')
|
|
65
|
+
} finally { f.session.close() }
|
|
66
|
+
})
|
|
67
|
+
for (const setting of ['badAck', 'badCommit', 'failCommit']) {
|
|
68
|
+
test(`${setting} triggers abort cleanup without publishing success`, async () => {
|
|
69
|
+
const f = fixture({ [setting]: true })
|
|
70
|
+
try {
|
|
71
|
+
await assert.rejects(f.session.writeFile('/x.bin', new Uint8Array(100)))
|
|
72
|
+
assert.equal(f.requests.at(-1).payload.action, 'abort')
|
|
73
|
+
} finally { f.session.close() }
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
test('cancellation after begin still sends an independent abort', async () => {
|
|
77
|
+
const controller = new AbortController(), f = fixture({ abort: controller })
|
|
78
|
+
try {
|
|
79
|
+
await assert.rejects(f.session.writeFile('/x.bin', new Uint8Array(32768), { signal: controller.signal }), { name: 'AbortError' })
|
|
80
|
+
assert.equal(f.requests.at(-1).payload.action, 'abort')
|
|
81
|
+
} finally { f.session.close() }
|
|
82
|
+
})
|
|
83
|
+
test('missing binary capability rejects without falling back to script.write', async () => {
|
|
84
|
+
const f = fixture(); f.session.capabilities.features = ['script.write']
|
|
85
|
+
try { await assert.rejects(f.session.writeFile('/x.bin', new Uint8Array(10)), { code: 'UNSUPPORTED_OPERATION' }); assert.equal(f.requests.length, 0) }
|
|
86
|
+
finally { f.session.close() }
|
|
87
|
+
})
|
|
88
|
+
test('corrupted binary frames fail CRC before dispatch', () => {
|
|
89
|
+
let calls = 0
|
|
90
|
+
const parser = new EvMuxParser({ filterFrame() { calls++; return false } })
|
|
91
|
+
const bytes = makeEvMuxFrame({ channel: 'user.rpc', type: 'req', method: 'file.write', encoding: 'binary', payload: new Uint8Array([0, 1, 0x1e, 0x1f, 255]) })
|
|
92
|
+
parser.feedBytes(bytes); assert.equal(calls, 1)
|
|
93
|
+
bytes[bytes.length - 2] ^= 1; parser.feedBytes(bytes); assert.equal(calls, 1)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test('cancellation during begin still aborts the client-known upload ID', async () => {
|
|
97
|
+
const controller = new AbortController(), f = fixture({ abortBegin: controller })
|
|
98
|
+
try {
|
|
99
|
+
await assert.rejects(f.session.writeFile('/x.bin', new Uint8Array(100), { signal: controller.signal }), { name: 'AbortError' })
|
|
100
|
+
assert.equal(f.requests.at(-1).payload.action, 'abort')
|
|
101
|
+
assert.equal(f.requests.at(-1).payload.uploadId, f.began.uploadId)
|
|
102
|
+
assert.equal(f.chunks.length, 0)
|
|
103
|
+
} finally { f.session.close() }
|
|
104
|
+
})
|
|
105
|
+
test('a lost begin response still allows abort without knowing a server-generated ID', async () => {
|
|
106
|
+
const f = fixture({ failBegin: true })
|
|
107
|
+
try {
|
|
108
|
+
await assert.rejects(f.session.writeFile('/x.bin', new Uint8Array(100)), /begin response lost/)
|
|
109
|
+
assert.equal(f.requests.at(-1).payload.action, 'abort')
|
|
110
|
+
assert.equal(f.requests.at(-1).payload.uploadId, f.began.uploadId)
|
|
111
|
+
} finally { f.session.close() }
|
|
112
|
+
})
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
import { test } from 'node:test'
|
|
4
|
+
import assert from 'node:assert/strict'
|
|
5
|
+
import { createDevice } from '../src/index.js'
|
|
6
|
+
import { firmwareSha256 } from '../src/firmware_flash.js'
|
|
7
|
+
import { parseCli, runCli } from '../src/cli.js'
|
|
8
|
+
|
|
9
|
+
async function fixture() {
|
|
10
|
+
const data = new Uint8Array(0x11000).fill(0xff), calls = []
|
|
11
|
+
data.set([0xe9, 1, 2, 3], 0x2000)
|
|
12
|
+
data.set([0xaa, 0x50, 4, 5], 0x8000)
|
|
13
|
+
data.set([0xe9, 6, 7, 8], 0x10000)
|
|
14
|
+
const part = async offset => ({ offset, bytes: 4, sha256: await firmwareSha256(data.subarray(offset, offset + 4)) })
|
|
15
|
+
const release = { board: 'BOARD', version: 'new', image: {
|
|
16
|
+
evMuxVersion: 4, chipFamily: 0x3280, chipRevision: 302, ...(await part(0x10000)),
|
|
17
|
+
partitionSize: 4096, partitionTable: await part(0x8000), url: 'https://example.test/app.bin',
|
|
18
|
+
}, install: { offset: 0, bytes: data.length, flashSize: 0x1000000, chipRevisionMin: 302, chipRevisionMax: 302,
|
|
19
|
+
sha256: await firmwareSha256(data), bootloader: await part(0x2000), url: 'https://example.test/full.bin' } }
|
|
20
|
+
const flash = new Uint8Array(data.length).fill(0xff)
|
|
21
|
+
const loader = {
|
|
22
|
+
chipFamily: 0x3280, chipRevision: 302,
|
|
23
|
+
async initialize() { calls.push('initialize') },
|
|
24
|
+
async getSecurityInfo() { return { flags: 0, flashCryptCnt: 0 } },
|
|
25
|
+
async runStub() { calls.push('stub'); return loader },
|
|
26
|
+
async flashId() { calls.push('capacity'); return 0x1840ef },
|
|
27
|
+
async flashData(buffer, progress, offset) { calls.push(['write', offset]); flash.set(new Uint8Array(buffer), offset); progress(buffer.byteLength, buffer.byteLength) },
|
|
28
|
+
async readFlash(offset, count) { calls.push(['read', offset]); return flash.slice(offset, offset + count) },
|
|
29
|
+
async readRegister() { return 0 },
|
|
30
|
+
async hardReset() { calls.push('reset') },
|
|
31
|
+
async disconnect() { calls.push('close') },
|
|
32
|
+
}
|
|
33
|
+
const device = createDevice(), request = { mode: 'install', release, board: 'BOARD', data, async openLoader() { calls.push('open'); return loader } }
|
|
34
|
+
return { data, calls, release, loader, device, request, flash }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
test('blank device installs through ROM without an EV-MUX transport or existing partition table', async () => {
|
|
38
|
+
const f = await fixture(), progress = []
|
|
39
|
+
const result = await f.device.firmware.update({ ...f.request, onProgress: value => progress.push(value) })
|
|
40
|
+
assert.equal(result.verified, true); assert.equal(result.reconnected, false)
|
|
41
|
+
assert.deepEqual(f.flash, f.data)
|
|
42
|
+
assert.deepEqual(f.calls, ['open', 'initialize', 'stub', 'capacity', ['write', 0], ['read', 0], ['read', 65536], 'reset', 'close'])
|
|
43
|
+
assert.equal(progress.at(-1), 100)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('default application update refuses blank layout and never falls back to installing', async () => {
|
|
47
|
+
const f = await fixture()
|
|
48
|
+
await assert.rejects(f.device.firmware.update({ ...f.request, mode: undefined, data: f.data.slice(65536, 65540) }), /Partition layout/)
|
|
49
|
+
assert.ok(!f.calls.some(call => Array.isArray(call) && call[0] === 'write'))
|
|
50
|
+
assert.ok(!f.calls.includes('capacity'))
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('complete image is downloaded from install URL with the requested cancellation signal', async () => {
|
|
54
|
+
const f = await fixture(), controller = new AbortController()
|
|
55
|
+
await f.device.firmware.update({ ...f.request, data: undefined, signal: controller.signal,
|
|
56
|
+
async fetch(url, options) { assert.equal(url, f.release.install.url); assert.equal(options.signal, controller.signal); return { ok: true, arrayBuffer: async () => f.data.buffer } },
|
|
57
|
+
})
|
|
58
|
+
assert.deepEqual(f.flash, f.data)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('install selection skips app-only releases and rejects explicit unqualified versions', async () => {
|
|
62
|
+
const f = await fixture(), appOnly = { ...f.release, version: 'app-only', install: undefined }
|
|
63
|
+
const request = { mode: 'install', identity: { board: 'BOARD', firmwareVersion: 'unknown' }, catalog: { schemaVersion: 1, releases: [appOnly, f.release] } }
|
|
64
|
+
const result = await f.device.firmware.check(request)
|
|
65
|
+
assert.equal(result.release.version, 'new'); assert.equal(result.releases.length, 2)
|
|
66
|
+
await assert.rejects(f.device.firmware.check({ ...request, version: 'app-only' }), { code: 'FIRMWARE_INSTALL_UNAVAILABLE' })
|
|
67
|
+
const updates = await f.device.firmware.check({ ...request, mode: 'update' })
|
|
68
|
+
assert.equal(updates.release.version, 'app-only')
|
|
69
|
+
assert.deepEqual(f.calls, [])
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
for (const change of ['hash', 'bootloader', 'table', 'application', 'overlap', 'bounds', 'revision-range', 'missing-metadata', 'mode', 'board']) {
|
|
73
|
+
test(`invalid ${change} rejects installation before opening or releasing a port`, async () => {
|
|
74
|
+
const f = await fixture()
|
|
75
|
+
if (change === 'hash') f.data[1] = 0
|
|
76
|
+
if (change === 'bootloader') f.release.install.bootloader.sha256 = '0'.repeat(64)
|
|
77
|
+
if (change === 'table') f.release.image.partitionTable.sha256 = '0'.repeat(64)
|
|
78
|
+
if (change === 'application') f.release.image.sha256 = '0'.repeat(64)
|
|
79
|
+
if (change === 'overlap') f.release.install.bootloader.bytes = 0x8000
|
|
80
|
+
if (change === 'bounds') f.release.install.offset = 0x1000
|
|
81
|
+
if (change === 'revision-range') f.release.install.chipRevisionMin = 301
|
|
82
|
+
if (change === 'missing-metadata') delete f.release.install
|
|
83
|
+
if (change === 'mode') f.request.mode = 'auto'
|
|
84
|
+
if (change === 'board') f.request.board = 'WRONG'
|
|
85
|
+
await assert.rejects(f.device.firmware.update(f.request))
|
|
86
|
+
assert.deepEqual(f.calls, [])
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
for (const change of ['wrong-chip', 'wrong-revision', 'secure-boot', 'encryption', 'small-flash', 'unknown-flash', 'missing-capacity']) {
|
|
91
|
+
test(`physical ${change} refuses installation before any write`, async () => {
|
|
92
|
+
const f = await fixture()
|
|
93
|
+
if (change === 'wrong-chip') f.loader.chipFamily = 0x3253
|
|
94
|
+
if (change === 'wrong-revision') f.loader.chipRevision = 301
|
|
95
|
+
if (change === 'secure-boot') f.loader.getSecurityInfo = async () => ({ flags: 1, flashCryptCnt: 0 })
|
|
96
|
+
if (change === 'encryption') f.loader.getSecurityInfo = async () => ({ flags: 0, flashCryptCnt: 1 })
|
|
97
|
+
if (change === 'small-flash') f.loader.flashId = async () => 0x1740ef
|
|
98
|
+
if (change === 'unknown-flash') f.loader.flashId = async () => 0xffffff
|
|
99
|
+
if (change === 'missing-capacity') delete f.loader.flashId
|
|
100
|
+
await assert.rejects(f.device.firmware.update(f.request))
|
|
101
|
+
assert.ok(!f.calls.some(call => Array.isArray(call) && call[0] === 'write'))
|
|
102
|
+
assert.equal(f.calls.at(-1), 'close')
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
test('installation with damaged readback does not reset or report success', async () => {
|
|
107
|
+
const f = await fixture(); f.loader.readFlash = async (_offset, count) => new Uint8Array(count)
|
|
108
|
+
await assert.rejects(f.device.firmware.update(f.request), /verification failed/)
|
|
109
|
+
assert.ok(!f.calls.includes('reset')); assert.equal(f.calls.at(-1), 'close')
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
test('CLI install uses ROM directly and passes mode through both check and update', async () => {
|
|
113
|
+
const f = await fixture(), checked = []
|
|
114
|
+
const options = parseCli(['firmware', 'update', '--mode', 'install', '--board', 'BOARD', '--port', '/dev/ttyACM0', '--version', 'new'])
|
|
115
|
+
const result = await runCli(options, {
|
|
116
|
+
transportFor: () => { throw new Error('No application transport should be acquired') },
|
|
117
|
+
async readFile() { throw new Error('No user catalog required') },
|
|
118
|
+
createDevice: () => ({ firmware: {
|
|
119
|
+
async check(request) { checked.push(request.mode); return { release: f.release } },
|
|
120
|
+
async update(request) { assert.equal(request.mode, 'install'); return { verified: true } },
|
|
121
|
+
}, async disconnect() {} }),
|
|
122
|
+
})
|
|
123
|
+
assert.equal(result.verified, true); assert.deepEqual(checked, ['install'])
|
|
124
|
+
assert.throws(() => parseCli(['firmware', 'update', '--mode', 'install', '--port', 'COM3', '--version', 'new']), /explicit --board/)
|
|
125
|
+
assert.throws(() => parseCli(['firmware', 'update', '--mode', 'install', '--tcp', 'host', '--board', 'BOARD', '--version', 'new']), /local --port/)
|
|
126
|
+
})
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
import assert from 'node:assert/strict'
|
|
4
|
+
import { test } from 'node:test'
|
|
5
|
+
import { verifyPublishedIntegrity } from '../scripts/verify-registry.mjs'
|
|
6
|
+
|
|
7
|
+
const integrity = 'sha512-test-content'
|
|
8
|
+
const url = 'https://registry.npmjs.org/%40esp-vision%2Fdevice-core/0.2.0'
|
|
9
|
+
const published = value => new Response(JSON.stringify({ dist: { integrity: value } }))
|
|
10
|
+
function fixture(replies) {
|
|
11
|
+
const calls = [], waits = [], reports = []
|
|
12
|
+
return { calls, waits, reports, options: {
|
|
13
|
+
async fetcher(address, options) {
|
|
14
|
+
calls.push({ address, options })
|
|
15
|
+
const reply = replies.shift()
|
|
16
|
+
if (reply instanceof Error) throw reply
|
|
17
|
+
assert(reply, 'Unexpected verification request')
|
|
18
|
+
return reply
|
|
19
|
+
},
|
|
20
|
+
wait: async milliseconds => { waits.push(milliseconds) },
|
|
21
|
+
report: message => reports.push(message),
|
|
22
|
+
} }
|
|
23
|
+
}
|
|
24
|
+
test('new npm version becomes visible after stale 404 metadata; only uncached reads repeat', async () => {
|
|
25
|
+
const f = fixture([new Response('', { status: 404 }), published(integrity)])
|
|
26
|
+
await verifyPublishedIntegrity(url, integrity, f.options)
|
|
27
|
+
assert.equal(f.calls.length, 2); assert.deepEqual(f.waits, [1000])
|
|
28
|
+
for (const call of f.calls) {
|
|
29
|
+
assert.equal(call.address, url); assert.equal(call.options.cache, 'no-store')
|
|
30
|
+
assert.equal(call.options.headers['Cache-Control'], 'no-cache')
|
|
31
|
+
assert.equal(call.options.method ?? 'GET', 'GET')
|
|
32
|
+
}
|
|
33
|
+
})
|
|
34
|
+
test('verification retries rate limits, temporary registry errors and network failures', async () => {
|
|
35
|
+
const f = fixture([new Response('', { status: 429 }), new Response('', { status: 503 }), new TypeError('network unavailable'), published(integrity)])
|
|
36
|
+
await verifyPublishedIntegrity(url, integrity, f.options)
|
|
37
|
+
assert.deepEqual(f.waits, [1000, 2000, 4000])
|
|
38
|
+
})
|
|
39
|
+
for (const status of [400, 401, 403]) test(`registry HTTP ${status} fails without retries`, async () => {
|
|
40
|
+
const f = fixture([new Response('', { status })])
|
|
41
|
+
await assert.rejects(verifyPublishedIntegrity(url, integrity, f.options), new RegExp(`HTTP ${status}`))
|
|
42
|
+
assert.equal(f.calls.length, 1); assert.deepEqual(f.waits, [])
|
|
43
|
+
})
|
|
44
|
+
test('published integrity mismatch fails immediately', async () => {
|
|
45
|
+
const f = fixture([published('sha512-other-content')])
|
|
46
|
+
await assert.rejects(verifyPublishedIntegrity(url, integrity, f.options), /integrity mismatch/)
|
|
47
|
+
assert.equal(f.calls.length, 1); assert.deepEqual(f.waits, [])
|
|
48
|
+
})
|
|
49
|
+
test('missing published integrity is not accepted', async () => {
|
|
50
|
+
const f = fixture([new Response('{}')])
|
|
51
|
+
await assert.rejects(verifyPublishedIntegrity(url, integrity, f.options), /integrity mismatch/)
|
|
52
|
+
assert.deepEqual(f.waits, [])
|
|
53
|
+
})
|
|
54
|
+
test('persistent propagation failures stop after ten reads with bounded backoff', async () => {
|
|
55
|
+
const f = fixture(Array.from({ length: 10 }, () => new Response('', { status: 404 })))
|
|
56
|
+
await assert.rejects(verifyPublishedIntegrity(url, integrity, f.options), /after 10 reads/)
|
|
57
|
+
assert.equal(f.calls.length, 10); assert.deepEqual(f.waits, [1000, 2000, 4000, 8000, 16000, 30000, 30000, 30000, 30000])
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('registry visibility delayed beyond the original 31-second window is verified without another publish', async () => {
|
|
61
|
+
const f = fixture([...Array.from({ length: 7 }, () => new Response('', { status: 404 })), published(integrity)])
|
|
62
|
+
await verifyPublishedIntegrity(url, integrity, f.options)
|
|
63
|
+
assert.equal(f.calls.length, 8)
|
|
64
|
+
assert.ok(f.waits.reduce((total, delay) => total + delay, 0) > 31000)
|
|
65
|
+
assert.ok(f.waits.every(delay => delay <= 30000))
|
|
66
|
+
})
|