@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.
- package/CHANGELOG.md +17 -0
- package/README.md +7 -4
- package/package.json +2 -2
- package/schemas/device-actions-v1.json +1 -1
- package/src/browser.js +5 -2
- package/src/browser_rom.js +55 -5
- package/src/device_session.js +85 -9
- package/src/evmux.js +15 -0
- package/src/firmware_flash.js +84 -26
- package/src/firmware_partitions.js +57 -0
- package/src/firmware_updates.js +5 -2
- package/src/node_rom.js +33 -5
- package/src/node_rom.py +26 -1
- package/src/rom_identity.js +9 -0
- package/tests/check-browser.mjs +23 -4
- package/tests/check-package.mjs +8 -1
- package/tests/device-checks.mjs +89 -7
- package/tests/firmware-fixtures.mjs +60 -0
- package/tests/fixtures/device-checks.py +357 -1
- package/tests/hardware-check.mjs +3 -2
- package/tests/test-browser-rom.mjs +169 -3
- package/tests/test-ci-check-selection.mjs +31 -0
- package/tests/test-device-functional-checks.mjs +275 -3
- package/tests/test-device-v4-alignment.mjs +261 -7
- package/tests/test-firmware-flash.mjs +22 -20
- package/tests/test-firmware-install.mjs +8 -35
- package/tests/test-firmware-source.mjs +10 -7
- package/tests/test-install-erase.mjs +153 -0
- package/tests/test-node-rom.mjs +87 -3
package/src/firmware_updates.js
CHANGED
|
@@ -5,6 +5,7 @@ import { loadOfficialFirmware } from './firmware_feed.js'
|
|
|
5
5
|
import { FirmwareFlashSession, validateFirmwareImage, firmwareMode } from './firmware_flash.js'
|
|
6
6
|
import { validateFirmwareRelease as release } from './firmware_release.js'
|
|
7
7
|
import { downloadBytes, MAX_FIRMWARE_BYTES } from './firmware_io.js'
|
|
8
|
+
import { planInstallFilesystemErase } from './firmware_partitions.js'
|
|
8
9
|
|
|
9
10
|
function record(value, keys, name) {
|
|
10
11
|
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new TypeError(`${name} must be an object`)
|
|
@@ -79,6 +80,7 @@ export async function updateFirmware(request, connectedIdentity, disconnect) {
|
|
|
79
80
|
bytes = bytes.slice(source.offset, source.offset + image.bytes)
|
|
80
81
|
}
|
|
81
82
|
await validateFirmwareImage(image, bytes, { mode })
|
|
83
|
+
if (mode === 'install') planInstallFilesystemErase(image, bytes)
|
|
82
84
|
abort(signal)
|
|
83
85
|
// Validate everything before releasing EV-MUX or acquiring a download port.
|
|
84
86
|
await disconnect()
|
|
@@ -87,10 +89,11 @@ export async function updateFirmware(request, connectedIdentity, disconnect) {
|
|
|
87
89
|
let session
|
|
88
90
|
try {
|
|
89
91
|
abort(signal)
|
|
90
|
-
session = new FirmwareFlashSession(loader, image, { mode, reset: reset ? () => reset(loader) : undefined })
|
|
92
|
+
session = new FirmwareFlashSession(loader, image, { mode, signal, reset: reset ? () => reset(loader) : undefined })
|
|
91
93
|
await session.connect(bytes)
|
|
92
94
|
abort(signal)
|
|
93
|
-
//
|
|
95
|
+
// Erase/erase-verification honor cancellation; once firmware writing starts,
|
|
96
|
+
// finish verification/reset as before. No uncertain operation is replayed.
|
|
94
97
|
const result = await session.flash(bytes, { onProgress, onPhase })
|
|
95
98
|
return { ...result, version: target.version, board: target.board, reconnected: false }
|
|
96
99
|
} finally {
|
package/src/node_rom.js
CHANGED
|
@@ -10,22 +10,22 @@ export function openNodeRom({ port, python = defaultPython, signal } = {}, depen
|
|
|
10
10
|
if (!port) throw new TypeError('Firmware update requires a local --port')
|
|
11
11
|
const lifecycle = new AbortController()
|
|
12
12
|
signal = signal ? AbortSignal.any([signal, lifecycle.signal]) : lifecycle.signal
|
|
13
|
-
let child, input, pending, sequence = 0, closed = false, initializing = false, romReady = false
|
|
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() },
|
|
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
|
-
|
|
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
|
+
}
|
package/tests/check-browser.mjs
CHANGED
|
@@ -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)
|
package/tests/check-package.mjs
CHANGED
|
@@ -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 })
|
package/tests/device-checks.mjs
CHANGED
|
@@ -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
|
|
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 {
|
|
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
|
+
}
|