@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.
Files changed (39) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +8 -5
  3. package/package.json +1 -1
  4. package/schemas/device-actions-v1.json +1 -1
  5. package/scripts/generate-action-schema.mjs +2 -1
  6. package/scripts/npm-command.mjs +17 -0
  7. package/scripts/release-npm.mjs +2 -1
  8. package/src/browser.js +5 -2
  9. package/src/browser_rom.js +55 -5
  10. package/src/device_session.js +85 -9
  11. package/src/evmux.js +15 -0
  12. package/src/firmware_flash.js +84 -26
  13. package/src/firmware_partitions.js +57 -0
  14. package/src/firmware_updates.js +5 -2
  15. package/src/node_rom.js +49 -7
  16. package/src/node_rom.py +92 -5
  17. package/src/node_rom_port.js +27 -19
  18. package/src/node_serial.py +75 -12
  19. package/src/node_transport.js +63 -14
  20. package/src/rom_identity.js +9 -0
  21. package/tests/check-browser.mjs +23 -4
  22. package/tests/check-package.mjs +15 -7
  23. package/tests/device-checks.mjs +89 -7
  24. package/tests/firmware-fixtures.mjs +60 -0
  25. package/tests/fixtures/device-checks.py +357 -1
  26. package/tests/hardware-check.mjs +3 -2
  27. package/tests/test-browser-rom.mjs +169 -3
  28. package/tests/test-ci-check-selection.mjs +31 -0
  29. package/tests/test-device-functional-checks.mjs +275 -3
  30. package/tests/test-device-v4-alignment.mjs +261 -7
  31. package/tests/test-firmware-flash.mjs +22 -20
  32. package/tests/test-firmware-install.mjs +8 -35
  33. package/tests/test-firmware-source.mjs +10 -7
  34. package/tests/test-host-checks.mjs +47 -0
  35. package/tests/test-install-erase.mjs +153 -0
  36. package/tests/test-node-rom-port.mjs +81 -1
  37. package/tests/test-node-rom.mjs +159 -4
  38. package/tests/test-node-serial.mjs +231 -2
  39. package/tests/test-npm-release-policy.mjs +3 -9
@@ -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
- constructor(loader, image, { reset = () => loader.hardReset(), mode = 'update' } = {}) {
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
- await validateFirmwareImage(this.image, bytes, { mode: this.mode })
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
- const revisionMatches = this.image.chipRevision !== undefined ? this.loader.chipRevision === this.image.chipRevision
54
- : Number.isInteger(this.image.chipRevisionMin) && Number.isInteger(this.image.chipRevisionMax) && this.loader.chipRevision >= this.image.chipRevisionMin && this.loader.chipRevision <= this.image.chipRevisionMax
55
- if (this.loader.chipFamily !== this.image.chipFamily || !revisionMatches) {
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
- if (!Number.isSafeInteger(security.flags) || !Number.isSafeInteger(security.flashCryptCnt)) {
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 & 1) !== 0 || encrypted) throw new Error('Secure boot or encrypted devices require their signed flashing workflow')
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
- if (this.mode === 'install') {
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 < this.image.flashSize) throw new Error('Detected flash is unknown or smaller than the approved complete image layout')
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 = this.image.partitionTable
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
- 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')
87
- await this.stub.flashData(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength),
88
- (written, total) => onProgress(total ? Math.min(80, Math.round(written / total * 80)) : 0), this.image.offset, true)
89
- onPhase(this.mode === 'install' ? 'Verifying the complete installation image' : 'Verifying the complete application image')
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(bytes.byteLength)
92
- for (let offset = 0; offset < bytes.byteLength; offset += 65536) {
93
- const count = Math.min(65536, bytes.byteLength - offset)
94
- const chunk = await this.stub.readFlash(this.image.offset + offset, count, undefined,
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) / bytes.byteLength))
155
+ onProgress(80 + Math.min(19, Math.floor(20 * (offset + count) / payload.byteLength)))
99
156
  }
100
- if (readback.byteLength !== bytes.byteLength || await firmwareSha256(readback) !== this.image.sha256) throw new Error('Written firmware verification failed; keep the device in download mode for recovery')
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 (this.loader.chipFamily === 0x3280) {
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 P4 system control register')
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('P4 normal boot latch verification failed')
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
+ }
@@ -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
- // No interruption after writing starts: finish verification and reset policy.
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>=5.3,<6 for the built-in flasher' : 'ROM operation failed'), { code })
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() }, 180000) }
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
- async runStub() { await rpc('stub'); return loader },
101
- async readFlash(offset, bytes) { return new Uint8Array(Buffer.from(await rpc('read', [offset, bytes]), 'base64')) },
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
- major, minor = map(int, esptool.__version__.split('.')[:2])
66
- if major != 5 or minor < 3:
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.read_flash(*args)).decode('ascii')
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
 
@@ -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
- let ports
184
- try { ports = await enumerate({ python, signal, timeout }) }
185
- catch (error) {
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('ROM_PORT_ENUMERATION_FAILED', 'Cannot verify the selected device for read-only initialization recovery', error)
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
- checkAbort(signal)
190
- if (now() >= deadline) throw fail('ROM_PORT_REBIND_TIMEOUT', 'Read-only initialization recovery enumeration exceeded its deadline')
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: matches[0].path, python, signal, timeout,
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: matches[0].productId }
217
+ result.transition.recovery = { previousPort: previous.port, runtimePid: runtime.productId }
210
218
  return result
211
219
  }