@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
@@ -11,6 +11,57 @@ import threading
11
11
  import serial
12
12
 
13
13
 
14
+ def _report_serial_error(stage, error):
15
+ """Emit bounded OS diagnostics, never exception text or transferred bytes."""
16
+ number = getattr(error, 'errno', None)
17
+ winerror = getattr(error, 'winerror', None)
18
+ cause = error.__cause__ or error.__context__
19
+ if not isinstance(number, int) and cause is not None:
20
+ number = getattr(cause, 'errno', None)
21
+ if not isinstance(winerror, int) and cause is not None:
22
+ winerror = getattr(cause, 'winerror', None)
23
+ def bounded_number(value):
24
+ if isinstance(value, int) and not isinstance(value, bool) and -0x80000000 <= value <= 0xffffffff:
25
+ return value
26
+ return None
27
+ number = bounded_number(number)
28
+ winerror = bounded_number(winerror)
29
+ # Pyserial sometimes wraps a Windows API failure without preserving errno.
30
+ # Only recognize fixed API names/messages; arbitrary exception text may
31
+ # contain application bytes and must not cross this diagnostic channel.
32
+ text = str(error)[:512]
33
+ message = 'Serial ' + stage + ' failed'
34
+ if number is not None and 0 < number < 4096:
35
+ message = os.strerror(number)
36
+ else:
37
+ for needle, safe in (
38
+ ('ClearCommError', 'ClearCommError failed'),
39
+ ('GetOverlappedResult', 'GetOverlappedResult failed'),
40
+ ('ReadFile', 'ReadFile failed'),
41
+ ('WriteFile', 'WriteFile failed'),
42
+ ('readiness to read but returned no data', 'Device reports readiness to read but returned no data'),
43
+ ('write timeout', 'Serial write timed out'),
44
+ ('Zero-byte serial write', 'Serial write returned zero bytes'),
45
+ ):
46
+ if needle.lower() in text.lower():
47
+ message = safe
48
+ break
49
+ record = {'kind': 'serial.error', 'stage': stage, 'type': type(error).__name__[:64],
50
+ 'errno': number, 'winerror': winerror, 'message': message[:160]}
51
+ # One small line keeps READY/control records intact when the reader thread
52
+ # fails concurrently with the main thread. stderr failure is terminal too.
53
+ line = (json.dumps(record, ensure_ascii=True) + '\n').encode()
54
+ try:
55
+ offset = 0
56
+ while offset < len(line):
57
+ count = os.write(2, line[offset:])
58
+ if count <= 0:
59
+ break
60
+ offset += count
61
+ except OSError:
62
+ pass
63
+
64
+
14
65
  def _configure_serial_exclusive(port, platform=os.name):
15
66
  # pyserial implements this as a non-blocking advisory lock on POSIX. The
16
67
  # Windows backend does not support this option, so leave its behavior alone.
@@ -50,16 +101,24 @@ def main():
50
101
  raise SystemExit(0)
51
102
 
52
103
  def receive():
53
- try:
54
- while not stopped.is_set():
104
+ while not stopped.is_set():
105
+ try:
55
106
  data = port.read(8192)
56
- if data:
107
+ except (OSError, serial.SerialException) as error:
108
+ if not stopped.is_set():
109
+ _report_serial_error('read', error)
110
+ os._exit(2)
111
+ return
112
+ if data:
113
+ try:
57
114
  offset = 0
58
115
  while offset < len(data):
59
116
  offset += os.write(1, data[offset:])
60
- except (OSError, serial.SerialException):
61
- if not stopped.is_set():
62
- os._exit(2)
117
+ except OSError as error:
118
+ if not stopped.is_set():
119
+ _report_serial_error('stdout', error)
120
+ os._exit(2)
121
+ return
63
122
 
64
123
  signal.signal(signal.SIGTERM, terminate)
65
124
  signal.signal(signal.SIGINT, terminate)
@@ -102,12 +161,16 @@ def main():
102
161
  response = {'id': request.get('id'), 'ok': False}
103
162
  print(json.dumps(response), file=sys.stderr, flush=True)
104
163
  continue
105
- offset = 0
106
- while offset < len(data):
107
- written = port.write(data[offset:])
108
- if not written:
109
- raise OSError('Zero-byte serial write')
110
- offset += written
164
+ try:
165
+ offset = 0
166
+ while offset < len(data):
167
+ written = port.write(data[offset:])
168
+ if not written:
169
+ raise OSError('Zero-byte serial write')
170
+ offset += written
171
+ except (OSError, serial.SerialException) as error:
172
+ _report_serial_error('write', error)
173
+ raise SystemExit(2) from None
111
174
  finally:
