@esp-vision/device-core 0.6.0 → 0.7.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 +29 -0
- package/README.md +8 -5
- package/package.json +1 -1
- package/schemas/device-actions-v1.json +1 -1
- package/scripts/generate-action-schema.mjs +2 -1
- package/scripts/npm-command.mjs +17 -0
- package/scripts/release-npm.mjs +2 -1
- package/src/browser.js +5 -2
- package/src/browser_rom.js +55 -5
- package/src/device_session.js +85 -9
- package/src/evmux.js +15 -0
- package/src/firmware_flash.js +84 -26
- package/src/firmware_partitions.js +57 -0
- package/src/firmware_updates.js +5 -2
- package/src/node_rom.js +49 -7
- package/src/node_rom.py +92 -5
- package/src/node_rom_port.js +27 -19
- package/src/node_serial.py +75 -12
- package/src/node_transport.js +63 -14
- package/src/rom_identity.js +9 -0
- package/tests/check-browser.mjs +23 -4
- package/tests/check-package.mjs +15 -7
- package/tests/device-checks.mjs +89 -7
- package/tests/firmware-fixtures.mjs +60 -0
- package/tests/fixtures/device-checks.py +357 -1
- package/tests/hardware-check.mjs +3 -2
- package/tests/test-browser-rom.mjs +169 -3
- package/tests/test-ci-check-selection.mjs +31 -0
- package/tests/test-device-functional-checks.mjs +275 -3
- package/tests/test-device-v4-alignment.mjs +261 -7
- package/tests/test-firmware-flash.mjs +22 -20
- package/tests/test-firmware-install.mjs +8 -35
- package/tests/test-firmware-source.mjs +10 -7
- package/tests/test-host-checks.mjs +47 -0
- package/tests/test-install-erase.mjs +153 -0
- package/tests/test-node-rom-port.mjs +81 -1
- package/tests/test-node-rom.mjs +159 -4
- package/tests/test-node-serial.mjs +231 -2
- package/tests/test-npm-release-policy.mjs +3 -9
package/src/firmware_flash.js
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
// SPDX-FileCopyrightText: 2026 ESP-VISION-IDE
|
|
2
2
|
// SPDX-License-Identifier: Apache-2.0
|
|
3
3
|
// ROM flashing is separate from DeviceSession and its EV-MUX v4 owner protocol.
|
|
4
|
+
import { planInstallFilesystemErase } from './firmware_partitions.js'
|
|
5
|
+
|
|
6
|
+
function checkAbort(signal) {
|
|
7
|
+
if (signal?.aborted) throw signal.reason instanceof Error ? signal.reason : Object.assign(new Error('Firmware operation cancelled'), { name: 'AbortError' })
|
|
8
|
+
}
|
|
4
9
|
export async function firmwareSha256(bytes) {
|
|
5
10
|
const digest = await globalThis.crypto.subtle.digest('SHA-256', bytes)
|
|
6
11
|
return Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, '0')).join('')
|
|
@@ -35,45 +40,68 @@ export async function validateFirmwareImage(image, bytes, { mode = 'update' } =
|
|
|
35
40
|
|
|
36
41
|
/** Loader injected by the browser host; no Web Serial or ROM protocol duplicated. */
|
|
37
42
|
export class FirmwareFlashSession {
|
|
38
|
-
|
|
43
|
+
#image
|
|
44
|
+
#mode
|
|
45
|
+
#filesystem
|
|
46
|
+
#signal
|
|
47
|
+
|
|
48
|
+
constructor(loader, image, { reset = () => loader.hardReset(), mode = 'update', signal } = {}) {
|
|
39
49
|
this.mode = firmwareMode(mode)
|
|
40
50
|
this.loader = loader
|
|
41
51
|
this.reset = reset
|
|
42
52
|
this.image = image
|
|
43
53
|
this.state = 'idle'
|
|
44
54
|
this.stub = null
|
|
55
|
+
this.#signal = signal
|
|
45
56
|
}
|
|
46
57
|
|
|
47
58
|
async connect(bytes) {
|
|
48
59
|
if (this.state !== 'idle') throw new Error('Firmware loader is already in use')
|
|
49
60
|
this.state = 'connecting'
|
|
50
61
|
try {
|
|
51
|
-
|
|
62
|
+
checkAbort(this.#signal)
|
|
63
|
+
// Keep qualification independent of caller mutations during callbacks or
|
|
64
|
+
// asynchronous ROM operations. Public metadata is not an erase authority.
|
|
65
|
+
const image = this.#image = structuredClone(this.image)
|
|
66
|
+
const mode = this.#mode = firmwareMode(this.mode)
|
|
67
|
+
const payload = bytes instanceof Uint8Array ? new Uint8Array(bytes) : bytes
|
|
68
|
+
await validateFirmwareImage(image, payload, { mode })
|
|
69
|
+
this.#filesystem = mode === 'install' ? planInstallFilesystemErase(image, payload) : null
|
|
70
|
+
checkAbort(this.#signal)
|
|
52
71
|
await this.loader.initialize()
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
72
|
+
checkAbort(this.#signal)
|
|
73
|
+
const revisionMatches = image.chipRevision !== undefined ? this.loader.chipRevision === image.chipRevision
|
|
74
|
+
: Number.isInteger(image.chipRevisionMin) && Number.isInteger(image.chipRevisionMax) && this.loader.chipRevision >= image.chipRevisionMin && this.loader.chipRevision <= image.chipRevisionMax
|
|
75
|
+
if (this.loader.chipFamily !== image.chipFamily || !Number.isInteger(this.loader.chipRevision) || !revisionMatches) {
|
|
56
76
|
throw new Error('Chip or silicon revision does not match the approved board firmware')
|
|
57
77
|
}
|
|
58
78
|
const security = await this.loader.getSecurityInfo()
|
|
59
|
-
|
|
79
|
+
checkAbort(this.#signal)
|
|
80
|
+
if (!Number.isSafeInteger(security.flags) || security.flags < 0 || security.flags > 0xffffffff
|
|
81
|
+
|| !Number.isSafeInteger(security.flashCryptCnt) || security.flashCryptCnt < 0 || security.flashCryptCnt > 0xffffffff) {
|
|
60
82
|
throw new Error('Cannot qualify the device ROM security mode')
|
|
61
83
|
}
|
|
62
84
|
const encrypted = security.flashCryptCnt.toString(2).replace(/0/g, '').length % 2 === 1
|
|
63
|
-
if ((security.flags &
|
|
85
|
+
if ((security.flags & 5) !== 0 || encrypted) throw new Error('Secure boot, secure download or encrypted devices require their signed flashing workflow')
|
|
64
86
|
this.stub = await this.loader.runStub(true)
|
|
65
|
-
|
|
87
|
+
checkAbort(this.#signal)
|
|
88
|
+
if (mode === 'install') {
|
|
66
89
|
// JEDEC capacity byte encodes log2(bytes); shared with browser loaders.
|
|
67
90
|
if (typeof this.stub.flashId !== 'function') throw new Error('Installation requires ROM flash capacity detection (flashId)')
|
|
68
91
|
const id = await this.stub.flashId()
|
|
92
|
+
checkAbort(this.#signal)
|
|
69
93
|
const capacity = (id >>> 16) & 0xff
|
|
70
94
|
if (!Number.isSafeInteger(id) || id <= 0 || id > 0xffffff || capacity < 18 || capacity > 30
|
|
71
|
-
|| 2 ** capacity <
|
|
95
|
+
|| 2 ** capacity < image.flashSize) throw new Error('Detected flash is unknown or smaller than the approved complete image layout')
|
|
96
|
+
if (typeof this.stub.eraseRegion !== 'function' || typeof this.stub.readFlash !== 'function' || typeof this.stub.flashData !== 'function') {
|
|
97
|
+
throw Object.assign(new Error('Complete installation requires bounded filesystem erase and readback support'), { code: 'FIRMWARE_ERASE_UNSUPPORTED' })
|
|
98
|
+
}
|
|
72
99
|
} else {
|
|
73
|
-
const table =
|
|
100
|
+
const table = image.partitionTable
|
|
74
101
|
const existing = await this.stub.readFlash(table.offset, table.bytes)
|
|
75
102
|
if (await firmwareSha256(existing) !== table.sha256) throw new Error('Partition layout differs; select explicit install mode to replace the layout')
|
|
76
103
|
}
|
|
104
|
+
checkAbort(this.#signal)
|
|
77
105
|
this.state = 'connected'
|
|
78
106
|
} catch (error) { await this.disconnect(); throw error }
|
|
79
107
|
}
|
|
@@ -82,33 +110,63 @@ export class FirmwareFlashSession {
|
|
|
82
110
|
if (this.state !== 'connected') throw new Error('Connect and validate the device before flashing')
|
|
83
111
|
this.state = 'flashing'
|
|
84
112
|
try {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
113
|
+
checkAbort(this.#signal)
|
|
114
|
+
const image = this.#image, install = this.#mode === 'install'
|
|
115
|
+
const payload = bytes instanceof Uint8Array ? new Uint8Array(bytes) : bytes
|
|
116
|
+
await validateFirmwareImage(image, payload, { mode: this.#mode })
|
|
117
|
+
checkAbort(this.#signal)
|
|
118
|
+
if (install) {
|
|
119
|
+
const { offset, size } = this.#filesystem
|
|
120
|
+
onPhase('Erasing internal FAT filesystem; device files will be deleted')
|
|
121
|
+
checkAbort(this.#signal)
|
|
122
|
+
// Erase only the range parsed from the qualified image. No whole-chip
|
|
123
|
+
// erase, backups or automatic recovery/replay are part of installation.
|
|
124
|
+
await this.stub.eraseRegion(offset, size)
|
|
125
|
+
checkAbort(this.#signal)
|
|
126
|
+
onPhase('Verifying the erased internal FAT filesystem')
|
|
127
|
+
checkAbort(this.#signal)
|
|
128
|
+
for (let position = 0; position < size; position += 65536) {
|
|
129
|
+
const count = Math.min(65536, size - position)
|
|
130
|
+
const chunk = await this.stub.readFlash(offset + position, count, undefined,
|
|
131
|
+
{ chunkSize: 65536, blockSize: 1024, maxInFlight: 1 })
|
|
132
|
+
checkAbort(this.#signal)
|
|
133
|
+
if (!(chunk instanceof Uint8Array) || chunk.byteLength !== count || chunk.some(value => value !== 0xff)) {
|
|
134
|
+
throw Object.assign(new Error('Internal FAT filesystem erase verification failed; keep the device in download mode'), { code: 'FIRMWARE_ERASE_VERIFY_FAILED' })
|
|
135
|
+
}
|
|
136
|
+
onProgress(Math.floor(20 * (position + count) / size))
|
|
137
|
+
checkAbort(this.#signal)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
onPhase(install ? 'Installing complete firmware; internal FAT filesystem cleared' : 'Writing application; bootloader, partition table, NVS and user files are preserved')
|
|
141
|
+
checkAbort(this.#signal)
|
|
142
|
+
await this.stub.flashData(payload.buffer.slice(payload.byteOffset, payload.byteOffset + payload.byteLength),
|
|
143
|
+
(written, total) => onProgress((install ? 20 : 0) + (total ? Math.min(install ? 60 : 80, Math.round(written / total * (install ? 60 : 80))) : 0)), image.offset, true)
|
|
144
|
+
// Once firmware writing starts, preserve the existing completion policy:
|
|
145
|
+
// finish verification/reset rather than interrupting a partial image.
|
|
146
|
+
onPhase(install ? 'Verifying the complete installation image' : 'Verifying the complete application image')
|
|
90
147
|
// maxInFlight is a packet count: one 1 KiB packet bounds USB-JTAG backlog.
|
|
91
|
-
const readback = new Uint8Array(
|
|
92
|
-
for (let offset = 0; offset <
|
|
93
|
-
const count = Math.min(65536,
|
|
94
|
-
const chunk = await this.stub.readFlash(
|
|
148
|
+
const readback = new Uint8Array(payload.byteLength)
|
|
149
|
+
for (let offset = 0; offset < payload.byteLength; offset += 65536) {
|
|
150
|
+
const count = Math.min(65536, payload.byteLength - offset)
|
|
151
|
+
const chunk = await this.stub.readFlash(image.offset + offset, count, undefined,
|
|
95
152
|
{ chunkSize: 65536, blockSize: 1024, maxInFlight: 1 })
|
|
96
153
|
if (chunk.byteLength !== count) throw new Error('Written firmware readback is incomplete')
|
|
97
154
|
readback.set(chunk, offset)
|
|
98
|
-
onProgress(80 + Math.floor(20 * (offset + count) /
|
|
155
|
+
onProgress(80 + Math.min(19, Math.floor(20 * (offset + count) / payload.byteLength)))
|
|
99
156
|
}
|
|
100
|
-
if (
|
|
157
|
+
if (await firmwareSha256(readback) !== image.sha256) throw new Error('Written firmware verification failed; keep the device in download mode for recovery')
|
|
158
|
+
onProgress(100)
|
|
101
159
|
this.state = 'verified'
|
|
102
160
|
try {
|
|
103
|
-
// P4 software download requests survive a CPU reset. Clear only that
|
|
161
|
+
// P4/S31 software download requests survive a CPU reset. Clear only that
|
|
104
162
|
// latch, verify all other live fields, then apply the host reset policy.
|
|
105
|
-
if (
|
|
106
|
-
const address = 0x50110008, mask = 4
|
|
163
|
+
if (image.chipFamily === 0x3280 || image.chipFamily === 0x3231) {
|
|
164
|
+
const address = image.chipFamily === 0x3280 ? 0x50110008 : 0x20700008, mask = 4
|
|
107
165
|
const before = await this.stub.readRegister(address)
|
|
108
|
-
if (!Number.isInteger(before) || before < 0 || before > 0xffffffff) throw new Error('Invalid
|
|
166
|
+
if (!Number.isInteger(before) || before < 0 || before > 0xffffffff) throw new Error('Invalid download system control register')
|
|
109
167
|
if (before & mask) await this.stub.writeRegister(address, 0, mask)
|
|
110
168
|
const after = await this.stub.readRegister(address)
|
|
111
|
-
if (after !== ((before & ~mask) >>> 0)) throw new Error('
|
|
169
|
+
if (after !== ((before & ~mask) >>> 0)) throw new Error('Normal boot latch verification failed')
|
|
112
170
|
}
|
|
113
171
|
await this.reset()
|
|
114
172
|
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
import { romMd5 } from './rom_md5.js'
|
|
4
|
+
|
|
5
|
+
const SECTOR_BYTES = 4096
|
|
6
|
+
const invalid = message => Object.assign(new Error(`Invalid installation partition table: ${message}`), { code: 'FIRMWARE_PARTITION_INVALID' })
|
|
7
|
+
const allFF = bytes => bytes.every(value => value === 0xff)
|
|
8
|
+
|
|
9
|
+
// Call only after validateFirmwareImage has authenticated the complete image
|
|
10
|
+
// and every declared segment. Never infer an erase range from a host/UI field.
|
|
11
|
+
export function planInstallFilesystemErase(image, bytes) {
|
|
12
|
+
const table = image.partitionTable
|
|
13
|
+
if (table.offset % SECTOR_BYTES || table.bytes % 32 || table.bytes > 0xc00) throw invalid('unsupported table bounds')
|
|
14
|
+
const raw = bytes.subarray(table.offset, table.offset + table.bytes)
|
|
15
|
+
const view = new DataView(raw.buffer, raw.byteOffset, raw.byteLength)
|
|
16
|
+
const entries = [], labels = new Set()
|
|
17
|
+
let digestSeen = false, terminated = false
|
|
18
|
+
for (let cursor = 0; cursor < raw.length; cursor += 32) {
|
|
19
|
+
const entry = raw.subarray(cursor, cursor + 32)
|
|
20
|
+
if (allFF(entry)) {
|
|
21
|
+
if (!allFF(raw.subarray(cursor))) throw invalid('data after the end marker')
|
|
22
|
+
terminated = true
|
|
23
|
+
break
|
|
24
|
+
}
|
|
25
|
+
const magic = view.getUint16(cursor, true)
|
|
26
|
+
if (magic === 0xebeb && !digestSeen) {
|
|
27
|
+
if (!allFF(entry.subarray(2, 16))) throw invalid('malformed MD5 record')
|
|
28
|
+
const digest = romMd5(raw.subarray(0, cursor))
|
|
29
|
+
if (!digest.every((value, index) => value === entry[16 + index])) throw invalid('MD5 mismatch')
|
|
30
|
+
digestSeen = true
|
|
31
|
+
continue
|
|
32
|
+
}
|
|
33
|
+
if (magic !== 0x50aa || digestSeen) throw invalid('unexpected entry or data after MD5')
|
|
34
|
+
const offset = view.getUint32(cursor + 4, true), size = view.getUint32(cursor + 8, true)
|
|
35
|
+
const type = entry[2], subtype = entry[3], flags = view.getUint32(cursor + 28, true)
|
|
36
|
+
const labelBytes = entry.subarray(12, 28), end = labelBytes.indexOf(0)
|
|
37
|
+
const label = String.fromCharCode(...labelBytes.subarray(0, end < 0 ? 16 : end))
|
|
38
|
+
if (!label || labels.has(label) || (end >= 0 && labelBytes.subarray(end).some(value => value !== 0))) throw invalid('empty, duplicate or malformed label')
|
|
39
|
+
if (offset < table.offset + SECTOR_BYTES || offset % SECTOR_BYTES || !size
|
|
40
|
+
|| offset + size > image.flashSize || (type === 0 && offset % 65536)) throw invalid('unsupported partition bounds')
|
|
41
|
+
labels.add(label)
|
|
42
|
+
entries.push({ label, type, subtype, offset, size, flags })
|
|
43
|
+
}
|
|
44
|
+
if (!terminated || !entries.length) throw invalid('missing entries or end marker')
|
|
45
|
+
entries.sort((a, b) => a.offset - b.offset)
|
|
46
|
+
for (let index = 1; index < entries.length; index++) {
|
|
47
|
+
if (entries[index].offset < entries[index - 1].offset + entries[index - 1].size) throw invalid('overlapping partitions')
|
|
48
|
+
}
|
|
49
|
+
const app = image.application
|
|
50
|
+
if (!entries.some(entry => entry.type === 0 && entry.offset === app.offset
|
|
51
|
+
&& entry.size === app.partitionSize && app.bytes <= entry.size)) throw invalid('application does not match the approved layout')
|
|
52
|
+
const fat = entries.find(entry => entry.label === 'ffat')
|
|
53
|
+
if (!fat) throw Object.assign(new Error('Complete installation requires a declared internal ffat partition'), { code: 'FIRMWARE_FATFS_UNAVAILABLE' })
|
|
54
|
+
if (fat.type !== 1 || fat.subtype !== 0x81 || fat.flags !== 0 || fat.size % SECTOR_BYTES
|
|
55
|
+
|| fat.offset < Math.ceil(bytes.byteLength / SECTOR_BYTES) * SECTOR_BYTES) throw invalid('ffat is not an unencrypted, writable FAT partition outside the firmware write range')
|
|
56
|
+
return Object.freeze({ label: fat.label, offset: fat.offset, size: fat.size })
|
|
57
|
+
}
|
package/src/firmware_updates.js
CHANGED
|
@@ -5,6 +5,7 @@ import { loadOfficialFirmware } from './firmware_feed.js'
|
|
|
5
5
|
import { FirmwareFlashSession, validateFirmwareImage, firmwareMode } from './firmware_flash.js'
|
|
6
6
|
import { validateFirmwareRelease as release } from './firmware_release.js'
|
|
7
7
|
import { downloadBytes, MAX_FIRMWARE_BYTES } from './firmware_io.js'
|
|
8
|
+
import { planInstallFilesystemErase } from './firmware_partitions.js'
|
|
8
9
|
|
|
9
10
|
function record(value, keys, name) {
|
|
10
11
|
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new TypeError(`${name} must be an object`)
|
|
@@ -79,6 +80,7 @@ export async function updateFirmware(request, connectedIdentity, disconnect) {
|
|
|
79
80
|
bytes = bytes.slice(source.offset, source.offset + image.bytes)
|
|
80
81
|
}
|
|
81
82
|
await validateFirmwareImage(image, bytes, { mode })
|
|
83
|
+
if (mode === 'install') planInstallFilesystemErase(image, bytes)
|
|
82
84
|
abort(signal)
|
|
83
85
|
// Validate everything before releasing EV-MUX or acquiring a download port.
|
|
84
86
|
await disconnect()
|
|
@@ -87,10 +89,11 @@ export async function updateFirmware(request, connectedIdentity, disconnect) {
|
|
|
87
89
|
let session
|
|
88
90
|
try {
|
|
89
91
|
abort(signal)
|
|
90
|
-
session = new FirmwareFlashSession(loader, image, { mode, reset: reset ? () => reset(loader) : undefined })
|
|
92
|
+
session = new FirmwareFlashSession(loader, image, { mode, signal, reset: reset ? () => reset(loader) : undefined })
|
|
91
93
|
await session.connect(bytes)
|
|
92
94
|
abort(signal)
|
|
93
|
-
//
|
|
95
|
+
// Erase/erase-verification honor cancellation; once firmware writing starts,
|
|
96
|
+
// finish verification/reset as before. No uncertain operation is replayed.
|
|
94
97
|
const result = await session.flash(bytes, { onProgress, onPhase })
|
|
95
98
|
return { ...result, version: target.version, board: target.board, reconnected: false }
|
|
96
99
|
} finally {
|
package/src/node_rom.js
CHANGED
|
@@ -10,22 +10,22 @@ export function openNodeRom({ port, python = defaultPython, signal } = {}, depen
|
|
|
10
10
|
if (!port) throw new TypeError('Firmware update requires a local --port')
|
|
11
11
|
const lifecycle = new AbortController()
|
|
12
12
|
signal = signal ? AbortSignal.any([signal, lifecycle.signal]) : lifecycle.signal
|
|
13
|
-
let child, input, pending, sequence = 0, closed = false, initializing = false, romReady = false
|
|
14
|
-
const fail = code => Object.assign(new Error(code === 'ESPTOOL_UNAVAILABLE' ? 'Install Python esptool
|
|
13
|
+
let child, input, pending, sequence = 0, closed = false, initializing = false, romReady = false, stubReady = false
|
|
14
|
+
const fail = code => Object.assign(new Error(code === 'ESPTOOL_UNAVAILABLE' ? 'Install Python esptool==5.6.0 for the built-in flasher' : 'ROM operation failed'), { code })
|
|
15
15
|
const reject = code => { if (pending) { clearTimeout(pending.timer); pending.reject(fail(code)); pending = null } }
|
|
16
|
-
function rpc(method, args = []) {
|
|
16
|
+
function rpc(method, args = [], timeout = 180000) {
|
|
17
17
|
if (closed) return Promise.reject(fail('ROM_CLOSED'))
|
|
18
18
|
if (!child || (method !== 'initialize' && !romReady)) return Promise.reject(fail('ROM_NOT_INITIALIZED'))
|
|
19
19
|
if (pending) return Promise.reject(fail('ROM_BUSY'))
|
|
20
20
|
return new Promise((resolve, rejectPromise) => {
|
|
21
21
|
const id = ++sequence
|
|
22
|
-
pending = { id, resolve, reject: rejectPromise, timer: setTimeout(() => { reject('ROM_TIMEOUT'); void loader.disconnect() },
|
|
22
|
+
pending = { id, resolve, reject: rejectPromise, timer: setTimeout(() => { reject('ROM_TIMEOUT'); void loader.disconnect() }, timeout) }
|
|
23
23
|
child.stdin.write(JSON.stringify({ id, method, args }) + '\n', error => { if (error) reject('ROM_IO_FAILED') })
|
|
24
24
|
})
|
|
25
25
|
}
|
|
26
26
|
async function closeProcess() {
|
|
27
27
|
const process = child
|
|
28
|
-
child = null; romReady = false; reject('ROM_CLOSED'); input?.close(); input = null
|
|
28
|
+
child = null; romReady = stubReady = false; reject('ROM_CLOSED'); input?.close(); input = null
|
|
29
29
|
if (!process || process.exitCode !== null) return
|
|
30
30
|
await new Promise(resolve => {
|
|
31
31
|
const timer = setTimeout(() => { process.kill(); resolve() }, 2000)
|
|
@@ -97,8 +97,50 @@ export function openNodeRom({ port, python = defaultPython, signal } = {}, depen
|
|
|
97
97
|
},
|
|
98
98
|
flashId: () => rpc('flashId'),
|
|
99
99
|
getSecurityInfo: () => rpc('security'),
|
|
100
|
-
|
|
101
|
-
async
|
|
100
|
+
get IS_STUB() { return romReady && stubReady },
|
|
101
|
+
async runStub() {
|
|
102
|
+
stubReady = false
|
|
103
|
+
try {
|
|
104
|
+
const result = await rpc('stub')
|
|
105
|
+
if (result?.stubRunning !== true) throw fail('ROM_STUB_REQUIRED')
|
|
106
|
+
stubReady = true
|
|
107
|
+
return loader
|
|
108
|
+
} catch (error) { await loader.disconnect(); throw error }
|
|
109
|
+
},
|
|
110
|
+
async eraseRegion(address, size) {
|
|
111
|
+
if (!Number.isSafeInteger(address) || address < 0 || address % 4096 !== 0
|
|
112
|
+
|| !Number.isSafeInteger(size) || size <= 0 || size > 0xffffffff || size % 4096 !== 0
|
|
113
|
+
|| address + size > 0x100000000) throw new RangeError('ROM erase requires a positive, sector-aligned 32-bit address range')
|
|
114
|
+
if (closed) throw fail('ROM_CLOSED')
|
|
115
|
+
if (!romReady) throw fail('ROM_NOT_INITIALIZED')
|
|
116
|
+
if (!stubReady) throw fail('ROM_STUB_REQUIRED')
|
|
117
|
+
const cancelled = () => { reject('CANCELLED'); void loader.disconnect() }
|
|
118
|
+
signal.addEventListener('abort', cancelled, { once: true })
|
|
119
|
+
try {
|
|
120
|
+
signal.throwIfAborted()
|
|
121
|
+
// esptool 5.6.0 permits 40 seconds per decimal MB. Large FFAT regions
|
|
122
|
+
// must not inherit the shorter generic RPC timeout.
|
|
123
|
+
await rpc('eraseRegion', [address, size], Math.max(180000, Math.ceil(size / 1000000 * 40000) + 30000))
|
|
124
|
+
signal.throwIfAborted()
|
|
125
|
+
if (closed || !stubReady) throw fail('ROM_CLOSED')
|
|
126
|
+
} catch (error) { await loader.disconnect(); throw error }
|
|
127
|
+
finally { signal.removeEventListener('abort', cancelled) }
|
|
128
|
+
},
|
|
129
|
+
async readFlash(offset, bytes, progress, options) {
|
|
130
|
+
if (!Number.isSafeInteger(offset) || offset < 0 || !Number.isSafeInteger(bytes) || bytes <= 0 || bytes > 0xffffffff
|
|
131
|
+
|| offset + bytes > 0x100000000) throw new RangeError('ROM read requires a positive 32-bit address range')
|
|
132
|
+
if (progress !== undefined && typeof progress !== 'function') throw new TypeError('ROM read progress must be a function')
|
|
133
|
+
if (options !== undefined && (!options || typeof options !== 'object' || Array.isArray(options))) throw new TypeError('ROM read options must be an object')
|
|
134
|
+
if (options && Object.keys(options).some(key => !['blockSize', 'maxInFlight', 'chunkSize'].includes(key))) throw new TypeError('Unsupported ROM read option')
|
|
135
|
+
const blockSize = options?.blockSize === undefined ? 1024 : options.blockSize
|
|
136
|
+
const maxInFlight = options?.maxInFlight === undefined ? 1 : options.maxInFlight
|
|
137
|
+
if (![1024, 4096].includes(blockSize) || !Number.isSafeInteger(maxInFlight) || maxInFlight < 1 || maxInFlight > 64) throw new RangeError('ROM read requires 1024 or 4096 byte blocks and 1..64 outstanding packets')
|
|
138
|
+
if (options?.chunkSize !== undefined && (!Number.isSafeInteger(options.chunkSize) || options.chunkSize <= 0 || bytes > options.chunkSize)) throw new RangeError('ROM read exceeds the requested chunk size')
|
|
139
|
+
const data = new Uint8Array(Buffer.from(await rpc('read', [offset, bytes, { blockSize, maxInFlight }]), 'base64'))
|
|
140
|
+
if (data.length !== bytes) throw Object.assign(fail('ROM_READ_LENGTH'), { operation: 'read' })
|
|
141
|
+
progress?.(data, data.length, bytes)
|
|
142
|
+
return data
|
|
143
|
+
},
|
|
102
144
|
async flashData(bytes, progress, offset) { await rpc('write', [offset, Buffer.from(bytes).toString('base64')]); progress(bytes.byteLength, bytes.byteLength) },
|
|
103
145
|
readRegister: address => rpc('readRegister', [address]),
|
|
104
146
|
writeRegister: (address, value, mask) => rpc('writeRegister', [address, value, mask]),
|
package/src/node_rom.py
CHANGED
|
@@ -5,6 +5,7 @@ import base64
|
|
|
5
5
|
import contextlib
|
|
6
6
|
import json
|
|
7
7
|
import os
|
|
8
|
+
import struct
|
|
8
9
|
import sys
|
|
9
10
|
|
|
10
11
|
|
|
@@ -59,13 +60,87 @@ def _open_rom(esptool, port):
|
|
|
59
60
|
first_error = error
|
|
60
61
|
|
|
61
62
|
|
|
63
|
+
|
|
64
|
+
def _read_flash(esp, offset, length, options=None):
|
|
65
|
+
"""Bound the upstream stub reader without replacing its ACK/MD5 parser."""
|
|
66
|
+
if type(offset) is not int or offset < 0 or type(length) is not int or length <= 0 or length > 0xffffffff or offset + length > 0x100000000:
|
|
67
|
+
raise ValueError('ROM read requires a positive 32-bit address range')
|
|
68
|
+
if options is None:
|
|
69
|
+
options = {}
|
|
70
|
+
if not isinstance(options, dict) or set(options) - {'blockSize', 'maxInFlight'}:
|
|
71
|
+
raise ValueError('Unsupported ROM read options')
|
|
72
|
+
block_size, max_in_flight = options.get('blockSize', 1024), options.get('maxInFlight', 1)
|
|
73
|
+
if type(block_size) is not int or block_size not in (1024, 4096) or type(max_in_flight) is not int or not 1 <= max_in_flight <= 64:
|
|
74
|
+
raise ValueError('ROM read requires 1024 or 4096 byte blocks and 1..64 outstanding packets')
|
|
75
|
+
if not getattr(esp, 'IS_STUB', False):
|
|
76
|
+
raise RuntimeError('Bounded Flash read requires an initialized flasher stub')
|
|
77
|
+
if getattr(esp, 'FLASH_SECTOR_SIZE', None) != 4096 or not callable(getattr(esp, 'check_command', None)):
|
|
78
|
+
raise RuntimeError('Unsupported esptool stub read layout; use esptool==5.6.0')
|
|
79
|
+
command = esp.ESP_CMDS['READ_FLASH']
|
|
80
|
+
original_check = esp.check_command
|
|
81
|
+
missing = object()
|
|
82
|
+
old_check = vars(esp).get('check_command', missing)
|
|
83
|
+
old_block = vars(esp).get('FLASH_SECTOR_SIZE', missing)
|
|
84
|
+
commands = 0
|
|
85
|
+
block_changed = check_changed = False
|
|
86
|
+
|
|
87
|
+
def bounded_check(op_description, op=None, data=b'', *args, **kwargs):
|
|
88
|
+
nonlocal commands
|
|
89
|
+
if op == command:
|
|
90
|
+
commands += 1
|
|
91
|
+
if commands != 1 or not isinstance(data, bytes) or len(data) != 16:
|
|
92
|
+
raise RuntimeError('Unsupported esptool READ_FLASH command layout')
|
|
93
|
+
wire_offset, wire_length, wire_block, wire_window = struct.unpack('<IIII', data)
|
|
94
|
+
if (wire_offset, wire_length, wire_block, wire_window) != (offset, length, block_size, 64):
|
|
95
|
+
raise RuntimeError('Unexpected esptool READ_FLASH range or packet layout')
|
|
96
|
+
data = struct.pack('<IIII', offset, length, block_size, max_in_flight)
|
|
97
|
+
return original_check(op_description, op, data, *args, **kwargs)
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
# Upstream 5.6.0 uses this instance field for both its request and its
|
|
101
|
+
# short-packet validation. Changing only the wire request is invalid.
|
|
102
|
+
esp.FLASH_SECTOR_SIZE = block_size
|
|
103
|
+
block_changed = True
|
|
104
|
+
esp.check_command = bounded_check
|
|
105
|
+
check_changed = True
|
|
106
|
+
result = esp.read_flash(offset, length)
|
|
107
|
+
if commands != 1 or not isinstance(result, bytes) or len(result) != length:
|
|
108
|
+
raise RuntimeError('Upstream Flash read did not complete the bounded request')
|
|
109
|
+
return result
|
|
110
|
+
finally:
|
|
111
|
+
try:
|
|
112
|
+
if check_changed:
|
|
113
|
+
if old_check is missing:
|
|
114
|
+
del esp.check_command
|
|
115
|
+
else:
|
|
116
|
+
esp.check_command = old_check
|
|
117
|
+
finally:
|
|
118
|
+
if block_changed:
|
|
119
|
+
if old_block is missing:
|
|
120
|
+
del esp.FLASH_SECTOR_SIZE
|
|
121
|
+
else:
|
|
122
|
+
esp.FLASH_SECTOR_SIZE = old_block
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _erase_region(esptool, esp, address, size):
|
|
126
|
+
"""Erase only the qualified range through the external official command."""
|
|
127
|
+
if type(address) is not int or address < 0 or address % 4096 or type(size) is not int or size <= 0 or size > 0xffffffff or size % 4096 or address + size > 0x100000000:
|
|
128
|
+
raise ValueError('ROM erase requires a positive, sector-aligned 32-bit address range')
|
|
129
|
+
if not getattr(esp, 'IS_STUB', False):
|
|
130
|
+
raise RuntimeError('Flash region erase requires an initialized flasher stub')
|
|
131
|
+
if getattr(esp, 'FLASH_SECTOR_SIZE', None) != 4096 or not callable(getattr(esp, 'erase_region', None)):
|
|
132
|
+
raise RuntimeError('Unsupported esptool stub region erase; use esptool==5.6.0')
|
|
133
|
+
# Do not use force: the official command also checks active security state.
|
|
134
|
+
esptool.erase_region(esp, address, size, force=False)
|
|
135
|
+
|
|
136
|
+
|
|
62
137
|
def main():
|
|
63
138
|
with contextlib.redirect_stdout(sys.stderr):
|
|
64
139
|
import esptool
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
raise RuntimeError('Install esptool>=5.3,<6')
|
|
140
|
+
if esptool.__version__ != '5.6.0':
|
|
141
|
+
raise RuntimeError('Install esptool==5.6.0 for the verified bounded Flash reader')
|
|
68
142
|
esp = None
|
|
143
|
+
erase_failed = False
|
|
69
144
|
families = {'ESP32-P4': 0x3280, 'ESP32-S3': 0x3253, 'ESP32-S31': 0x3231}
|
|
70
145
|
try:
|
|
71
146
|
for line in sys.stdin:
|
|
@@ -74,6 +149,8 @@ def main():
|
|
|
74
149
|
with contextlib.redirect_stdout(sys.stderr):
|
|
75
150
|
method, args = request['method'], request.get('args', [])
|
|
76
151
|
result = None
|
|
152
|
+
if erase_failed:
|
|
153
|
+
raise RuntimeError('Flash region erase failed; start a fresh ROM session')
|
|
77
154
|
if method != 'initialize' and esp is None:
|
|
78
155
|
raise RuntimeError('Initialize the ROM adapter before issuing operations')
|
|
79
156
|
if method == 'initialize':
|
|
@@ -96,12 +173,17 @@ def main():
|
|
|
96
173
|
result = {'flags': security['flags'], 'flashCryptCnt': security['flash_crypt_cnt']}
|
|
97
174
|
elif method == 'stub':
|
|
98
175
|
esp = esptool.run_stub(esp)
|
|
176
|
+
if not esp.IS_STUB:
|
|
177
|
+
raise RuntimeError('A validated flasher stub is required')
|
|
99
178
|
esptool.attach_flash(esp)
|
|
100
179
|
esp.change_baud(460800)
|
|
180
|
+
result = {'stubRunning': True}
|
|
101
181
|
elif method == 'flashId':
|
|
102
182
|
result = esp.flash_id()
|
|
103
183
|
elif method == 'read':
|
|
104
|
-
result = base64.b64encode(esp
|
|
184
|
+
result = base64.b64encode(_read_flash(esp, *args)).decode('ascii')
|
|
185
|
+
elif method == 'eraseRegion':
|
|
186
|
+
_erase_region(esptool, esp, *args)
|
|
105
187
|
elif method == 'write':
|
|
106
188
|
offset, encoded = args
|
|
107
189
|
data = base64.b64decode(encoded, validate=True)
|
|
@@ -116,9 +198,14 @@ def main():
|
|
|
116
198
|
raise ValueError('Unknown ROM adapter method')
|
|
117
199
|
print(json.dumps({'id': request['id'], 'result': result}), flush=True)
|
|
118
200
|
except Exception as error:
|
|
201
|
+
if request['method'] == 'eraseRegion':
|
|
202
|
+
erase_failed = True
|
|
203
|
+
with contextlib.suppress(Exception):
|
|
204
|
+
if esp is not None and esp._port.is_open:
|
|
205
|
+
esp._port.close()
|
|
119
206
|
print(json.dumps({'id': request['id'], 'error': 'ROM_OPERATION_FAILED', 'method': request['method'], 'detail': str(error)}), flush=True)
|
|
120
207
|
finally:
|
|
121
|
-
if esp is not None:
|
|
208
|
+
if esp is not None and esp._port.is_open:
|
|
122
209
|
esp._port.close()
|
|
123
210
|
|
|
124
211
|
|
package/src/node_rom_port.js
CHANGED
|
@@ -179,33 +179,41 @@ export async function recoverNodeRomPort(previous, { python = defaultPython, sig
|
|
|
179
179
|
if (mac(previous.transition.serial) !== expectedMac) return null
|
|
180
180
|
const enumerate = dependencies.listSerial ?? listSerial
|
|
181
181
|
const now = dependencies.now ?? (() => performance.now())
|
|
182
|
+
const sleep = dependencies.sleep ?? ((ms, abortSignal) => delay(ms, undefined, { signal: abortSignal }))
|
|
182
183
|
const deadline = now() + timeout
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
184
|
+
const sameDevice = item => mac(item.serialNumber) === expectedMac && physicalLocation(item.location) === location
|
|
185
|
+
let runtime
|
|
186
|
+
// A closed native stub may still enumerate as ROM, then disappear before
|
|
187
|
+
// the same device's runtime reappears. Only observe during this window;
|
|
188
|
+
// never re-SYNC the stale ROM or issue a second handoff while it settles.
|
|
189
|
+
while (now() < deadline) {
|
|
190
|
+
checkAbort(signal)
|
|
191
|
+
let ports
|
|
192
|
+
try { ports = await enumerate({ python, signal, timeout: Math.max(1, Math.ceil(deadline - now())) }) }
|
|
193
|
+
catch (error) {
|
|
194
|
+
checkAbort(signal)
|
|
195
|
+
throw fail('ROM_PORT_ENUMERATION_FAILED', 'Cannot verify the selected device for read-only initialization recovery', error)
|
|
196
|
+
}
|
|
186
197
|
checkAbort(signal)
|
|
187
|
-
throw fail('
|
|
198
|
+
if (now() >= deadline) throw fail('ROM_PORT_REBIND_TIMEOUT', 'Read-only initialization recovery enumeration exceeded its deadline')
|
|
199
|
+
if (!Array.isArray(ports)) throw fail('ROM_PORT_ENUMERATION_FAILED', 'Serial enumeration returned invalid descriptors')
|
|
200
|
+
const candidates = ports.filter(item => nativeRom(item) || nativeRuntime(item))
|
|
201
|
+
const conflicting = candidates.filter(item => (mac(item.serialNumber) === expectedMac || physicalLocation(item.location) === location) && !sameDevice(item))
|
|
202
|
+
if (conflicting.length) throw fail('ROM_RUNTIME_IDENTITY_MISMATCH', 'Runtime descriptors do not match the previously selected ROM device')
|
|
203
|
+
const matches = candidates.filter(sameDevice)
|
|
204
|
+
if (matches.length > 1) throw fail('ROM_PORT_AMBIGUOUS', 'Multiple native USB ports match the selected device')
|
|
205
|
+
if (matches.length && nativeRuntime(matches[0])) { runtime = matches[0]; break }
|
|
206
|
+
await sleep(Math.min(50, Math.max(1, deadline - now())), signal)
|
|
188
207
|
}
|
|
189
|
-
|
|
190
|
-
if (
|
|
191
|
-
if (!Array.isArray(ports)) throw fail('ROM_PORT_ENUMERATION_FAILED', 'Serial enumeration returned invalid descriptors')
|
|
192
|
-
const sameDevice = item => mac(item.serialNumber) === expectedMac && physicalLocation(item.location) === location
|
|
193
|
-
const existingRom = ports.filter(item => nativeRom(item) && sameDevice(item))
|
|
194
|
-
if (existingRom.length > 1) throw fail('ROM_PORT_AMBIGUOUS', 'Multiple ROM ports match the selected device')
|
|
195
|
-
if (existingRom.length) return null
|
|
196
|
-
const runtimes = ports.filter(nativeRuntime)
|
|
197
|
-
const conflicting = runtimes.filter(item => (mac(item.serialNumber) === expectedMac || physicalLocation(item.location) === location) && !sameDevice(item))
|
|
198
|
-
if (conflicting.length) throw fail('ROM_RUNTIME_IDENTITY_MISMATCH', 'Runtime descriptors do not match the previously selected ROM device')
|
|
199
|
-
const matches = runtimes.filter(sameDevice)
|
|
200
|
-
if (matches.length > 1) throw fail('ROM_PORT_AMBIGUOUS', 'Multiple runtime ports match the selected device')
|
|
201
|
-
if (!matches.length) return null
|
|
208
|
+
// Returning null preserves the caller's original initialize failure.
|
|
209
|
+
if (!runtime) return null
|
|
202
210
|
// Revalidate at prepare's fresh enumeration, before opening EV-MUX. An OS port
|
|
203
211
|
// may have been reused for a different board since the recovery snapshot.
|
|
204
|
-
const result = await prepareNodeRomPort({ port:
|
|
212
|
+
const result = await prepareNodeRomPort({ port: runtime.path, python, signal, timeout,
|
|
205
213
|
expectedIdentity: { mac: expectedMac, location } }, dependencies)
|
|
206
214
|
if (result.expectedMac !== expectedMac || result.transition?.observed !== 'rom' || physicalLocation(result.transition.location) !== location) {
|
|
207
215
|
throw fail('ROM_RUNTIME_IDENTITY_MISMATCH', 'Recovery did not preserve the selected physical device identity')
|
|
208
216
|
}
|
|
209
|
-
result.transition.recovery = { previousPort: previous.port, runtimePid:
|
|
217
|
+
result.transition.recovery = { previousPort: previous.port, runtimePid: runtime.productId }
|
|
210
218
|
return result
|
|
211
219
|
}
|