@esp-vision/device-core 0.6.1 → 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.
@@ -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
13
+ let child, input, pending, sequence = 0, closed = false, initializing = false, romReady = false, stubReady = false
14
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,7 +97,35 @@ 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 },
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
+ },
101
129
  async readFlash(offset, bytes, progress, options) {
102
130
  if (!Number.isSafeInteger(offset) || offset < 0 || !Number.isSafeInteger(bytes) || bytes <= 0 || bytes > 0xffffffff
103
131
  || offset + bytes > 0x100000000) throw new RangeError('ROM read requires a positive 32-bit address range')
package/src/node_rom.py CHANGED
@@ -122,12 +122,25 @@ def _read_flash(esp, offset, length, options=None):
122
122
  esp.FLASH_SECTOR_SIZE = old_block
123
123
 
124
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
+
125
137
  def main():
126
138
  with contextlib.redirect_stdout(sys.stderr):
127
139
  import esptool
128
140
  if esptool.__version__ != '5.6.0':
129
141
  raise RuntimeError('Install esptool==5.6.0 for the verified bounded Flash reader')
130
142
  esp = None
143
+ erase_failed = False
131
144
  families = {'ESP32-P4': 0x3280, 'ESP32-S3': 0x3253, 'ESP32-S31': 0x3231}
132
145
  try:
133
146
  for line in sys.stdin:
@@ -136,6 +149,8 @@ def main():
136
149
  with contextlib.redirect_stdout(sys.stderr):
137
150
  method, args = request['method'], request.get('args', [])
138
151
  result = None
152
+ if erase_failed:
153
+ raise RuntimeError('Flash region erase failed; start a fresh ROM session')
139
154
  if method != 'initialize' and esp is None:
140
155
  raise RuntimeError('Initialize the ROM adapter before issuing operations')
141
156
  if method == 'initialize':
@@ -158,12 +173,17 @@ def main():
158
173
  result = {'flags': security['flags'], 'flashCryptCnt': security['flash_crypt_cnt']}
159
174
  elif method == 'stub':
160
175
  esp = esptool.run_stub(esp)
176
+ if not esp.IS_STUB:
177
+ raise RuntimeError('A validated flasher stub is required')
161
178
  esptool.attach_flash(esp)
162
179
  esp.change_baud(460800)
180
+ result = {'stubRunning': True}
163
181
  elif method == 'flashId':
164
182
  result = esp.flash_id()
165
183
  elif method == 'read':
166
184
  result = base64.b64encode(_read_flash(esp, *args)).decode('ascii')
185
+ elif method == 'eraseRegion':
186
+ _erase_region(esptool, esp, *args)
167
187
  elif method == 'write':
168
188
  offset, encoded = args
169
189
  data = base64.b64decode(encoded, validate=True)
@@ -178,9 +198,14 @@ def main():
178
198
  raise ValueError('Unknown ROM adapter method')
179
199
  print(json.dumps({'id': request['id'], 'result': result}), flush=True)
180
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()
181
206
  print(json.dumps({'id': request['id'], 'error': 'ROM_OPERATION_FAILED', 'method': request['method'], 'detail': str(error)}), flush=True)
182
207
  finally:
183
- if esp is not None:
208
+ if esp is not None and esp._port.is_open:
184
209
  esp._port.close()
185
210
 
186
211
 
@@ -0,0 +1,9 @@
1
+ // SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ // This option is validated before opening a port or loading a ROM backend.
5
+ export function normalizeExpectedMac(value) {
6
+ if (value === undefined) return undefined
7
+ if (typeof value !== 'string' || value.length !== 12 || !/^[0-9a-f]{12}$/i.test(value)) throw new TypeError('expectedMac must contain exactly 12 hexadecimal characters')
8
+ return value.toLowerCase()
9
+ }
@@ -50,12 +50,13 @@ try {
50
50
  async setSignals() { throw new Error('OTG must not require UART control signals') },
51
51
  })