112
175
  stopped.set()
113
176
  port.close()
@@ -39,7 +39,7 @@ export function tcpTransport(options) {
39
39
 
40
40
  export function serialTransport(options) {
41
41
  const events = subscribers()
42
- let child, controlId = 0, baudRate = 115200
42
+ let child, connection, lastError = null, controlId = 0, baudRate = 115200
43
43
  const pending = new Map()
44
44
  const rejectControls = error => {
45
45
  for (const request of pending.values()) request.reject(error)
@@ -58,35 +58,65 @@ export function serialTransport(options) {
58
58
  connect({ signal }) {
59
59
  return new Promise((resolveConnect, reject) => {
60
60
  const adapter = fileURLToPath(new URL('./node_serial.py', import.meta.url))
61
- child = spawn(options.python, ['-u', adapter, options.serial], { stdio: ['pipe', 'pipe', 'pipe'] })
61
+ const bridge = child = spawn(options.python, ['-u', adapter, options.serial], { stdio: ['pipe', 'pipe', 'pipe'] })
62
+ const current = connection = { intentional: false }
63
+ lastError = null
64
+ let adapterCause = null, processCause = null
65
+ const safeCause = value => {
66
+ if (value?.kind !== 'serial.error' || !['read', 'write', 'stdout'].includes(value.stage)
67
+ || typeof value.type !== 'string' || typeof value.message !== 'string') return null
68
+ const integer = n => Number.isSafeInteger(n) && n >= -0x80000000 && n <= 0xffffffff ? n : null
69
+ return { stage: value.stage, type: value.type.slice(0, 64), errno: integer(value.errno),
70
+ winerror: integer(value.winerror), message: value.message.replace(/[\r\n\x00-\x1f\x7f]/g, ' ').slice(0, 160) }
71
+ }
72
+ const errorDetails = (exitCode = null, signal = null) => ({
73
+ ...(adapterCause ?? processCause ?? { stage: 'process', type: 'ProcessExit', errno: null, winerror: null,
74
+ message: 'Serial adapter exited unexpectedly' }),
75
+ exitCode: Number.isInteger(exitCode) ? exitCode : null,
76
+ signal: typeof signal === 'string' ? signal.slice(0, 32) : null,
77
+ })
62
78
  let ready = false, settled = false, runtimeErrorReported = false, stderr = '', lines = ''
63
79
  const abort = () => {
64
- child.kill('SIGTERM')
80
+ current.intentional = true
81
+ bridge.kill('SIGTERM')
65
82
  if (!settled) {
66
83
  settled = true
67
84
  reject(fail('CANCELLED', 'Serial connection cancelled'))
68
85
  }
69
86
  }
70
87
  const cleanup = () => signal.removeEventListener('abort', abort)
71
- const serialError = () => {
72
- const code = ['SERIAL_ACCESS_DENIED', 'SERIAL_BUSY'].find(code => stderr.includes(code)) ?? 'SERIAL_ERROR'
73
- return fail(code, 'Serial adapter failed; ensure Python, pyserial and port access are available')
88
+ const serialError = details => {
89
+ const code = ready ? 'SERIAL_ERROR' : ['SERIAL_ACCESS_DENIED', 'SERIAL_BUSY'].find(code => stderr.includes(code)) ?? 'SERIAL_ERROR'
90
+ const message = ready && details?.stage !== 'process'
91
+ ? `Serial adapter ${details.stage} failed: ${details.message}`
92
+ : 'Serial adapter failed; ensure Python, pyserial and port access are available'
93
+ const error = fail(code, message)
94
+ error.details = details ?? errorDetails()
95
+ error.cause = Object.assign(new Error(error.details.message), { ...error.details, name: error.details.type })
96
+ return error
74
97
  }
75
- const failBeforeReady = () => {
98
+ const failBeforeReady = details => {
76
99
  if (settled) return
77
100
  settled = true
78
101
  cleanup()
79
- const error = serialError()
102
+ const error = serialError(details)
80
103
  reject(error)
81
104
  events.emit('error', error)
82
105
  }
83
- const reportRuntimeError = () => {
106
+ const reportRuntimeError = details => {
84
107
  if (runtimeErrorReported) return
85
108
  runtimeErrorReported = true
86
- events.emit('error', serialError())
109
+ const error = serialError(details)
110
+ lastError = { ...error.details }
111
+ events.emit('error', error)
112
+ }
113
+ const recordProcessError = (stage, error) => {
114
+ processCause = { stage, type: 'ProcessError', errno: null, winerror: null,
115
+ message: typeof error?.code === 'string' && /^[A-Z0-9_]{1,32}$/.test(error.code)
116
+ ? `Serial adapter ${stage} failed (${error.code})` : `Serial adapter ${stage} failed` }
87
117
  }
88
- child.on('error', () => { if (ready) reportRuntimeError() })
89
- child.stdin.on('error', () => { if (ready) reportRuntimeError() })
118
+ child.on('error', error => recordProcessError('process', error))
119
+ child.stdin.on('error', error => recordProcessError('stdin', error))
90
120
  child.stdout.on('data', bytes => events.emit('data', bytes))
91
121
  child.stderr.on('data', bytes => {
92
122
  // Do not print Python errors: they can contain host paths or device data.
@@ -105,6 +135,8 @@ export function serialTransport(options) {
105
135
  }
106
136
  let response
107
137
  try { response = JSON.parse(line) } catch { continue }
138
+ const diagnostic = safeCause(response)
139
+ if (diagnostic) { adapterCause = diagnostic; continue }
108
140
  const request = pending.get(response.id)
109
141
  if (!request) continue
110
142
  pending.delete(response.id)
@@ -119,7 +151,23 @@ export function serialTransport(options) {
119
151
  })
120
152
  // Wait for close rather than exit: stdout/stderr may still have buffered
121
153
  // data after exit, including the adapter's stable SERIAL_BUSY marker.
122
- child.on('close', () => { cleanup(); rejectControls(fail('SERIAL_CLOSED', 'Serial adapter closed')); if (!ready) failBeforeReady(); events.emit('close') })
154
+ child.on('close', (code, signal) => {
155
+ cleanup()
156
+ if (connection !== current) return
157
+ const details = errorDetails(code, signal)
158
+ if (current.intentional) {
159
+ const error = fail('SERIAL_CLOSED', 'Serial adapter closed')
160
+ rejectControls(error)
161
+ if (!settled) { settled = true; reject(error) }
162
+ } else {
163
+ lastError = { ...details }
164
+ const error = serialError(details)
165
+ rejectControls(error)
166
+ if (!ready) failBeforeReady(details)
167
+ else reportRuntimeError(details)
168
+ }
169
+ events.emit('close')
170
+ })
123
171
  signal.addEventListener('abort', abort, { once: true })
124
172
  if (signal.aborted) abort()
125
173
  })
@@ -158,8 +206,9 @@ export function serialTransport(options) {
158
206
  send(1, Buffer.from(JSON.stringify({ id, baudRate: speed }))).catch(error => pending.get(id)?.reject(error))
159
207
  })
160
208
  },
161
- getDiagnostics() { return { baudRate } },
209
+ getDiagnostics() { return { baudRate, ...(lastError ? { lastError: { ...lastError } } : {}) } },
162
210
  async disconnect() {
211
+ if (connection) connection.intentional = true
163
212
  rejectControls(fail('SERIAL_CLOSED', 'Serial adapter closed'))
164
213
  if (!child?.pid || child.exitCode !== null || child.signalCode !== null) return
165
214
  await new Promise(resolveExit => {
@@ -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)
@@ -7,6 +7,7 @@ import { tmpdir } from 'node:os'
7
7
  import { dirname, join, resolve } from 'node:path'
8
8
  import { fileURLToPath } from 'node:url'
9
9
  import process from 'node:process'
10
+ import { npmCommand } from '../scripts/npm-command.mjs'
10
11
 
11
12
  const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
12
13
  const manifest = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'))
@@ -14,7 +15,7 @@ assert.equal(manifest.license, 'Apache-2.0')
14
15
  assert.match(await readFile(join(root, 'LICENSE'), 'utf8'), /Apache License[\s\S]*Version 2\.0/)
15
16
  const temporary = await mkdtemp(join(tmpdir(), 'esp-vision-device-core-package-'))
16
17
  try {
17
- const [packed] = JSON.parse(execFileSync('npm', ['pack', '--json', '--ignore-scripts', '--pack-destination', temporary], { cwd: root, encoding: 'utf8' }))
18
+ const [packed] = JSON.parse(execFileSync(...npmCommand(['pack', '--json', '--ignore-scripts', '--pack-destination', temporary]), { cwd: root, encoding: 'utf8' }))
18
19
  assert.ok(packed.files.some(({ path }) => path === 'LICENSE'))
19
20
  assert.ok(packed.files.some(({ path }) => path === 'NOTICE'))
20
21
  assert.ok(packed.files.some(({ path }) => path === 'schemas/device-actions-v1.json'))
@@ -25,7 +26,7 @@ try {
25
26
  assert.ok(!packed.files.some(({ path }) => path.includes('__pycache__') || path.endsWith('.pyc')), 'Never ship host Python bytecode')
26
27
  const consumer = join(temporary, 'consumer')
27
28
  await mkdir(consumer)
28
- execFileSync('npm', ['install', '--offline', '--ignore-scripts', '--no-audit', '--no-fund', '--package-lock=false', join(temporary, packed.filename)], { cwd: consumer, stdio: 'pipe' })
29
+ execFileSync(...npmCommand(['install', '--offline', '--ignore-scripts', '--no-audit', '--no-fund', '--package-lock=false', join(temporary, packed.filename)]), { cwd: consumer, stdio: 'pipe' })
29
30
  const installed = join(consumer, 'node_modules/@esp-vision/device-core')
30
31
  assert.equal(await readFile(join(installed, 'LICENSE'), 'utf8'), await readFile(join(root, 'LICENSE'), 'utf8'))
31
32
  const notice = await readFile(join(installed, 'NOTICE'), 'utf8')
@@ -44,6 +45,13 @@ try {
44
45
  const { defaultPython, filterSerialPorts, inventory, listSerial, serialEntry, tcpTransport, serialTransport, transportFor, openNodeRom } = await import('@esp-vision/device-core/node');
45
46
  const { openBrowserRom } = await import('@esp-vision/device-core/browser');
46
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();
47
55
  if (typeof tcpTransport !== 'function' || typeof serialTransport !== 'function') throw new Error('Missing public Node transports');
48
56
  if (typeof inventory !== 'function' || typeof listSerial !== 'function' || typeof filterSerialPorts !== 'function'
49
57
  || typeof serialEntry !== 'function' || typeof transportFor !== 'function' || typeof defaultPython !== 'string') throw new Error('Missing public Node inventory APIs');
@@ -62,22 +70,22 @@ try {
62
70
  api.createDevice({ transport: serialTransport({ python: 'python3', serial: 'not-opened' }) });
63
71
  if (typeof DeviceSession !== 'function' || typeof FirmwareFlashSession !== 'function') throw new Error('Missing host adapter APIs');`
64
72
  execFileSync(process.execPath, ['--input-type=module', '-e', script], { cwd: consumer, stdio: 'pipe' })
65
- 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']) {
66
74
  assert.ok(packed.files.some(({ path }) => path === required), `Missing installed check asset: ${required}`)
67
75
  }
68
- const unitOutput = execFileSync('npm', ['test'], { cwd: installed, encoding: 'utf8', maxBuffer: 4 * 1024 * 1024 })
76
+ const unitOutput = execFileSync(...npmCommand(['test']), { cwd: installed, encoding: 'utf8', maxBuffer: 4 * 1024 * 1024 })
69
77
  const unitCount = Number(/^(?:# tests|ℹ tests) (\d+)$/m.exec(unitOutput)?.[1] ?? 0)
70
78
  assert.ok(unitCount > 0, 'Installed npm test must execute a nonempty suite')
71
- const hardwareHelp = execFileSync('npm', ['run', 'test:hardware', '--', '--help'], { cwd: installed, encoding: 'utf8' })
79
+ const hardwareHelp = execFileSync(...npmCommand(['run', 'test:hardware', '--', '--help']), { cwd: installed, encoding: 'utf8' })
72
80
  assert.ok(hardwareHelp.includes('--serial PATH'), 'Installed hardware runner must load and show its help')
73
81
  // A damaged tarball must fail instead of reporting a successful zero-test run.
74
82
  await rm(join(installed, 'tests'), { recursive: true })
75
83
  await mkdir(join(installed, 'tests'))
76
- const empty = spawnSync('npm', ['test'], { cwd: installed, encoding: 'utf8' })
84
+ const empty = spawnSync(...npmCommand(['test']), { cwd: installed, encoding: 'utf8' })
77
85
  assert.equal(empty.status, 1, 'An empty suite must fail closed')
78
86
  assert.match(empty.stderr, /No Device Core unit tests found/)
79
87
  await rm(join(installed, 'tests'), { recursive: true })
80
- const missing = spawnSync('npm', ['test'], { cwd: installed, encoding: 'utf8' })
88
+ const missing = spawnSync(...npmCommand(['test']), { cwd: installed, encoding: 'utf8' })
81
89
  assert.equal(missing.status, 1, 'Missing tests must fail closed')
82
90
  console.log(JSON.stringify({ package: packed.name, version: packed.version, entryPoints, unitCount, standalone: true }))
83
91
  } finally {
@@ -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
+ }