@esp-vision/device-core 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/README.md +8 -5
- package/package.json +1 -1
- package/schemas/device-actions-v1.json +1 -1
- package/scripts/generate-action-schema.mjs +2 -1
- package/scripts/npm-command.mjs +17 -0
- package/scripts/release-npm.mjs +2 -1
- package/src/browser.js +5 -2
- package/src/browser_rom.js +55 -5
- package/src/device_session.js +85 -9
- package/src/evmux.js +15 -0
- package/src/firmware_flash.js +84 -26
- package/src/firmware_partitions.js +57 -0
- package/src/firmware_updates.js +5 -2
- package/src/node_rom.js +49 -7
- package/src/node_rom.py +92 -5
- package/src/node_rom_port.js +27 -19
- package/src/node_serial.py +75 -12
- package/src/node_transport.js +63 -14
- package/src/rom_identity.js +9 -0
- package/tests/check-browser.mjs +23 -4
- package/tests/check-package.mjs +15 -7
- package/tests/device-checks.mjs +89 -7
- package/tests/firmware-fixtures.mjs +60 -0
- package/tests/fixtures/device-checks.py +357 -1
- package/tests/hardware-check.mjs +3 -2
- package/tests/test-browser-rom.mjs +169 -3
- package/tests/test-ci-check-selection.mjs +31 -0
- package/tests/test-device-functional-checks.mjs +275 -3
- package/tests/test-device-v4-alignment.mjs +261 -7
- package/tests/test-firmware-flash.mjs +22 -20
- package/tests/test-firmware-install.mjs +8 -35
- package/tests/test-firmware-source.mjs +10 -7
- package/tests/test-host-checks.mjs +47 -0
- package/tests/test-install-erase.mjs +153 -0
- package/tests/test-node-rom-port.mjs +81 -1
- package/tests/test-node-rom.mjs +159 -4
- package/tests/test-node-serial.mjs +231 -2
- package/tests/test-npm-release-policy.mjs +3 -9
|
@@ -1,16 +1,21 @@
|
|
|
1
1
|
// SPDX-License-Identifier: Apache-2.0
|
|
2
2
|
import test from 'node:test'
|
|
3
3
|
import assert from 'node:assert/strict'
|
|
4
|
-
import { readFile, mkdtemp, rm } from 'node:fs/promises'
|
|
4
|
+
import { readFile, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
5
5
|
import { join } from 'node:path'
|
|
6
6
|
import { EV_REPL_INPUT_MAX_BYTES, evReplExecInputBytes } from '../src/evrepl.js'
|
|
7
7
|
import { tmpdir } from 'node:os'
|
|
8
|
+
import { existsSync } from 'node:fs'
|
|
9
|
+
import { createHash } from 'node:crypto'
|
|
10
|
+
import { spawnSync } from 'node:child_process'
|
|
11
|
+
import { fileURLToPath } from 'node:url'
|
|
8
12
|
import { parseArgs } from './hardware-check.mjs'
|
|
9
|
-
import { DEVICE_CHECKS, parseCheckResult, runDeviceChecks, saveJpeg } from './device-checks.mjs'
|
|
13
|
+
import { DEFAULT_ESPDL_MODEL, DEFAULT_ESPDL_SHA256, DEVICE_CHECKS, parseCheckResult, runDeviceChecks, saveJpeg } from './device-checks.mjs'
|
|
10
14
|
import { jpegFixture } from './jpeg-fixtures.mjs'
|
|
11
15
|
|
|
12
16
|
function devicePeer({ results = {}, board = 'TEST', screenFails = false } = {}) {
|
|
13
17
|
const files = new Map(), calls = [], listeners = new Map()
|
|
18
|
+
let modelNonce, modelBytes, modelPath, modelOffset = 0
|
|
14
19
|
return {
|
|
15
20
|
calls, files, listeners,
|
|
16
21
|
async inspect({ fields }) { calls.push(['inspect', fields]); return { identity: { board, firmwareVersion: 'test' }, capabilities: { protocol: { evMuxVersion: 4 }, features: ['storage.writer.lease'], limits: { replInputMax: EV_REPL_INPUT_MAX_BYTES } }, runtime: { vmReady: true }, storage: { flash: { present: true, mounted: false, writable: false } } } },
|
|
@@ -21,6 +26,26 @@ function devicePeer({ results = {}, board = 'TEST', screenFails = false } = {})
|
|
|
21
26
|
}),
|
|
22
27
|
python: { async execute({ source }) {
|
|
23
28
|
calls.push(['execute', source])
|
|
29
|
+
if (source.includes("={'_source':[],'_fixture_nonce':")) modelNonce = JSON.parse(source.slice(source.indexOf("'_fixture_nonce':") + 17, -1))
|
|
30
|
+
const model = /\['model_command'\]\((.*)\)$/.exec(source)
|
|
31
|
+
if (model) {
|
|
32
|
+
const [operation, ...args] = JSON.parse('[' + model[1] + ']')
|
|
33
|
+
let data
|
|
34
|
+
if (operation === 'begin') {
|
|
35
|
+
;[modelNonce, modelBytes, modelPath] = args
|
|
36
|
+
modelOffset = 0
|
|
37
|
+
data = { size: modelBytes, offset: 0, path: modelPath }
|
|
38
|
+
} else if (operation === 'append') {
|
|
39
|
+
const [offset, encoded] = args, bytes = Buffer.from(encoded, 'base64').length
|
|
40
|
+
modelOffset = offset + bytes
|
|
41
|
+
data = { offset, end: modelOffset, bytes, current: modelOffset }
|
|
42
|
+
} else if (operation === 'mount') data = { path: modelPath, bytes: modelBytes, sha256: args[0] }
|
|
43
|
+
else data = { bytes: 0, offset: 0, mounted: false }
|
|
44
|
+
return { ok: true, output: '__EVHILACK__' + JSON.stringify([modelNonce, operation, data]) }
|
|
45
|
+
}
|
|
46
|
+
if (source.startsWith('assert ') && source.includes('__EVHILACK__')) {
|
|
47
|
+
return { ok: true, output: JSON.parse(source.slice(source.indexOf(';print(') + 7, -1)) }
|
|
48
|
+
}
|
|
24
49
|
const name = /\['run_check'\]\("([a-z_]+)"/.exec(source)?.[1]
|
|
25
50
|
if (!name) return { ok: true, output: '' }
|
|
26
51
|
const result = results[name] ?? { name, status: 'PASS', data: { width: 240, height: 320 } }
|
|
@@ -147,10 +172,257 @@ test('identity and RAM checks do not require optional storage inspection support
|
|
|
147
172
|
})
|
|
148
173
|
|
|
149
174
|
test('P4 issue regressions are included in the strict target-check path', async () => {
|
|
150
|
-
const required = ['camera_display_lifecycle', 'image_copy_to_fb', 'image_transform_argument', 'sensor_snapshot_buffer', 'memory_policy']
|
|
175
|
+
const required = ['camera_display_lifecycle', 'image_copy_to_fb', 'image_transform_argument', 'sensor_snapshot_buffer', 'memory_policy', 'isp_ccm_frames', 'isp_lsc_display']
|
|
151
176
|
assert.ok(required.every(name => DEVICE_CHECKS.includes(name)))
|
|
152
177
|
const device = devicePeer(), reports = []
|
|
153
178
|
await runDeviceChecks(device, parseArgs(['--tcp', 'test', '--checks', required.join(','), '--require-all']),
|
|
154
179
|
{ nonce: 'issue910', report: value => reports.push(value) })
|
|
155
180
|
assert.deepEqual(reports.at(-1), { check: 'summary', passed: required.length, failed: 0, skipped: 0 })
|
|
156
181
|
})
|
|
182
|
+
|
|
183
|
+
test('ESP-DL inference streams a model into RAM, respects the live REPL limit and never touches persistent storage', async () => {
|
|
184
|
+
const directory = await mkdtemp(join(tmpdir(), 'esp-vision-model-'))
|
|
185
|
+
const model = join(directory, 'model.espdl'), bytes = Buffer.from(Array.from({ length: 1024 }, (_, index) => index & 255))
|
|
186
|
+
try {
|
|
187
|
+
await writeFile(model, bytes)
|
|
188
|
+
const device = devicePeer(), reports = [], inspect = device.inspect
|
|
189
|
+
device.inspect = async request => {
|
|
190
|
+
assert.ok(!request.fields.includes('storage'))
|
|
191
|
+
const info = await inspect(request)
|
|
192
|
+
info.capabilities.limits.replInputMax = 256
|
|
193
|
+
return info
|
|
194
|
+
}
|
|
195
|
+
await runDeviceChecks(device, parseArgs(['--tcp', 'test', '--checks', 'espdl_inference', '--model', model, '--require-all']), { nonce: 'abc123', report: value => reports.push(value) })
|
|
196
|
+
const commands = device.calls.filter(call => call[0] === 'execute').map(call => call[1])
|
|
197
|
+
assert.ok(commands.every(source => evReplExecInputBytes(source) <= 256))
|
|
198
|
+
const uploaded = commands.filter(source => source.includes("['model_command'](\"append\",")).map(source => Buffer.from(JSON.parse('[' + source.slice(source.indexOf('](') + 2, -1) + ']')[2], 'base64'))
|
|
199
|
+
assert.deepEqual(Buffer.concat(uploaded), bytes)
|
|
200
|
+
assert.ok(commands.some(source => source.includes("['model_command'](\"mount\",\"" + createHash('sha256').update(bytes).digest('hex'))))
|
|
201
|
+
assert.ok(commands.some(source => source.includes('modelPath\\\":\\\"/evhil_abc123/model.espdl')))
|
|
202
|
+
assert.ok(commands.some(source => source.includes("['model_command'](\"close\")")))
|
|
203
|
+
assert.equal(reports.find(result => result.check === 'espdl_inference').data.modelBytes, bytes.length)
|
|
204
|
+
assert.equal(reports.find(result => result.check === 'espdl_inference').data.modelStorage, 'ram-vfs')
|
|
205
|
+
assert.equal(device.files.size, 0)
|
|
206
|
+
} finally { await rm(directory, { recursive: true, force: true }) }
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
test('missing ESP-DL model skips only optional acceptance and malformed expected hashes are rejected', async () => {
|
|
210
|
+
assert.throws(() => parseArgs(['--tcp', 'test', '--model-sha256', 'bad']), { code: 'ARGUMENT' })
|
|
211
|
+
for (const strict of [false, true]) {
|
|
212
|
+
const device = devicePeer(), results = []
|
|
213
|
+
const run = runDeviceChecks(device, parseArgs(['--tcp', 'test', '--checks', 'espdl_inference', '--model', join(tmpdir(), 'missing-esp-vision.espdl'), ...(strict ? ['--require-all'] : [])]), { nonce: 'abc123', report: value => results.push(value) })
|
|
214
|
+
if (strict) await assert.rejects(run, { code: 'DEVICE_CHECKS_FAILED' })
|
|
215
|
+
else await run
|
|
216
|
+
assert.equal(results.find(result => result.check === 'espdl_inference').status, strict ? 'FAIL' : 'SKIP')
|
|
217
|
+
assert.ok(!device.calls.some(call => call[0] === 'execute'))
|
|
218
|
+
}
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
test('ESP-DL model hash mismatch is rejected before device mutation', async () => {
|
|
222
|
+
const directory = await mkdtemp(join(tmpdir(), 'esp-vision-model-'))
|
|
223
|
+
try {
|
|
224
|
+
const model = join(directory, 'model.espdl'), device = devicePeer(), reports = []
|
|
225
|
+
await writeFile(model, Buffer.from([1, 2, 3]))
|
|
226
|
+
await assert.rejects(runDeviceChecks(device, parseArgs(['--tcp', 'test', '--checks', 'espdl_inference', '--model', model, '--model-sha256', '0'.repeat(64)]), { nonce: 'abc123', report: value => reports.push(value) }), { code: 'DEVICE_CHECKS_FAILED' })
|
|
227
|
+
assert.equal(reports.find(result => result.check === 'espdl_inference').error.code, 'MODEL_HASH_MISMATCH')
|
|
228
|
+
assert.equal(device.calls.length, 0)
|
|
229
|
+
} finally { await rm(directory, { recursive: true, force: true }) }
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
test('partial model upload, inference failure and cleanup failure cannot report success', async () => {
|
|
233
|
+
const directory = await mkdtemp(join(tmpdir(), 'esp-vision-model-'))
|
|
234
|
+
try {
|
|
235
|
+
const model = join(directory, 'model.espdl')
|
|
236
|
+
await writeFile(model, Buffer.alloc(1024))
|
|
237
|
+
for (const stage of ['append_model', 'espdl_inference', 'close_model']) {
|
|
238
|
+
const device = devicePeer(), execute = device.python.execute, reports = []
|
|
239
|
+
device.python.execute = async request => {
|
|
240
|
+
if (request.source.includes(`['model_command']("${stage.replace('_model', '')}"`) || (stage === 'espdl_inference' && request.source.includes("['run_check'](\"espdl_inference\""))) {
|
|
241
|
+
device.calls.push(['failed-execute', request.source])
|
|
242
|
+
throw new Error('injected failure')
|
|
243
|
+
}
|
|
244
|
+
return execute(request)
|
|
245
|
+
}
|
|
246
|
+
await assert.rejects(runDeviceChecks(device, parseArgs(['--tcp', 'test', '--checks', 'espdl_inference', '--model', model, '--require-all']), { nonce: 'abc123', report: value => reports.push(value) }), { code: 'DEVICE_CHECKS_FAILED' })
|
|
247
|
+
assert.equal(reports.find(result => result.check === 'espdl_inference').status, 'FAIL')
|
|
248
|
+
assert.ok(device.calls.some(call => call[1]?.includes("['model_command'](\"close\")")))
|
|
249
|
+
assert.ok(device.calls.some(call => call[1]?.includes(".get('close_model',lambda:None)()")))
|
|
250
|
+
}
|
|
251
|
+
} finally { await rm(directory, { recursive: true, force: true }) }
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
test('model transfer cancellation releases RAM resources with an independent cleanup signal', async () => {
|
|
255
|
+
const directory = await mkdtemp(join(tmpdir(), 'esp-vision-model-'))
|
|
256
|
+
try {
|
|
257
|
+
const model = join(directory, 'model.espdl'), device = devicePeer(), execute = device.python.execute, controller = new AbortController()
|
|
258
|
+
await writeFile(model, Buffer.alloc(1024))
|
|
259
|
+
let cleaned = false
|
|
260
|
+
device.python.execute = async request => {
|
|
261
|
+
if (request.source.includes("['model_command'](\"append\",")) controller.abort(new Error('cancelled transfer'))
|
|
262
|
+
if (request.source.includes("['model_command'](\"close\")")) { assert.equal(request.signal.aborted, false); cleaned = true }
|
|
263
|
+
return execute(request)
|
|
264
|
+
}
|
|
265
|
+
await assert.rejects(runDeviceChecks(device, parseArgs(['--tcp', 'test', '--checks', 'espdl_inference', '--model', model]), { signal: controller.signal, nonce: 'abc123', report() {} }), /cancelled transfer/)
|
|
266
|
+
assert.equal(cleaned, true)
|
|
267
|
+
} finally { await rm(directory, { recursive: true, force: true }) }
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
// Installed npm packages run these tests without the repository's models directory.
|
|
271
|
+
async function modelCase(callback, size = 1024) {
|
|
272
|
+
const directory = await mkdtemp(join(tmpdir(), 'esp-vision-model-ack-'))
|
|
273
|
+
try {
|
|
274
|
+
const model = join(directory, 'model.espdl'), bytes = typeof size === 'number' ? Buffer.alloc(size, 42) : Buffer.from(size)
|
|
275
|
+
await writeFile(model, bytes)
|
|
276
|
+
await callback({ model, bytes, device: devicePeer(), reports: [] })
|
|
277
|
+
} finally { await rm(directory, { recursive: true, force: true }) }
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
test('model upload retries a complete public execute only for uncertain TIMEOUT and reports recovery', async () => {
|
|
281
|
+
for (const executed of [false, true]) await modelCase(async ({ model, device, reports }) => {
|
|
282
|
+
const execute = device.python.execute, attempts = []
|
|
283
|
+
let first = true
|
|
284
|
+
device.python.execute = async request => {
|
|
285
|
+
if (request.source.includes('[\'model_command\']("append",')) {
|
|
286
|
+
attempts.push(request.source)
|
|
287
|
+
if (first) {
|
|
288
|
+
first = false
|
|
289
|
+
if (executed) await execute(request)
|
|
290
|
+
throw Object.assign(new Error('uncertain acknowledgement'), { code: 'TIMEOUT' })
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return execute(request)
|
|
294
|
+
}
|
|
295
|
+
await runDeviceChecks(device, parseArgs(['--tcp', 'test', '--checks', 'espdl_inference', '--model', model, '--require-all']), { nonce: 'acktest', report: value => reports.push(value) })
|
|
296
|
+
assert.equal(attempts[0], attempts[1])
|
|
297
|
+
const data = reports.find(result => result.check === 'espdl_inference').data
|
|
298
|
+
assert.equal(data.modelRetries, 1)
|
|
299
|
+
assert.deepEqual(data.modelRetryOffsets, [{ offset: 0, retries: 1 }])
|
|
300
|
+
assert.equal(attempts.length, data.modelChunks + 1)
|
|
301
|
+
})
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
test('model upload limits timeout attempts to three and never retries cancellation or semantic errors', async () => {
|
|
305
|
+
for (const mode of ['timeout', 'error', 'abort']) await modelCase(async ({ model, device, reports }) => {
|
|
306
|
+
const execute = device.python.execute, controller = new AbortController()
|
|
307
|
+
let attempts = 0
|
|
308
|
+
device.python.execute = async request => {
|
|
309
|
+
if (request.source.includes('[\'model_command\']("append",')) {
|
|
310
|
+
attempts++
|
|
311
|
+
const error = Object.assign(new Error(mode), { code: mode === 'error' ? 'DATA_CONFLICT' : 'TIMEOUT' })
|
|
312
|
+
if (mode === 'abort') controller.abort(error)
|
|
313
|
+
throw error
|
|
314
|
+
}
|
|
315
|
+
return execute(request)
|
|
316
|
+
}
|
|
317
|
+
await assert.rejects(runDeviceChecks(device, parseArgs(['--tcp', 'test', '--checks', 'espdl_inference', '--model', model, '--require-all']), { signal: controller.signal, nonce: 'acktest', report: value => reports.push(value) }))
|
|
318
|
+
assert.equal(attempts, mode === 'timeout' ? 3 : 1)
|
|
319
|
+
})
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
test('every model operation requires a semantic ACK even when Python prints an exception with ok true', async () => {
|
|
323
|
+
for (const operation of ['begin', 'append', 'mount', 'close', 'cleanup']) await modelCase(async ({ model, device, reports }) => {
|
|
324
|
+
const execute = device.python.execute
|
|
325
|
+
let attempts = 0
|
|
326
|
+
device.python.execute = async request => {
|
|
327
|
+
if (request.source.includes(`['model_command']("${operation}"`) || (operation === 'cleanup' && request.source.startsWith('assert '))) {
|
|
328
|
+
attempts++
|
|
329
|
+
return { ok: true, output: 'Traceback (most recent call last):\nOSError: injected failure\n' }
|
|
330
|
+
}
|
|
331
|
+
return execute(request)
|
|
332
|
+
}
|
|
333
|
+
await assert.rejects(runDeviceChecks(device, parseArgs(['--tcp', 'test', '--checks', 'espdl_inference', '--model', model, '--require-all']), { nonce: 'acktest', report: value => reports.push(value) }), { code: 'DEVICE_CHECKS_FAILED' })
|
|
334
|
+
assert.equal(attempts, 1)
|
|
335
|
+
assert.ok(reports.some(result => result.status === 'FAIL'))
|
|
336
|
+
})
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
test('upload ACK rejects stale nonce, wrong offset, duplicate markers and malformed output without retry', async () => {
|
|
340
|
+
for (const invalid of ['nonce', 'offset', 'duplicate', 'malformed']) await modelCase(async ({ model, device, reports }) => {
|
|
341
|
+
const execute = device.python.execute
|
|
342
|
+
let attempts = 0
|
|
343
|
+
device.python.execute = async request => {
|
|
344
|
+
const response = await execute(request)
|
|
345
|
+
if (request.source.includes('[\'model_command\']("append",')) {
|
|
346
|
+
attempts++
|
|
347
|
+
const ack = JSON.parse(response.output.slice('__EVHILACK__'.length))
|
|
348
|
+
if (invalid === 'nonce') ack[0] = 'stale'
|
|
349
|
+
if (invalid === 'offset') ack[2].current++
|
|
350
|
+
response.output = '__EVHILACK__' + JSON.stringify(ack)
|
|
351
|
+
if (invalid === 'duplicate') response.output += '\n' + response.output
|
|
352
|
+
if (invalid === 'malformed') response.output = '__EVHILACK__broken'
|
|
353
|
+
}
|
|
354
|
+
return response
|
|
355
|
+
}
|
|
356
|
+
await assert.rejects(runDeviceChecks(device, parseArgs(['--tcp', 'test', '--checks', 'espdl_inference', '--model', model]), { nonce: 'acktest', report: value => reports.push(value) }), { code: 'DEVICE_CHECKS_FAILED' })
|
|
357
|
+
assert.equal(attempts, 1)
|
|
358
|
+
assert.equal(reports.find(result => result.check === 'espdl_inference').error.code, 'MODEL_ACK_INVALID')
|
|
359
|
+
})
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
test('begin timeout before its body preserves the original error and still acknowledges empty cleanup', async () => {
|
|
363
|
+
await modelCase(async ({ model, device, reports }) => {
|
|
364
|
+
const execute = device.python.execute
|
|
365
|
+
device.python.execute = async request => {
|
|
366
|
+
if (request.source.includes('[\'model_command\']("begin",')) throw Object.assign(new Error('begin preparation timeout'), { code: 'TIMEOUT' })
|
|
367
|
+
return execute(request)
|
|
368
|
+
}
|
|
369
|
+
await assert.rejects(runDeviceChecks(device, parseArgs(['--tcp', 'test', '--checks', 'espdl_inference', '--model', model]), { nonce: 'acktest', report: value => reports.push(value) }), { code: 'DEVICE_CHECKS_FAILED' })
|
|
370
|
+
assert.equal(reports.find(result => result.check === 'espdl_inference').error.code, 'TIMEOUT')
|
|
371
|
+
assert.equal(reports.at(-1).failed, 1)
|
|
372
|
+
})
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
test('full-sized model upload reserves the largest offset and final block within both live REPL budgets', async () => {
|
|
376
|
+
const modelBytes = existsSync(DEFAULT_ESPDL_MODEL) ? await readFile(DEFAULT_ESPDL_MODEL) : Buffer.alloc(495840, 42)
|
|
377
|
+
for (const limit of [256, 511]) await modelCase(async ({ model, device, reports, bytes }) => {
|
|
378
|
+
const inspect = device.inspect
|
|
379
|
+
device.inspect = async request => { const result = await inspect(request); result.capabilities.limits.replInputMax = limit; return result }
|
|
380
|
+
try { await runDeviceChecks(device, parseArgs(['--tcp', 'test', '--checks', 'espdl_inference', '--model', model]), { nonce: '0123456789abcdef', report: value => reports.push(value) }) }
|
|
381
|
+
catch { assert.fail(JSON.stringify({ limit, reports })) }
|
|
382
|
+
const commands = device.calls.filter(call => call[0] === 'execute').map(call => call[1])
|
|
383
|
+
assert.ok(commands.every(source => evReplExecInputBytes(source) <= limit))
|
|
384
|
+
const chunks = commands.filter(source => source.includes('[\'model_command\']("append",')).map(source => JSON.parse('[' + source.slice(source.indexOf('](') + 2, -1) + ']'))
|
|
385
|
+
let offset = 0
|
|
386
|
+
for (const [, actualOffset, encoded] of chunks) { assert.equal(actualOffset, offset); offset += Buffer.from(encoded, 'base64').length }
|
|
387
|
+
assert.equal(offset, bytes.length)
|
|
388
|
+
assert.deepEqual(Buffer.concat(chunks.map(chunk => Buffer.from(chunk[2], 'base64'))), bytes)
|
|
389
|
+
assert.equal(reports.find(result => result.check === 'espdl_inference').data.modelChunks, limit === 511 ? 1517 : 3673)
|
|
390
|
+
}, modelBytes)
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
test('repository checkouts provide the default ESP-DL model', { skip: !existsSync(new URL('../../../models/', import.meta.url)) }, async () => {
|
|
394
|
+
const bytes = await readFile(DEFAULT_ESPDL_MODEL)
|
|
395
|
+
assert.equal(bytes.length, 495840)
|
|
396
|
+
assert.equal(createHash('sha256').update(bytes).digest('hex'), DEFAULT_ESPDL_SHA256)
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
test('ISP cleanup releases camera and collects memory even when display deinit fails', t => {
|
|
400
|
+
const python = [process.env.PYTHON, 'python3', 'python'].filter(Boolean).find(command => spawnSync(command, ['-c', 'pass'], { windowsHide: true }).status === 0)
|
|
401
|
+
if (!python) return t.skip('Python is required to execute the device-side cleanup fixture')
|
|
402
|
+
const source = `
|
|
403
|
+
import runpy, sys, types
|
|
404
|
+
fixture = runpy.run_path(sys.argv[1])
|
|
405
|
+
for display_fails, camera_fails in ((False, False), (True, False), (False, True), (True, True)):
|
|
406
|
+
events = []
|
|
407
|
+
class Display:
|
|
408
|
+
def deinit(self):
|
|
409
|
+
events.append('display')
|
|
410
|
+
if display_fails:
|
|
411
|
+
raise RuntimeError('display cleanup failed')
|
|
412
|
+
def shutdown():
|
|
413
|
+
events.append('camera')
|
|
414
|
+
if camera_fails:
|
|
415
|
+
raise RuntimeError('camera cleanup failed')
|
|
416
|
+
sys.modules['sensor'] = types.SimpleNamespace(shutdown=shutdown)
|
|
417
|
+
sys.modules['gc'] = types.SimpleNamespace(collect=lambda: events.append('collect'))
|
|
418
|
+
try:
|
|
419
|
+
fixture['isp_release_camera_lcd'](Display())
|
|
420
|
+
except RuntimeError:
|
|
421
|
+
assert display_fails or camera_fails
|
|
422
|
+
else:
|
|
423
|
+
assert not display_fails and not camera_fails
|
|
424
|
+
assert events == ['display', 'camera', 'collect'], events
|
|
425
|
+
`
|
|
426
|
+
const result = spawnSync(python, ['-c', source, fileURLToPath(new URL('./fixtures/device-checks.py', import.meta.url))], { encoding: 'utf8', windowsHide: true })
|
|
427
|
+
assert.equal(result.status, 0, result.stderr)
|
|
428
|
+
})
|
|
@@ -10,6 +10,37 @@ function fixture(options = {}) {
|
|
|
10
10
|
return new DeviceSession({ transport: { async writeBytes() {} }, EvMuxParser, makeEvMuxFrame, EvRepl, ...options })
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
async function flushMicrotasks() {
|
|
14
|
+
for (let i = 0; i < 20; i++) await Promise.resolve()
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function advance(t, ms) {
|
|
18
|
+
t.mock.timers.tick(ms)
|
|
19
|
+
await flushMicrotasks()
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function startupFixture(t, { signal, progressMs = 12000, totalMs = 12000 } = {}) {
|
|
23
|
+
t.mock.timers.enable({ apis: ['setTimeout', 'Date'], now: 0 })
|
|
24
|
+
const stats = { writes: 0, requests: 0, disconnects: 0, settled: false, firstRequestAt: null }
|
|
25
|
+
const diagnostics = []
|
|
26
|
+
const session = fixture({
|
|
27
|
+
transport: { async writeBytes() { stats.writes += 1 }, async disconnect() { stats.disconnects += 1 } },
|
|
28
|
+
handlers: { diagnostic: event => diagnostics.push(event) },
|
|
29
|
+
})
|
|
30
|
+
session.parser.setLimits({ frameProgressTimeoutMs: progressMs, frameTotalTimeoutMs: totalMs })
|
|
31
|
+
session.rpc = async () => {
|
|
32
|
+
stats.requests += 1
|
|
33
|
+
stats.firstRequestAt = Date.now()
|
|
34
|
+
throw new Error('first request reached')
|
|
35
|
+
}
|
|
36
|
+
const result = session.negotiate({ role: 'usb', signal }).then(
|
|
37
|
+
() => { throw new Error('unexpected successful fixture negotiation') },
|
|
38
|
+
error => { stats.settled = true; return error },
|
|
39
|
+
)
|
|
40
|
+
t.after(() => { session.close(); session.parser.reset() })
|
|
41
|
+
return { session, stats, diagnostics, result }
|
|
42
|
+
}
|
|
43
|
+
|
|
13
44
|
test('Unicode Python is ASCII on the wire and length preflight includes escapes', async () => {
|
|
14
45
|
let command
|
|
15
46
|
const repl = new EvRepl({ writeEvmux() {} })
|
|
@@ -73,16 +104,239 @@ test('startup drain parses split frames without adopting old sessions', async ()
|
|
|
73
104
|
session.close()
|
|
74
105
|
})
|
|
75
106
|
|
|
76
|
-
test('
|
|
77
|
-
|
|
107
|
+
test('startup waits for a valid frame split across 299/301 ms without accepting old sessions', async t => {
|
|
108
|
+
const f = startupFixture(t)
|
|
109
|
+
const frame = makeEvMuxFrame({ channel: 'user.rpc', type: 'event', method: 'hello',
|
|
110
|
+
sessionId: 'a'.repeat(32), vmEpoch: 9,
|
|
111
|
+
payload: JSON.stringify({ evMuxVersion: 4, session: { id: 'a'.repeat(32), vmEpoch: 9 }, vmEpoch: 9 }) })
|
|
112
|
+
await advance(t, 299)
|
|
113
|
+
f.session.feedBytes(frame.subarray(0, 20))
|
|
114
|
+
await advance(t, 1)
|
|
115
|
+
assert.equal(f.stats.settled, false)
|
|
116
|
+
assert.equal(f.stats.requests, 0)
|
|
117
|
+
assert.equal(f.session.draining, true)
|
|
118
|
+
assert.equal(f.session.sessionId, null)
|
|
119
|
+
assert.equal(f.session.vmEpoch, null)
|
|
120
|
+
const snapshot = f.session.parser.getDiagnostics()
|
|
121
|
+
assert.equal(snapshot.partialFrame.startedAt, 299)
|
|
122
|
+
snapshot.partialFrame.totalDeadline = 0
|
|
123
|
+
assert.equal(f.session.parser.getDiagnostics().partialFrame.totalDeadline, 12299)
|
|
124
|
+
await advance(t, 1)
|
|
125
|
+
const staleLog = makeEvMuxFrame({ channel: 'log.idf', payload: 'old startup text' })
|
|
126
|
+
f.session.feedBytes(Buffer.concat([frame.subarray(20), staleLog]))
|
|
127
|
+
assert.match((await f.result).message, /first request reached/)
|
|
128
|
+
assert.equal(f.stats.firstRequestAt, 301)
|
|
129
|
+
assert.equal(f.stats.disconnects, 0)
|
|
130
|
+
assert.equal(f.session.sessionId, null)
|
|
131
|
+
assert.equal(f.session.vmEpoch, null)
|
|
132
|
+
assert.equal(f.session.parser.getDiagnostics().droppedFrames, 2)
|
|
133
|
+
assert.equal(f.session.parser.getDiagnostics().bufferedBytes, 0)
|
|
134
|
+
assert.equal(f.session.startupDrainWait, null)
|
|
135
|
+
assert.equal(f.session.draining, false)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
test('a frame complete before 300 ms adds no startup wait', async t => {
|
|
139
|
+
const f = startupFixture(t)
|
|
140
|
+
await advance(t, 299)
|
|
141
|
+
f.session.feedBytes(makeEvMuxFrame({ channel: 'log.idf', payload: 'complete' }))
|
|
142
|
+
await advance(t, 1)
|
|
143
|
+
assert.match((await f.result).message, /first request reached/)
|
|
144
|
+
assert.equal(f.stats.firstRequestAt, 300)
|
|
145
|
+
assert.equal(f.session.startupDrainWait, null)
|
|
146
|
+
assert.equal(f.diagnostics.some(event => event.event === 'negotiate.startup_frame_wait'), false)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
for (const [kind, makePartial] of [
|
|
150
|
+
['header', frame => frame.subarray(0, 20)],
|
|
151
|
+
['body', frame => frame.subarray(0, -1)],
|
|
152
|
+
]) {
|
|
153
|
+
test(`a truncated startup ${kind} times out before any request, even after the parser empties it`, async t => {
|
|
154
|
+
const f = startupFixture(t, { progressMs: 500, totalMs: 2000 })
|
|
155
|
+
const frame = makeEvMuxFrame({ channel: 'user.rpc', type: 'event', method: 'hello', payload: '{}' })
|
|
156
|
+
await advance(t, 299)
|
|
157
|
+
f.session.feedBytes(makePartial(frame))
|
|
158
|
+
await advance(t, 1)
|
|
159
|
+
assert.equal(f.session.draining, true)
|
|
160
|
+
assert.equal(f.stats.requests, 0)
|
|
161
|
+
await advance(t, 498)
|
|
162
|
+
assert.equal(f.stats.settled, false)
|
|
163
|
+
await advance(t, 1)
|
|
164
|
+
assert.equal((await f.result).code, 'SERIAL_STARTUP_INCOMPLETE_FRAME')
|
|
165
|
+
assert.equal(f.stats.requests, 0)
|
|
166
|
+
assert.equal(f.stats.writes, 0)
|
|
167
|
+
assert.equal(f.stats.disconnects, 1)
|
|
168
|
+
assert.equal(f.session.state, 'disconnected')
|
|
169
|
+
assert.equal(f.session.startupDrainWait, null)
|
|
170
|
+
const error = f.diagnostics.find(event => event.event === 'negotiate.startup_incomplete')
|
|
171
|
+
assert.equal(error.parser.incompleteFrameTimeouts, 1)
|
|
172
|
+
assert.equal(error.parser.bufferedBytes, 0)
|
|
173
|
+
})
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
test('successive coalesced startup frames cannot extend the initial partial-frame deadline', async t => {
|
|
177
|
+
const f = startupFixture(t, { progressMs: 1000, totalMs: 500 })
|
|
178
|
+
const frame = makeEvMuxFrame({ channel: 'log.idf', payload: 'ongoing output' })
|
|
179
|
+
await advance(t, 299)
|
|
180
|
+
f.session.feedBytes(frame.subarray(0, 20))
|
|
181
|
+
await advance(t, 1)
|
|
182
|
+
await advance(t, 1)
|
|
183
|
+
f.session.feedBytes(Buffer.concat([frame.subarray(20), frame.subarray(0, 20)]))
|
|
184
|
+
await advance(t, 299)
|
|
185
|
+
f.session.feedBytes(Buffer.concat([frame.subarray(20), frame.subarray(0, 20)]))
|
|
186
|
+
await advance(t, 198)
|
|
187
|
+
assert.equal(f.stats.settled, false)
|
|
188
|
+
assert.equal(f.session.draining, true)
|
|
189
|
+
assert.equal(f.stats.requests, 0)
|
|
190
|
+
await advance(t, 1)
|
|
191
|
+
assert.equal((await f.result).code, 'SERIAL_STARTUP_INCOMPLETE_FRAME')
|
|
192
|
+
assert.equal(Date.now(), 799)
|
|
193
|
+
assert.equal(f.stats.requests, 0)
|
|
194
|
+
assert.equal(f.stats.writes, 0)
|
|
195
|
+
assert.equal(f.stats.disconnects, 1)
|
|
196
|
+
const error = f.diagnostics.find(event => event.event === 'negotiate.startup_incomplete')
|
|
197
|
+
assert.equal(error.parser.partialFrame.startedAt, 600)
|
|
198
|
+
assert.equal(error.parser.incompleteFrameTimeouts, 0)
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
test('back-to-back adapter notifications cannot send hello between a completed tail and another prefix', async t => {
|
|
202
|
+
const f = startupFixture(t)
|
|
203
|
+
const frame = makeEvMuxFrame({ channel: 'log.idf', payload: 'adjacent chunks' })
|
|
204
|
+
await advance(t, 299)
|
|
205
|
+
f.session.feedBytes(frame.subarray(0, 20))
|
|
206
|
+
await advance(t, 1)
|
|
207
|
+
await advance(t, 1)
|
|
208
|
+
f.session.feedBytes(frame.subarray(20))
|
|
209
|
+
f.session.feedBytes(frame.subarray(0, 20))
|
|
210
|
+
await flushMicrotasks()
|
|
211
|
+
assert.equal(f.stats.requests, 0)
|
|
212
|
+
assert.equal(f.session.draining, true)
|
|
213
|
+
assert.notEqual(f.session.startupDrainWait, null)
|
|
214
|
+
await advance(t, 1)
|
|
215
|
+
f.session.feedBytes(frame.subarray(20))
|
|
216
|
+
assert.match((await f.result).message, /first request reached/)
|
|
217
|
+
assert.equal(f.stats.firstRequestAt, 302)
|
|
218
|
+
assert.equal(f.session.startupDrainWait, null)
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
test('actual startup progress refreshes the idle budget within the fixed total deadline', async t => {
|
|
222
|
+
const f = startupFixture(t, { progressMs: 100, totalMs: 2000 })
|
|
223
|
+
const frame = makeEvMuxFrame({ channel: 'log.idf', payload: 'progress' })
|
|
224
|
+
await advance(t, 299)
|
|
225
|
+
f.session.feedBytes(frame.subarray(0, 20))
|
|
226
|
+
await advance(t, 1)
|
|
227
|
+
await advance(t, 50)
|
|
228
|
+
f.session.feedBytes(frame.subarray(20, 21))
|
|
229
|
+
await advance(t, 99)
|
|
230
|
+
assert.equal(f.stats.settled, false)
|
|
231
|
+
await advance(t, 1)
|
|
232
|
+
assert.equal((await f.result).code, 'SERIAL_STARTUP_INCOMPLETE_FRAME')
|
|
233
|
+
assert.equal(Date.now(), 450)
|
|
234
|
+
assert.equal(f.stats.requests, 0)
|
|
235
|
+
assert.equal(f.stats.disconnects, 1)
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
test('a receive timeout inside the initial 300 ms cannot become an empty successful drain', async t => {
|
|
239
|
+
const f = startupFixture(t, { progressMs: 100, totalMs: 1000 })
|
|
240
|
+
await advance(t, 1)
|
|
241
|
+
f.session.feedBytes(new TextEncoder().encode('\x1eEVMUX/1 h='))
|
|
242
|
+
await advance(t, 100)
|
|
243
|
+
assert.equal(f.session.parser.hasPartialFrame(), false)
|
|
244
|
+
assert.equal(f.session.parser.getDiagnostics().incompleteFrameTimeouts, 1)
|
|
245
|
+
await advance(t, 199)
|
|
246
|
+
assert.equal((await f.result).code, 'SERIAL_STARTUP_INCOMPLETE_FRAME')
|
|
247
|
+
assert.equal(f.stats.requests, 0)
|
|
248
|
+
assert.equal(f.stats.writes, 0)
|
|
249
|
+
assert.equal(f.stats.disconnects, 1)
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
test('a complete tail received after an expired idle budget cannot bypass the queued timeout', async t => {
|
|
253
|
+
const f = startupFixture(t, { progressMs: 100, totalMs: 2000 })
|
|
254
|
+
const frame = makeEvMuxFrame({ channel: 'log.idf', payload: 'late tail' })
|
|
255
|
+
await advance(t, 299)
|
|
256
|
+
f.session.feedBytes(frame.subarray(0, 20))
|
|
257
|
+
await advance(t, 1)
|
|
258
|
+
// Model a busy event loop: the wall clock passes the idle deadline before
|
|
259
|
+
// either timer callback runs, then an already queued read delivers the tail.
|
|
260
|
+
t.mock.timers.setTime(400)
|
|
261
|
+
f.session.feedBytes(frame.subarray(20))
|
|
262
|
+
assert.equal((await f.result).code, 'SERIAL_STARTUP_INCOMPLETE_FRAME')
|
|
263
|
+
assert.equal(f.stats.requests, 0)
|
|
264
|
+
assert.equal(f.stats.writes, 0)
|
|
265
|
+
assert.equal(f.stats.disconnects, 1)
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
test('dripping bytes of one startup frame cannot extend its total budget', async t => {
|
|
269
|
+
const f = startupFixture(t, { progressMs: 150, totalMs: 400 })
|
|
270
|
+
const frame = makeEvMuxFrame({ channel: 'log.idf', payload: 'progress' })
|
|
271
|
+
await advance(t, 299)
|
|
272
|
+
f.session.feedBytes(frame.subarray(0, 20))
|
|
273
|
+
await advance(t, 1)
|
|
274
|
+
for (let i = 0; i < 3; i++) {
|
|
275
|
+
await advance(t, 100)
|
|
276
|
+
f.session.feedBytes(frame.subarray(20 + i, 21 + i))
|
|
277
|
+
}
|
|
278
|
+
await advance(t, 98)
|
|
279
|
+
assert.equal(f.stats.settled, false)
|
|
280
|
+
await advance(t, 1)
|
|
281
|
+
assert.equal((await f.result).code, 'SERIAL_STARTUP_INCOMPLETE_FRAME')
|
|
282
|
+
assert.equal(Date.now(), 699)
|
|
283
|
+
assert.equal(f.stats.requests, 0)
|
|
284
|
+
assert.equal(f.stats.writes, 0)
|
|
285
|
+
assert.equal(f.stats.disconnects, 1)
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
for (const action of ['cancel', 'close']) {
|
|
289
|
+
test(`${action} promptly clears the startup boundary waiter without sending a request`, async t => {
|
|
290
|
+
const controller = new AbortController()
|
|
291
|
+
const f = startupFixture(t, { signal: controller.signal })
|
|
292
|
+
await advance(t, 299)
|
|
293
|
+
f.session.feedBytes(new TextEncoder().encode('\x1eEVMUX/1 h='))
|
|
294
|
+
await advance(t, 1)
|
|
295
|
+
assert.notEqual(f.session.startupDrainWait, null)
|
|
296
|
+
const reason = new Error(`test ${action}`)
|
|
297
|
+
if (action === 'cancel') controller.abort(reason)
|
|
298
|
+
else f.session.close(reason)
|
|
299
|
+
assert.equal(await f.result, reason)
|
|
300
|
+
assert.equal(f.session.startupDrainWait, null)
|
|
301
|
+
assert.equal(f.session.draining, false)
|
|
302
|
+
assert.equal(f.session.negotiateController, null)
|
|
303
|
+
await advance(t, 13000)
|
|
304
|
+
assert.equal(f.stats.requests, 0)
|
|
305
|
+
assert.equal(f.stats.writes, 0)
|
|
306
|
+
assert.equal(f.diagnostics.some(event => event.event === 'negotiate.startup_incomplete'), false)
|
|
307
|
+
})
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
test('injected legacy parsers keep the conservative incomplete-frame failure without private budget methods', async () => {
|
|
311
|
+
class LegacyParser {
|
|
312
|
+
constructor(handlers) { this.parser = new EvMuxParser(handlers) }
|
|
313
|
+
feedBytes(bytes) { this.parser.feedBytes(bytes) }
|
|
314
|
+
hasPartialFrame() { return this.parser.hasPartialFrame() }
|
|
315
|
+
reset() { this.parser.reset() }
|
|
316
|
+
}
|
|
317
|
+
let session, disconnects = 0, requests = 0
|
|
78
318
|
session = fixture({
|
|
79
|
-
|
|
80
|
-
|
|
319
|
+
EvMuxParser: LegacyParser,
|
|
320
|
+
transport: { async writeBytes() {}, async disconnect() { disconnects += 1 } },
|
|
321
|
+
sleep: async () => session.feedBytes(new TextEncoder().encode('\x1eEVMUX/1 h=')),
|
|
81
322
|
})
|
|
82
|
-
|
|
83
|
-
assert.
|
|
323
|
+
session.rpc = async () => { requests += 1; throw new Error('unexpected request') }
|
|
324
|
+
await assert.rejects(session.negotiate(), error => error.code === 'SERIAL_STARTUP_INCOMPLETE_FRAME')
|
|
325
|
+
assert.equal(requests, 0)
|
|
84
326
|
assert.equal(disconnects, 1)
|
|
85
|
-
assert.equal(session.
|
|
327
|
+
assert.equal(session.startupDrainWait, null)
|
|
328
|
+
session.close()
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
test('injected parsers without optional startup diagnostics or boundary methods remain supported', async () => {
|
|
332
|
+
class MinimalParser extends EvMuxParser {
|
|
333
|
+
constructor(handlers) { super(handlers); this.hasPartialFrame = undefined; this.getDiagnostics = undefined }
|
|
334
|
+
}
|
|
335
|
+
const session = fixture({ EvMuxParser: MinimalParser, sleep: async () => {} })
|
|
336
|
+
session.rpc = async () => { throw new Error('first request reached') }
|
|
337
|
+
await assert.rejects(session.negotiate(), /first request reached/)
|
|
338
|
+
session.close()
|
|
339
|
+
session.parser.reset()
|
|
86
340
|
})
|
|
87
341
|
|
|
88
342
|
test('plain console tails do not fail drain and TCP does not wait for drain', async () => {
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import assert from 'node:assert/strict'
|
|
2
2
|
import { test } from 'node:test'
|
|
3
3
|
import { FirmwareFlashSession, firmwareSha256, validateFirmwareImage } from '../src/firmware_flash.js'
|
|
4
|
-
async function fixture() {
|
|
4
|
+
async function fixture(chipFamily = 0x3280) {
|
|
5
5
|
const bytes = new Uint8Array([0xe9, 1, 2, 3]), table = new Uint8Array([4, 5, 6]), calls = []
|
|
6
|
-
const image = { evMuxVersion: 4, chipFamily
|
|
6
|
+
const image = { evMuxVersion: 4, chipFamily, chipRevision: chipFamily === 0x3231 ? 0 : 302, offset: 0x10000, bytes: 4, partitionSize: 0x700000, sha256: await firmwareSha256(bytes), partitionTable: { offset: 0x8000, bytes: 3, sha256: await firmwareSha256(table) } }
|
|
7
7
|
const stub = { async readFlash(offset) { calls.push(['read', offset]); return offset === 0x8000 ? table : bytes }, async readRegister() { return 0x1dfbc7f8 }, async writeRegister() { throw new Error('unexpected register write') }, async flashData(_data, progress, offset, compress) { calls.push(['write', offset, compress]); progress(4, 4) } }
|
|
8
8
|
const loader = { chipFamily: image.chipFamily, chipRevision: image.chipRevision, async initialize() { calls.push(['initialize']) }, async getSecurityInfo() { return { flags: 0, flashCryptCnt: 0 } }, async runStub(skip) { calls.push(['stub', skip]); return stub }, async readRegister() { return 0x1dfbc7f8 }, async writeRegister() { throw new Error('unexpected register write') }, async hardReset() { calls.push(['reset']) }, async disconnect() { calls.push(['close']) } }
|
|
9
9
|
return { bytes, table, image, calls, loader, stub, session: new FirmwareFlashSession(loader, image) }
|
|
@@ -69,21 +69,23 @@ test('unsigned application updates refuse secure boot and flash encryption befor
|
|
|
69
69
|
})
|
|
70
70
|
|
|
71
71
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
72
|
+
for (const [name, family, address] of [['P4', 0x3280, 0x50110008], ['S31', 0x3231, 0x20700008]]) {
|
|
73
|
+
test(`${name} software download latch is cleared with a mask and verified before reset`, async () => {
|
|
74
|
+
const f = await fixture(family), registers = []; let register = 0x1dfbc7fc
|
|
75
|
+
f.stub.readRegister = async address => { registers.push(['read', address]); return register }
|
|
76
|
+
f.stub.writeRegister = async (address, value, mask) => { registers.push(['write', address, value, mask]); register &= ~mask }
|
|
77
|
+
f.loader.hardReset = async () => { assert.equal(register, 0x1dfbc7f8); f.calls.push(['reset']) }
|
|
78
|
+
await f.session.connect(f.bytes); await f.session.flash(f.bytes)
|
|
79
|
+
assert.equal(register, 0x1dfbc7f8)
|
|
80
|
+
assert.deepEqual(registers, [['read', address], ['write', address, 0, 4], ['read', address]])
|
|
81
|
+
assert.ok(f.calls.some(([operation]) => operation === 'reset'))
|
|
82
|
+
})
|
|
83
|
+
test(`${name} latch clear failure does not reset and retains manual recovery guidance`, async () => {
|
|
84
|
+
const f = await fixture(family), messages = []
|
|
85
|
+
f.stub.readRegister = async () => 0x1dfbc7fc
|
|
86
|
+
f.stub.writeRegister = async () => {}
|
|
87
|
+
await f.session.connect(f.bytes); await f.session.flash(f.bytes, { onPhase: message => messages.push(message) })
|
|
88
|
+
assert.ok(!f.calls.some(([operation]) => operation === 'reset'))
|
|
89
|
+
assert.match(messages.at(-1), /verified.*RESET/)
|
|
90
|
+
})
|
|
91
|
+
}
|