52
52
  try {
53
- prototype.detectChip = async function () { detects++; await this.transport.connect(115200); this.chip = { CHIP_NAME: 'ESP32-S31', getChipRevision: async () => 0 } }
53
+ prototype.detectChip = async function () { detects++; await this.transport.connect(115200); this.chip = { CHIP_NAME: 'ESP32-S31', getChipRevision: async () => 0, readMac: async () => '30:ed:a0:ed:a1:b0' } }
54
54
  prototype.getSecurityInfo = async () => ({ flags: 0, flashCryptCnt: 0 })
55
- const controller = new AbortController(), loader = context.DeviceCore.openBrowserRom({ port: fakePort(), signal: controller.signal })
55
+ const controller = new AbortController(), loader = context.DeviceCore.openBrowserRom({ port: fakePort(), signal: controller.signal, expectedMac: '30EDA0EDA1B0' })
56
+ assert.equal(loader.chipMac, null)
56
57
  const first = loader.initialize()
57
58
  assert.equal(loader.initialize(), first)
58
- await first; controller.abort()
59
+ await first; assert.equal(loader.chipMac, '30eda0eda1b0'); controller.abort()
59
60
  assert.equal(closes, 0, 'abort after initialization preserves ongoing flash')
60
61
  await loader.disconnect(); await loader.disconnect()
61
62
  assert.equal(closes, 1); assert.equal(detects, 1)
@@ -73,6 +74,18 @@ try {
73
74
  cancelled.abort(); await assert.rejects(starting, { name: 'AbortError' })
74
75
  finishOpen(); await new Promise(resolve => setTimeout(resolve, 5))
75
76
  assert.equal(closes, 3); assert.equal(waitingPort.readable, null)
77
+ prototype.runStub = async function () { this.IS_STUB = true }
78
+ let finishErase
79
+ prototype.checkCommand = () => new Promise(resolve => { finishErase = resolve })
80
+ const eraseCancel = new AbortController(), erasePort = fakePort()
81
+ const eraseLoader = context.DeviceCore.openBrowserRom({ port: erasePort, signal: eraseCancel.signal })
82
+ await eraseLoader.initialize(); const eraseStub = await eraseLoader.runStub()
83
+ const erasing = eraseStub.eraseRegion(0x710000, 4096)
84
+ const failedErase = assert.rejects(erasing, { name: 'AbortError' })
85
+ eraseCancel.abort(); await failedErase
86
+ finishErase(0)
87
+ assert.equal(closes, 4); assert.equal(erasePort.readable, null)
88
+ await assert.rejects(eraseStub.eraseRegion(0x710000, 4096))
76
89
  const data = new Uint8Array([0xe9, 1, 2, 3]), table = new Uint8Array([4, 5])
77
90
  const hash = async bytes => Array.from(new Uint8Array(await webcrypto.subtle.digest('SHA-256', bytes)), byte => byte.toString(16).padStart(2, '0')).join('')
78
91
  const release = { board: 'BOARD', version: 'browser-test', image: {
@@ -102,10 +115,16 @@ try {
102
115
  this.transport.buffer = new Uint8Array([...frame(bytes), ...frame(digest)])
103
116
  return 0
104
117
  }
118
+ const wrongPort = fakePort(), wrong = context.DeviceCore.openBrowserRom({ port: wrongPort, expectedMac: '30eda0eda1b1' })
119
+ const beforeStubs = stubs, beforeWrites = writes
120
+ await assert.rejects(wrong.initialize(), { code: 'ROM_IDENTITY_CHANGED' })
121
+ assert.equal(wrong.chipMac, null); assert.equal(wrongPort.readable, null)
122
+ await assert.rejects(async () => wrong.runStub(), { code: 'ROM_CLOSED' })
123
+ assert.equal(stubs, beforeStubs); assert.equal(writes, beforeWrites)
105
124
  const updatePort = fakePort(), openUpdate = updatePort.open
106
125
  updatePort.open = async function (...args) { await openUpdate.apply(this, args); this.writable = { getWriter: () => ({ async write() {}, releaseLock() {} }) } }
107
126
  const beforeCloses = closes
108
- const updated = await device.firmware.update({ ...request, signal: writing.signal, openLoader: ({ signal }) => context.DeviceCore.openBrowserRom({ port: updatePort, signal }) })
127
+ const updated = await device.firmware.update({ ...request, signal: writing.signal, openLoader: ({ signal }) => context.DeviceCore.openBrowserRom({ port: updatePort, signal, expectedMac: '30eda0eda1b0' }) })
109
128
  assert.equal(updated.verified, true); assert.equal(updated.needsReset, false)
110
129
  assert.equal(stubs, 1); assert.equal(writes, 1); assert.equal(resets, 4)
111
130
  assert.equal(closes, beforeCloses + 1)
@@ -45,6 +45,13 @@ try {
45
45
  const { defaultPython, filterSerialPorts, inventory, listSerial, serialEntry, tcpTransport, serialTransport, transportFor, openNodeRom } = await import('@esp-vision/device-core/node');
46
46
  const { openBrowserRom } = await import('@esp-vision/device-core/browser');
47
47
  if (typeof openBrowserRom !== 'function' || typeof openBrowserRom({ port: { open() { throw new Error('Not opened'); }, close() {} } }).initialize !== 'function') throw new Error('Missing passive browser ROM adapter');
48
+ const romPort = { open() { throw new Error('Not opened'); }, close() {} };
49
+ const browserRom = openBrowserRom({ port: romPort, expectedMac: '30EDA0EDA1B0' });
50
+ if (!Object.hasOwn(browserRom, 'chipMac') || browserRom.chipMac !== null) throw new Error('Missing passive browser ROM MAC contract');
51
+ let rejectedExpectedMac = false;
52
+ try { openBrowserRom({ port: romPort, expectedMac: '30:ed:a0:ed:a1:b0' }); } catch (error) { rejectedExpectedMac = error instanceof TypeError; }
53
+ if (!rejectedExpectedMac) throw new Error('Installed browser ROM must validate expectedMac before opening');
54
+ await browserRom.disconnect();
48
55
  if (typeof tcpTransport !== 'function' || typeof serialTransport !== 'function') throw new Error('Missing public Node transports');
49
56
  if (typeof inventory !== 'function' || typeof listSerial !== 'function' || typeof filterSerialPorts !== 'function'
50
57
  || typeof serialEntry !== 'function' || typeof transportFor !== 'function' || typeof defaultPython !== 'string') throw new Error('Missing public Node inventory APIs');
@@ -63,7 +70,7 @@ try {
63
70
  api.createDevice({ transport: serialTransport({ python: 'python3', serial: 'not-opened' }) });
64
71
  if (typeof DeviceSession !== 'function' || typeof FirmwareFlashSession !== 'function') throw new Error('Missing host adapter APIs');`
65
72
  execFileSync(process.execPath, ['--input-type=module', '-e', script], { cwd: consumer, stdio: 'pipe' })
66
- for (const required of ['src/node_serial.py', 'src/jpeg_validate.js', 'scripts/run-tests.mjs', 'tests/hardware-check.mjs', 'tests/device-checks.mjs', 'tests/jpeg-fixtures.mjs', 'tests/test-jpeg-validation.mjs', 'tests/fixtures/device-checks.py']) {
73
+ for (const required of ['src/node_serial.py', 'src/jpeg_validate.js', 'src/rom_identity.js', 'scripts/run-tests.mjs', 'tests/hardware-check.mjs', 'tests/device-checks.mjs', 'tests/jpeg-fixtures.mjs', 'tests/test-jpeg-validation.mjs', 'tests/fixtures/device-checks.py']) {
67
74
  assert.ok(packed.files.some(({ path }) => path === required), `Missing installed check asset: ${required}`)
68
75
  }
69
76
  const unitOutput = execFileSync(...npmCommand(['test']), { cwd: installed, encoding: 'utf8', maxBuffer: 4 * 1024 * 1024 })
@@ -2,10 +2,15 @@
2
2
  // Application-level HIL checks. All device I/O goes through createDevice's API.
3
3
  import { readFile, mkdir, writeFile } from 'node:fs/promises'
4
4
  import { dirname } from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+ import { createHash } from 'node:crypto'
5
7
  import { EV_REPL_INPUT_MAX_BYTES, evReplExecInputBytes } from '../src/evrepl.js'
6
8
  import { validateJpegImage } from '../src/jpeg_validate.js'
7
9
 
8
- export const DEVICE_CHECKS = Object.freeze(['identity', 'memory', 'camera', 'framesizes', 'pixformats', 'orientation', 'ipa_stats', 'snapshot_rate', 'preview', 'image_ops', 'display', 'display_stream', 'camera_display_lifecycle', 'image_copy_to_fb', 'image_transform_argument', 'sensor_snapshot_buffer', 'memory_policy'])
10
+ export const DEVICE_CHECKS = Object.freeze(['identity', 'memory', 'camera', 'framesizes', 'pixformats', 'orientation', 'ipa_stats', 'snapshot_rate', 'preview', 'image_ops', 'display', 'display_stream', 'camera_display_lifecycle', 'image_copy_to_fb', 'image_transform_argument', 'sensor_snapshot_buffer', 'memory_policy', 'espdl_inference', 'isp_ccm_frames', 'isp_lsc_display'])
11
+ // The npm package ships tests without models; repository checkouts use the bundled face detector.
12
+ export const DEFAULT_ESPDL_SHA256 = '04eec2220adbf642a843c953817fb219a06c094aedb76c181ba2a958c65201dc'
13
+ export const DEFAULT_ESPDL_MODEL = fileURLToPath(new URL('../../../models/espdet/pico/face/espdet_pico_224_224_face.espdl', import.meta.url))
9
14
  const MARKER = '__EVDEVICECHECK__'
10
15
  const failure = (code, message) => Object.assign(new Error(message), { code })
11
16
  const unsupported = error => error?.code === 'UNSUPPORTED_OPERATION' || /ESP_ERR_NOT_SUPPORTED|ESP_ERR_NOT_FOUND|no module named|ENODEV/i.test(error?.message ?? '')
@@ -44,7 +49,7 @@ export async function runDeviceChecks(device, options, { signal, nonce, report =
44
49
  const results = [], namespace = `_evhil_${nonce}`
45
50
  let loaded = false, namespaceStarted = false, loadError = null, inputLimit = EV_REPL_INPUT_MAX_BYTES
46
51
  const parameters = { frames: options.frames, framesize: options.framesize, pixformat: options.pixformat, displayHold: options.displayHold, screenCapture: Boolean(options.screenOutput) }
47
- const timeoutFor = name => 1000 * (name === 'display' ? 30 + options.displayHold * 3 : name === 'camera_display_lifecycle' ? options.frameTimeout + 80 : name === 'memory_policy' ? options.frameTimeout + 60 : name === 'sensor_snapshot_buffer' ? options.frameTimeout + 40 : name === 'image_copy_to_fb' ? 30 : name === 'ipa_stats' ? options.frameTimeout + 40 : options.frameTimeout + Math.max(30, options.frames * (name === 'display_stream' ? 2 : 1)))
52
+ const timeoutFor = name => 1000 * (name === 'display' ? 30 + options.displayHold * 3 : name === 'camera_display_lifecycle' ? options.frameTimeout + 80 : name === 'memory_policy' ? options.frameTimeout + 60 : name === 'sensor_snapshot_buffer' ? options.frameTimeout + 40 : name === 'image_copy_to_fb' ? 30 : name === 'espdl_inference' ? options.frameTimeout + 60 + options.frames * 2 : name === 'ipa_stats' ? options.frameTimeout + 40 : options.frameTimeout + Math.max(30, options.frames * (name === 'display_stream' ? 2 : 1)))
48
53
  const execute = async (source, timeout = 10000, operationSignal = signal) => {
49
54
  // Larger python.execute() requests use temporary files in Device Core.
50
55
  // Keep fixtures in RAM by respecting the actual encoded REPL-line budget.
@@ -53,6 +58,34 @@ export async function runDeviceChecks(device, options, { signal, nonce, report =
53
58
  if (result.ok === false || result.outputTruncated) throw failure('PYTHON_CHECK', 'Device Python check did not complete')
54
59
  return result.output
55
60
  }
61
+ const acknowledged = (output, operation, expected) => {
62
+ let reply
63
+ try {
64
+ const line = output.trim()
65
+ if (!line.startsWith('__EVHILACK__')) throw new Error()
66
+ reply = JSON.parse(line.slice('__EVHILACK__'.length))
67
+ } catch { throw failure('MODEL_ACK_INVALID', 'Missing or malformed hardware fixture acknowledgement') }
68
+ // Python dictionaries need not preserve the host's insertion order.
69
+ if (!Array.isArray(reply) || reply.length !== 3 || reply[0] !== nonce || reply[1] !== operation || !reply[2] || typeof reply[2] !== 'object' || Array.isArray(reply[2]) ||
70
+ Object.keys(reply[2]).length !== Object.keys(expected).length || Object.entries(expected).some(([key, value]) => reply[2][key] !== value)) {
71
+ throw failure('MODEL_ACK_INVALID', 'Hardware fixture acknowledgement does not match the operation')
72
+ }
73
+ }
74
+ const modelSource = (operation, args) => `${namespace}['model_command'](${JSON.stringify(operation)}${args.length ? ',' + args.map(value => JSON.stringify(value)).join(',') : ''})`
75
+ const modelCall = async (operation, args, expected, { retry = false, timeout = 10000, operationSignal = signal } = {}) => {
76
+ for (let attempt = 1; ; attempt++) {
77
+ cancelled(operationSignal)
78
+ try {
79
+ // Re-enter the public API for a fresh source variable and its existing
80
+ // Ctrl-C + exec('1') synchronization. Never replay an SDK += line.
81
+ acknowledged(await execute(modelSource(operation, args), timeout, operationSignal), operation, expected)
82
+ return attempt - 1
83
+ } catch (error) {
84
+ cancelled(operationSignal)
85
+ if (!retry || error.code !== 'TIMEOUT' || attempt === 3) throw error
86
+ }
87
+ }
88
+ }
56
89
  const load = async () => {
57
90
  if (loaded) return
58
91
  if (loadError) throw loadError
@@ -62,7 +95,7 @@ export async function runDeviceChecks(device, options, { signal, nonce, report =
62
95
  if (Number.isSafeInteger(advertised) && advertised > 0) inputLimit = Math.min(inputLimit, advertised)
63
96
  const source = await readFile(new URL('./fixtures/device-checks.py', import.meta.url), 'utf8')
64
97
  namespaceStarted = true
65
- await execute(`${namespace}={'_source':[]}`)
98
+ await execute(`${namespace}={'_source':[],'_fixture_nonce':${JSON.stringify(nonce)}}`)
66
99
  const append = chunk => `${namespace}['_source'].append(${JSON.stringify(chunk)})`
67
100
  let chunk = ''
68
101
  for (const character of source) {
@@ -78,15 +111,58 @@ export async function runDeviceChecks(device, options, { signal, nonce, report =
78
111
  loaded = true
79
112
  } catch (error) { loadError = error; throw error }
80
113
  }
81
- const invoke = async name => {
114
+ const invoke = async (name, extra = {}) => {
82
115
  await load()
83
- const output = await execute(`${namespace}['run_check'](${JSON.stringify(name)},${JSON.stringify(JSON.stringify(parameters))})`, timeoutFor(name))
116
+ const selected = name === 'espdl_inference' ? { frames: parameters.frames, framesize: parameters.framesize, ...extra } : { ...parameters, ...extra }
117
+ const output = await execute(`${namespace}['run_check'](${JSON.stringify(name)},${JSON.stringify(JSON.stringify(selected))})`, timeoutFor(name))
84
118
  return parseCheckResult(output, name)
85
119
  }
86
120
  const requirePass = result => {
87
121
  if (result.status !== 'PASS') throw Object.assign(failure(result.status === 'SKIP' ? 'UNSUPPORTED_OPERATION' : 'DEVICE_CHECK_FAILED', result.error.message), { checkResult: result })
88
122
  return result.data
89
123
  }
124
+ // ESPDet accepts VFS streams, including a read-only BytesIO-backed mount.
125
+ // Keep inference acceptance independent of persistent storage provisioning.
126
+ const espdlInference = async () => {
127
+ let model
128
+ try { model = new Uint8Array(await readFile(options.model ?? DEFAULT_ESPDL_MODEL)) }
129
+ catch (error) { throw failure('MODEL_UNAVAILABLE', `Cannot read ESP-DL model: ${error.code ?? 'read failed'}`) }
130
+ if (!model.length || model.length > 2 * 1024 * 1024) throw failure('MODEL_INVALID', 'ESP-DL acceptance model must be 1 byte to 2 MiB')
131
+ const sha256 = createHash('sha256').update(model).digest('hex')
132
+ const expected = options.modelSha256 ?? (options.model ? null : DEFAULT_ESPDL_SHA256)
133
+ if (expected && expected !== sha256) throw failure('MODEL_HASH_MISMATCH', 'ESP-DL model differs from the expected SHA-256')
134
+ await load()
135
+ const path = `/evhil_${nonce}`
136
+ let started = false, chunks = 0, retries = 0
137
+ const retryOffsets = []
138
+ const transferStarted = Date.now()
139
+ try {
140
+ started = true
141
+ await modelCall('begin', [nonce, model.length, path], { size: model.length, offset: 0, path })
142
+ const append = (chunk, offset) => modelSource('append', [offset, Buffer.from(chunk).toString('base64')])
143
+ // Base64 carries binary model bytes without the temporary-file execution
144
+ // path or a second host protocol implementation. Honor the live limit.
145
+ const chunkBytes = Math.floor((inputLimit - evReplExecInputBytes(append([], model.length))) / 4) * 3
146
+ if (chunkBytes < 3) throw failure('REPL_INPUT_LIMIT', 'Device REPL input limit cannot load the inference model')
147
+ for (let offset = 0; offset < model.length; offset += chunkBytes) {
148
+ cancelled(signal)
149
+ const chunk = model.subarray(offset, offset + chunkBytes), end = offset + chunk.length
150
+ const recovered = await modelCall('append', [offset, Buffer.from(chunk).toString('base64')], { offset, end, bytes: chunk.length, current: end }, { retry: true })
151
+ retries += recovered
152
+ if (recovered) retryOffsets.push({ offset, retries: recovered })
153
+ chunks++
154
+ }
155
+ await modelCall('mount', [sha256], { path, bytes: model.length, sha256 })
156
+ const modelTransferMs = Date.now() - transferStarted
157
+ const data = requirePass(await invoke('espdl_inference', { modelPath: `${path}/model.espdl` }))
158
+ return { ...data, modelBytes: model.length, modelSha256: sha256, modelStorage: 'ram-vfs', modelChunks: chunks, modelTransferMs, modelRetries: retries, modelRetryOffsets: retryOffsets }
159
+ } finally {
160
+ if (started) {
161
+ try { await modelCall('close', [], { bytes: 0, offset: 0, mounted: false }, { timeout: 5000, operationSignal: AbortSignal.timeout(6000) }) }
162
+ catch { throw failure('MODEL_CLEANUP_FAILED', 'Could not release the temporary RAM model mount') }
163
+ }
164
+ }
165
+ }
90
166
  const record = result => { results.push(result); report({ check: result.name, ...result, name: undefined }) }
91
167
  try {
92
168
  for (const name of options.checks) {
@@ -119,6 +195,8 @@ export async function runDeviceChecks(device, options, { signal, nonce, report =
119
195
  if (!count) throw failure('PREVIEW_EMPTY', 'No preview frame arrived')
120
196
  data.received = count
121
197
  } finally { unsubscribe() }
198
+ } else if (name === 'espdl_inference') {
199
+ data = await espdlInference()
122
200
  } else {
123
201
  const response = await invoke(name)
124
202
  if (name === 'ipa_stats' && response.status === 'SKIP') {
@@ -137,7 +215,7 @@ export async function runDeviceChecks(device, options, { signal, nonce, report =
137
215
  result = { name, status: 'PASS', data }
138
216
  } catch (error) {
139
217
  cancelled(signal)
140
- const skip = unsupported(error) && !options.requireAll && !(name === 'display' && options.screenOutput)
218
+ const skip = (unsupported(error) || error.code === 'MODEL_UNAVAILABLE') && !options.requireAll && !(name === 'display' && options.screenOutput)
141
219
  result = { name, status: skip ? 'SKIP' : 'FAIL', error: { code: error.code ?? 'DEVICE_CHECK_FAILED', message: String(error.message ?? 'Device check failed').slice(0, 240) } }
142
220
  } finally {
143
221
  if (name === 'display' && loaded) {
@@ -152,7 +230,11 @@ export async function runDeviceChecks(device, options, { signal, nonce, report =
152
230
  const cleanupSignal = AbortSignal.timeout(10000)
153
231
  let cleanupError
154
232
  if (namespaceStarted) {
155
- try { await execute(`if ${JSON.stringify(namespace)} in globals():\n ${namespace}.get('close_display',lambda:None)()\n del ${namespace}`, 5000, cleanupSignal) }
233
+ try {
234
+ await execute(`if ${JSON.stringify(namespace)} in globals():\n ${namespace}.get('close_display',lambda:None)()\n ${namespace}.get('close_model',lambda:None)()\n del ${namespace}`, 5000, cleanupSignal)
235
+ const marker = '__EVHILACK__' + JSON.stringify([nonce, 'cleanup', { absent: true }])
236
+ acknowledged(await execute(`assert ${JSON.stringify(namespace)} not in globals();print(${JSON.stringify(marker)})`, 5000, cleanupSignal), 'cleanup', { absent: true })
237
+ }
156
238
  catch (error) { cleanupError = error }
157
239
  }
158
240
  if (cleanupError) record({ name: 'fixture-cleanup', status: 'FAIL', error: { code: 'FIXTURE_CLEANUP_FAILED', message: 'Could not release the hardware fixture namespace' } })
@@ -0,0 +1,60 @@
1
+ // SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import { createHash } from 'node:crypto'
4
+ import { createDevice } from '../src/index.js'
5
+ import { firmwareSha256 } from '../src/firmware_flash.js'
6
+
7
+ export function partitionTable(entries = [
8
+ { label: 'nvs', type: 1, subtype: 2, offset: 0x9000, size: 0x6000 },
9
+ { label: 'phy_init', type: 1, subtype: 1, offset: 0xf000, size: 0x1000 },
10
+ { label: 'factory', type: 0, subtype: 0, offset: 0x10000, size: 0x10000 },
11
+ { label: 'ffat', type: 1, subtype: 0x81, offset: 0x20000, size: 0x21000 },
12
+ ], { md5 = true, size = 0xc00 } = {}) {
13
+ const bytes = new Uint8Array(size).fill(0xff), view = new DataView(bytes.buffer)
14
+ entries.forEach((entry, index) => {
15
+ const offset = index * 32
16
+ bytes.fill(0, offset, offset + 32)
17
+ view.setUint16(offset, 0x50aa, true)
18
+ bytes[offset + 2] = entry.type
19
+ bytes[offset + 3] = entry.subtype
20
+ view.setUint32(offset + 4, entry.offset, true)
21
+ view.setUint32(offset + 8, entry.size, true)
22
+ bytes.set(new TextEncoder().encode(entry.label), offset + 12)
23
+ view.setUint32(offset + 28, entry.flags ?? 0, true)
24
+ })
25
+ if (md5) {
26
+ const offset = entries.length * 32
27
+ bytes.set([0xeb, 0xeb], offset)
28
+ bytes.set(createHash('md5').update(bytes.subarray(0, offset)).digest(), offset + 16)
29
+ }
30
+ return bytes
31
+ }
32
+
33
+ export async function installFixture() {
34
+ const data = new Uint8Array(0x11000).fill(0xff), calls = []
35
+ data.set([0xe9, 1, 2, 3], 0x2000)
36
+ data.set(partitionTable(), 0x8000)
37
+ data.set([0xe9, 6, 7, 8], 0x10000)
38
+ const part = async (offset, size = 4) => ({ offset, bytes: size, sha256: await firmwareSha256(data.subarray(offset, offset + size)) })
39
+ const release = { board: 'BOARD', version: 'new', image: {
40
+ evMuxVersion: 4, chipFamily: 0x3280, chipRevision: 302, ...(await part(0x10000)),
41
+ partitionSize: 0x10000, partitionTable: await part(0x8000, 0xc00), url: 'https://example.test/app.bin',
42
+ }, install: { offset: 0, bytes: data.length, flashSize: 0x1000000, chipRevisionMin: 302, chipRevisionMax: 302,
43
+ sha256: await firmwareSha256(data), bootloader: await part(0x2000), url: 'https://example.test/full.bin' } }
44
+ const flash = new Uint8Array(0x41000).fill(0x5a)
45
+ const loader = {
46
+ chipFamily: 0x3280, chipRevision: 302,
47
+ async initialize() { calls.push('initialize') },
48
+ async getSecurityInfo() { return { flags: 0, flashCryptCnt: 0 } },
49
+ async runStub() { calls.push('stub'); return loader },
50
+ async flashId() { calls.push('capacity'); return 0x1840ef },
51
+ async eraseRegion(offset, size) { calls.push(['erase', offset, size]); flash.fill(0xff, offset, offset + size) },
52
+ async flashData(buffer, progress, offset) { calls.push(['write', offset]); flash.set(new Uint8Array(buffer), offset); progress(buffer.byteLength, buffer.byteLength) },
53
+ async readFlash(offset, count) { calls.push(['read', offset]); return flash.slice(offset, offset + count) },
54
+ async readRegister() { return 0 },
55
+ async hardReset() { calls.push('reset') },
56
+ async disconnect() { calls.push('close') },
57
+ }
58
+ const device = createDevice(), request = { mode: 'install', release, board: 'BOARD', data, async openLoader() { calls.push('open'); return loader } }
59
+ return { data, calls, release, loader, device, request, flash }
60
+ }