@esp-vision/device-core 0.3.0 → 0.4.1

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.
@@ -3,6 +3,8 @@
3
3
  */
4
4
  import { loadOfficialFirmware } from './firmware_feed.js'
5
5
  import { FirmwareFlashSession, validateFirmwareImage, firmwareMode } from './firmware_flash.js'
6
+ import { validateFirmwareRelease as release } from './firmware_release.js'
7
+ import { downloadBytes, MAX_FIRMWARE_BYTES } from './firmware_io.js'
6
8
 
7
9
  function record(value, keys, name) {
8
10
  if (!value || typeof value !== 'object' || Array.isArray(value)) throw new TypeError(`${name} must be an object`)
@@ -16,38 +18,7 @@ function identity(value) {
16
18
  if (!value || typeof value.board !== 'string' || !value.board || typeof value.firmwareVersion !== 'string' || !value.firmwareVersion) throw new TypeError('An inspected identity with board and firmwareVersion is required')
17
19
  return structuredClone(value)
18
20
  }
19
- function release(value, mode = 'update') {
20
- if (!value || typeof value.board !== 'string' || !value.board || typeof value.version !== 'string' || !value.version) throw new TypeError('Release requires board and version')
21
- const image = value.image
22
- const revisionValid = image?.chipRevision !== undefined
23
- ? Number.isSafeInteger(image.chipRevision) && image.chipRevision >= 0
24
- : Number.isSafeInteger(image?.chipRevisionMin) && image.chipRevisionMin >= 0
25
- && Number.isSafeInteger(image?.chipRevisionMax) && image.chipRevisionMax >= image.chipRevisionMin
26
- if (!image || image.evMuxVersion !== 4 || !Number.isSafeInteger(image.chipFamily)
27
- || image.chipFamily < 0 || !revisionValid || !Number.isSafeInteger(image.offset) || image.offset < 0
28
- || !Number.isSafeInteger(image.bytes) || image.bytes <= 0 || !Number.isSafeInteger(image.partitionSize) || image.bytes > image.partitionSize
29
- || !/^[a-f0-9]{64}$/.test(image.sha256 ?? '') || !Number.isSafeInteger(image.partitionTable?.offset) || image.partitionTable.offset < 0
30
- || !Number.isSafeInteger(image.partitionTable?.bytes) || image.partitionTable.bytes <= 0
31
- || !/^[a-f0-9]{64}$/.test(image.partitionTable?.sha256 ?? '')) throw new TypeError('Release requires complete EV-MUX v4 application/chip/partition/SHA-256 metadata')
32
- if (mode === 'install') {
33
- const install = value.install
34
- if (!install || install.offset !== 0 || !Number.isSafeInteger(install.bytes) || install.bytes <= 0
35
- || !Number.isSafeInteger(install.flashSize) || install.flashSize < install.bytes || install.flashSize > 2 ** 30
36
- || !Number.isInteger(Math.log2(install.flashSize)) || install.flashSize < 2 ** 18
37
- || !/^[a-f0-9]{64}$/.test(install.sha256 ?? '') || !install.bootloader
38
- || !Number.isSafeInteger(install.chipRevisionMin) || !Number.isSafeInteger(install.chipRevisionMax)
39
- || install.chipRevisionMin < (image.chipRevision ?? image.chipRevisionMin) || install.chipRevisionMax > (image.chipRevision ?? image.chipRevisionMax)
40
- || install.chipRevisionMin < 0 || install.chipRevisionMax < install.chipRevisionMin) throw Object.assign(new TypeError('Release lacks qualified complete-install metadata'), { code: 'FIRMWARE_INSTALL_UNAVAILABLE' })
41
- let end = 0
42
- for (const part of [install.bootloader, image.partitionTable, image]) {
43
- if (!Number.isSafeInteger(part.offset) || !Number.isSafeInteger(part.bytes) || part.bytes <= 0
44
- || part.offset < end || part.offset + part.bytes > install.bytes
45
- || !/^[a-f0-9]{64}$/.test(part.sha256 ?? '')) throw new TypeError('Invalid complete-install segment bounds or hashes')
46
- end = part.offset + part.bytes
47
- }
48
- }
49
- return structuredClone(value)
50
- }
21
+
51
22
  async function download(url, fetcher, signal) {
52
23
  if (typeof url !== 'string' || new URL(url).protocol !== 'https:') throw new TypeError('Firmware URLs must use HTTPS')
53
24
  if (typeof fetcher !== 'function') throw new TypeError('Provide fetch or an in-memory catalog/image')
@@ -64,7 +35,7 @@ export async function checkFirmware(request = {}, connectedIdentity) {
64
35
  if (request.version !== undefined && (typeof request.version !== 'string' || !request.version.trim())) throw new TypeError('version must be a non-empty firmware version string')
65
36
  const mode = firmwareMode(request.mode)
66
37
  const current = identity(connectedIdentity ?? request.identity)
67
- const catalog = request.catalog === undefined ? await loadOfficialFirmware({ board: current.board, fetch: fetcher, signal }) : typeof request.catalog === 'string' ? await (await download(request.catalog, fetcher, signal)).json() : structuredClone(request.catalog)
38
+ const catalog = request.catalog === undefined ? await loadOfficialFirmware({ board: current.board, version: request.version, fetch: fetcher, signal }) : typeof request.catalog === 'string' ? await (await download(request.catalog, fetcher, signal)).json() : structuredClone(request.catalog)
68
39
  abort(signal)
69
40
  if (catalog?.schemaVersion !== 1 || !Array.isArray(catalog.releases)) throw new TypeError('Catalog requires schemaVersion 1 and releases ordered newest first')
70
41
  // Feed ordering is authoritative: firmware versions may be calendar/git labels.
@@ -88,15 +59,25 @@ export async function updateFirmware(request, connectedIdentity, disconnect) {
88
59
  if (typeof openLoader !== 'function' || typeof onProgress !== 'function' || typeof onPhase !== 'function' || (reset !== undefined && typeof reset !== 'function')) throw new TypeError('Firmware update requires openLoader and valid callbacks')
89
60
  const mode = firmwareMode(request.mode)
90
61
  const target = release(request.release, mode)
91
- const image = mode === 'install' ? {
62
+ const installation = target.install ? {
92
63
  ...target.image, offset: target.install.offset, bytes: target.install.bytes,
93
64
  flashSize: target.install.flashSize, sha256: target.install.sha256, url: target.install.url,
94
65
  bootloader: target.install.bootloader, application: target.image,
95
66
  chipRevision: undefined, chipRevisionMin: target.install.chipRevisionMin, chipRevisionMax: target.install.chipRevisionMax,
96
- } : target.image
67
+ } : null
68
+ const image = mode === 'install' ? installation : target.image
97
69
  const board = connectedIdentity?.board ?? request.board
98
70
  if (!board || board !== target.board || (request.board !== undefined && request.board !== board)) throw new TypeError('Firmware release does not match the connected or explicitly selected board')
99
- const bytes = request.data === undefined ? new Uint8Array(await (await download(image.url, fetcher, signal)).arrayBuffer()) : request.data instanceof Uint8Array ? request.data.slice() : request.data
71
+ const source = mode === 'update' ? image.source : undefined
72
+ const descriptor = source ?? image
73
+ let bytes = request.data === undefined
74
+ ? await downloadBytes(descriptor.url, { fetch: fetcher, signal, maxBytes: MAX_FIRMWARE_BYTES, expectedBytes: descriptor.bytes, immutable: target.image.source !== undefined })
75
+ : request.data instanceof Uint8Array ? request.data.slice() : request.data
76
+ if (source) {
77
+ if (!(bytes instanceof Uint8Array) || bytes.byteLength !== source.bytes) throw new Error('Complete BIN source size does not match the approved build')
78
+ await validateFirmwareImage(installation, bytes, { mode: 'install' })
79
+ bytes = bytes.slice(source.offset, source.offset + image.bytes)
80
+ }
100
81
  await validateFirmwareImage(image, bytes, { mode })
101
82
  abort(signal)
102
83
  // Validate everything before releasing EV-MUX or acquiring a download port.
@@ -0,0 +1,151 @@
1
+ // SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import assert from 'node:assert/strict'
4
+ import { mkdtemp, readFile, writeFile, rm } from 'node:fs/promises'
5
+ import { tmpdir } from 'node:os'
6
+ import { join, dirname } from 'node:path'
7
+ import { fileURLToPath, pathToFileURL } from 'node:url'
8
+ import vm from 'node:vm'
9
+ import { webcrypto } from 'node:crypto'
10
+ import { build } from 'esbuild'
11
+
12
+ const root = dirname(dirname(fileURLToPath(import.meta.url)))
13
+ const temporary = await mkdtemp(join(tmpdir(), 'esp-vision-browser-'))
14
+ try {
15
+ // Bundle the actual pinned dependency and its dynamic JSON stubs for browsers.
16
+ const result = await build({
17
+ stdin: { contents: `export { createDevice } from './src/index.js'; export { openBrowserRom } from './src/browser.js'; export { loadReleaseManifest } from './src/firmware.js'; export { ESPLoader as TestLoader } from 'tasmota-webserial-esptool'; export { browserRomCompatibility as testCompatibility } from './src/browser_rom_compat.js';`, resolveDir: root },
18
+ bundle: true, platform: 'browser', format: 'iife', globalName: 'DeviceCore',
19
+ write: false, metafile: true,
20
+ })
21
+ const code = result.outputFiles[0].text
22
+ assert.ok(Object.keys(result.metafile.inputs).some(path => path.includes('tasmota-webserial-esptool/dist/esp_loader.js')))
23
+ assert.ok(Object.keys(result.metafile.inputs).some(path => path.includes('stubs/esp32p4.json')))
24
+ assert.ok(Object.keys(result.metafile.inputs).some(path => path.includes('stubs/esp32s31.json')))
25
+ assert.ok(!Object.keys(result.metafile.inputs).some(path => /src\/(node|cli)/.test(path)))
26
+ const context = vm.createContext({ EventTarget, URL, TextDecoder, TextEncoder, Uint8Array, AbortController, setTimeout, clearTimeout, console, crypto: webcrypto, structuredClone })
27
+ vm.runInContext(code, context)
28
+ let opens = 0, closes = 0
29
+ const adapter = context.DeviceCore.openBrowserRom({ port: {
30
+ async open() { opens++; throw new Error('real dependency reached SerialPort.open') },
31
+ async close() { closes++ },
32
+ } })
33
+ assert.equal(opens, 0, 'import and construction must not open/reset the port')
34
+ await assert.rejects(adapter.initialize(), /real dependency reached SerialPort.open/)
35
+ assert.equal(opens, 1)
36
+ // Run upstream's actual SLIP parser in both CDC and UART modes. A blank board
37
+ // may emit a boot log before the stub greeting; invalid escapes must still fail.
38
+ for (const cdc of [false, true]) {
39
+ const raw = new context.DeviceCore.TestLoader({}, { log() {}, error() {}, debug() {} })
40
+ context.DeviceCore.testCompatibility(raw)
41
+ raw._isCDCDevice = cdc
42
+ raw.__inputBuffer = []
43
+ raw._inputBuffer.push(71, 97, 114, 0xc0, 79, 72, 65, 73, 0xc0)
44
+ assert.deepEqual(Array.from(await raw.readPacket(100)), [79, 72, 65, 73])
45
+ raw._clearInputBuffer()
46
+ raw._inputBuffer.push(0xc0, 0xdb, 0x47, 0xc0)
47
+ await assert.rejects(raw.readPacket(100), /Invalid SLIP escape/)
48
+ }
49
+ const prototype = context.DeviceCore.TestLoader.prototype
50
+ const originals = Object.fromEntries(['initialize', 'disconnect', 'getSecurityInfo', 'runStub', 'hardReset'].map(key => [key, prototype[key]]))
51
+ let initializeCalls = 0, disconnectCalls = 0
52
+ prototype.disconnect = async function () { disconnectCalls++; await this.port.close() }
53
+ const fakePort = () => ({
54
+ async open() { this.readable = {}; this.writable = {} },
55
+ async close() { this.readable = null; this.writable = null },
56
+ })
57
+ try {
58
+ prototype.initialize = async () => { initializeCalls++ }
59
+ const controller = new AbortController()
60
+ const loader = context.DeviceCore.openBrowserRom({ port: fakePort(), signal: controller.signal })
61
+ const first = loader.initialize()
62
+ assert.equal(loader.initialize(), first, 'initialize must share one ROM connection')
63
+ await first
64
+ controller.abort()
65
+ assert.equal(disconnectCalls, 0, 'abort after initialization must not interrupt flash/readback')
66
+ await loader.disconnect(); await loader.disconnect()
67
+ assert.equal(disconnectCalls, 1)
68
+ assert.equal(initializeCalls, 1)
69
+ prototype.initialize = async () => { throw new Error('sync failed') }
70
+ await assert.rejects(context.DeviceCore.openBrowserRom({ port: fakePort() }).initialize(), /sync failed/)
71
+ assert.equal(disconnectCalls, 2, 'failed ROM sync must release the acquired port')
72
+ let finishOpen
73
+ const waitingPort = fakePort()
74
+ const open = waitingPort.open
75
+ waitingPort.open = async function () {
76
+ await new Promise(resolve => { finishOpen = resolve })
77
+ await open.call(this)
78
+ }
79
+ const cancelled = new AbortController()
80
+ const late = context.DeviceCore.openBrowserRom({ port: waitingPort, signal: cancelled.signal })
81
+ const starting = late.initialize()
82
+ while (!finishOpen) await new Promise(resolve => setTimeout(resolve, 1))
83
+ cancelled.abort()
84
+ await assert.rejects(starting, { name: 'AbortError' })
85
+ finishOpen()
86
+ await new Promise(resolve => setTimeout(resolve, 5))
87
+ assert.equal(disconnectCalls, 3, 'an open completing after cancellation must close')
88
+ assert.equal(waitingPort.readable, null)
89
+ const data = new Uint8Array([0xe9, 1, 2, 3]), table = new Uint8Array([4, 5])
90
+ const hash = async bytes => Array.from(new Uint8Array(await webcrypto.subtle.digest('SHA-256', bytes)), byte => byte.toString(16).padStart(2, '0')).join('')
91
+ const release = { board: 'BOARD', version: 'browser-test', image: {
92
+ evMuxVersion: 4, chipFamily: 0x3280, chipRevision: 302,
93
+ offset: 65536, bytes: data.byteLength, partitionSize: 4096,
94
+ sha256: await hash(data), partitionTable: { offset: 32768, bytes: table.byteLength, sha256: await hash(table) },
95
+ } }
96
+ let stubs = 0, writes = 0, resets = 0
97
+ prototype.initialize = async function () { this.chipFamily = 0x3280; this.chipRevision = 302 }
98
+ prototype.getSecurityInfo = async () => ({ flags: 1, flashCryptCnt: 0 })
99
+ prototype.runStub = async () => { stubs++; throw new Error('must not launch') }
100
+ const device = context.DeviceCore.createDevice()
101
+ const request = {
102
+ release, board: 'BOARD', data,
103
+ openLoader: ({ signal }) => context.DeviceCore.openBrowserRom({ port: fakePort(), signal }),
104
+ }
105
+ await assert.rejects(device.firmware.update(request), /Secure boot/)
106
+ assert.equal(stubs, 0, 'ROM security must be checked before the browser launches a stub')
107
+ await assert.rejects(device.firmware.update({ ...request, data: new Uint8Array(4) }), /SHA-256/)
108
+ assert.equal(stubs, 0)
109
+ prototype.getSecurityInfo = async () => ({ flags: 0, flashCryptCnt: 0 })
110
+ const writing = new AbortController()
111
+ prototype.runStub = async () => {
112
+ stubs++
113
+ return {
114
+ IS_STUB: true,
115
+ async flashData() { writes++; writing.abort() },
116
+ async readFlash(offset) { return offset === 32768 ? table : data },
117
+ async readRegister() { return 0 },
118
+ }
119
+ }
120
+ prototype.hardReset = async () => { resets++ }
121
+ const beforeUpdateCloses = disconnectCalls
122
+ const updated = await device.firmware.update({ ...request, signal: writing.signal })
123
+ assert.equal(updated.verified, true)
124
+ assert.equal(updated.needsReset, false)
125
+ assert.equal(stubs, 1); assert.equal(writes, 1); assert.equal(resets, 1)
126
+ assert.equal(disconnectCalls, beforeUpdateCloses + 1, 'write-time abort must preserve complete readback, reset and one cleanup')
127
+ const manifest = { schemaVersion: 1, version: 'browser-test', sourceCommit: 'a'.repeat(40), pipelineId: 1, layout: 'legacy',
128
+ releases: [{ ...release, image: { ...release.image, url: 'https://dl.espressif.com/AE/esp-vision/bin/app.bin' } }],
129
+ website: { schemaVersion: '1.0.0', release: 'browser-test', releases: ['browser-test'], binaryNameTemplate: '{board}-{tag}.bin',
130
+ boards: [{ board: 'BOARD', name: 'Board', slug: 'board', flashable: true }], models: [] }, mcp: null }
131
+ const rawManifest = new TextEncoder().encode(JSON.stringify(manifest)), caches = []
132
+ const snapshot = await context.DeviceCore.loadReleaseManifest({ url: 'https://dl.espressif.com/AE/esp-vision/releases/browser-test/manifest.json', sha256: await hash(rawManifest),
133
+ async fetch(_url, options) {
134
+ caches.push(options.cache)
135
+ if (caches.length === 1) throw new TypeError('Cached response CORS failure from the host realm')
136
+ return new Response(rawManifest)
137
+ } })
138
+ assert.equal(snapshot.version, 'browser-test')
139
+ assert.deepEqual(caches, ['default', 'no-store'], 'real browser bundle must recover a fetch TypeError from host-provided fetch')
140
+
141
+ } finally { Object.assign(prototype, originals) }
142
+ // Node import must not eagerly resolve tasmota's browser-only, extensionless ESM.
143
+ const { openBrowserRom } = await import(pathToFileURL(join(root, 'src/browser.js')))
144
+ assert.equal(typeof openBrowserRom, 'function')
145
+ const dependency = JSON.parse(await readFile(join(root, 'node_modules/tasmota-webserial-esptool/package.json'), 'utf8'))
146
+ assert.equal(dependency.version, '9.4.2')
147
+ const stubsLicense = await readFile(join(root, 'node_modules/tasmota-webserial-esptool/src/stubs/README.md'), 'utf8')
148
+ assert.match(stubsLicense, /dual licensed.*Apache License Version 2.0 or the MIT license/s)
149
+ await writeFile(join(temporary, 'browser.js'), code)
150
+ console.log(JSON.stringify({ browserBundle: true, dependency: dependency.version, bytes: code.length, passiveImports: true, p4AndS31Stubs: true, cleanupAndCancellation: true, coreSafetyChecks: true }))
151
+ } finally { await rm(temporary, { recursive: true, force: true }) }
@@ -37,8 +37,11 @@ try {
37
37
  const api = await import('@esp-vision/device-core');
38
38
  if (Object.keys(api).sort().join(',') !== 'createDevice') throw new Error('Root must expose only the application API');
39
39
  const { DeviceSession } = await import('@esp-vision/device-core/session');
40
- const { FirmwareFlashSession } = await import('@esp-vision/device-core/firmware');
40
+ const { FirmwareFlashSession, loadOfficialFirmware, loadReleaseManifest } = await import('@esp-vision/device-core/firmware');
41
+ if (typeof loadOfficialFirmware !== 'function' || typeof loadReleaseManifest !== 'function') throw new Error('Missing public release manifest readers');
41
42
  const { tcpTransport, serialTransport, openNodeRom } = await import('@esp-vision/device-core/node');
43
+ const { openBrowserRom } = await import('@esp-vision/device-core/browser');
44
+ if (typeof openBrowserRom !== 'function' || typeof openBrowserRom({ port: { open() { throw new Error('Not opened'); }, close() {} } }).initialize !== 'function') throw new Error('Missing passive browser ROM adapter');
42
45
  if (typeof tcpTransport !== 'function' || typeof serialTransport !== 'function') throw new Error('Missing public Node transports');
43
46
  const { createNodeDiscovery } = await import('@esp-vision/device-core/discovery/node');
44
47
  const { EventEmitter } = await import('node:events');
@@ -2,6 +2,7 @@
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 { EV_REPL_INPUT_MAX_BYTES, evReplExecInputBytes } from '../src/evrepl.js'
5
6
 
6
7
  export const DEVICE_CHECKS = Object.freeze(['identity', 'memory', 'camera', 'framesizes', 'pixformats', 'orientation', 'ipa_stats', 'snapshot_rate', 'preview', 'image_ops', 'display', 'display_stream'])
7
8
  const MARKER = '__EVDEVICECHECK__'
@@ -39,11 +40,14 @@ function pause(milliseconds, signal) {
39
40
  }
40
41
 
41
42
  export async function runDeviceChecks(device, options, { signal, nonce, report = value => console.log(JSON.stringify(value)) } = {}) {
42
- const results = [], fixturePath = `/evhil_${nonce}.py`, namespace = `_evhil_${nonce}`
43
- let ownsFixture = false, loaded = false, namespaceStarted = false, loadError = null
43
+ const results = [], namespace = `_evhil_${nonce}`
44
+ let loaded = false, namespaceStarted = false, loadError = null, inputLimit = EV_REPL_INPUT_MAX_BYTES
44
45
  const parameters = { frames: options.frames, framesize: options.framesize, pixformat: options.pixformat, displayHold: options.displayHold, screenCapture: Boolean(options.screenOutput) }
45
46
  const timeoutFor = name => 1000 * (name === 'display' ? 30 + options.displayHold * 3 : name === 'ipa_stats' ? options.frameTimeout + 40 : options.frameTimeout + Math.max(30, options.frames * (name === 'display_stream' ? 2 : 1)))
46
47
  const execute = async (source, timeout = 10000, operationSignal = signal) => {
48
+ // Larger python.execute() requests use temporary files in Device Core.
49
+ // Keep fixtures in RAM by respecting the actual encoded REPL-line budget.
50
+ if (evReplExecInputBytes(source) > inputLimit) throw failure('REPL_INPUT_LIMIT', 'Hardware fixture command exceeds the device REPL input limit')
47
51
  const result = await device.python.execute({ source, timeout, maxOutputChars: 16384, signal: operationSignal })
48
52
  if (result.ok === false || result.outputTruncated) throw failure('PYTHON_CHECK', 'Device Python check did not complete')
49
53
  return result.output
@@ -52,16 +56,25 @@ export async function runDeviceChecks(device, options, { signal, nonce, report =
52
56
  if (loaded) return
53
57
  if (loadError) throw loadError
54
58
  try {
55
- const entries = await device.files.list({ path: '/', signal })
56
- if (entries.some(entry => [fixturePath, `${fixturePath}.tmp`].includes(entry.path))) throw failure('TEST_PATH_EXISTS', 'Fixture path already exists')
59
+ const info = await device.inspect({ fields: ['capabilities'], signal })
60
+ const advertised = info.capabilities?.limits?.replInputMax
61
+ if (Number.isSafeInteger(advertised) && advertised > 0) inputLimit = Math.min(inputLimit, advertised)
57
62
  const source = await readFile(new URL('./fixtures/device-checks.py', import.meta.url), 'utf8')
58
- ownsFixture = true
59
- await device.files.write({ path: fixturePath, data: source, overwrite: false, signal })
60
63
  namespaceStarted = true
61
- const loader = `with open(${JSON.stringify(fixturePath)}) as _fixture:\n exec(_fixture.read(),globals())`
62
- await execute(`${namespace}={};exec(${JSON.stringify(loader)},${namespace})`)
64
+ await execute(`${namespace}={'_source':[]}`)
65
+ const append = chunk => `${namespace}['_source'].append(${JSON.stringify(chunk)})`
66
+ let chunk = ''
67
+ for (const character of source) {
68
+ if (evReplExecInputBytes(append(chunk + character)) > inputLimit) {
69
+ if (!chunk) throw failure('REPL_INPUT_LIMIT', 'Device REPL input limit cannot load hardware fixtures')
70
+ await execute(append(chunk))
71
+ chunk = ''
72
+ }
73
+ chunk += character
74
+ }
75
+ if (chunk) await execute(append(chunk))
76
+ await execute(`exec(''.join(${namespace}['_source']),${namespace});del ${namespace}['_source']`)
63
77
  loaded = true
64
- await device.files.remove({ path: fixturePath, signal })
65
78
  } catch (error) { loadError = error; throw error }
66
79
  }
67
80
  const invoke = async name => {
@@ -83,8 +96,12 @@ export async function runDeviceChecks(device, options, { signal, nonce, report =
83
96
  let data
84
97
  if (name === 'identity') {
85
98
  const info = await device.inspect({ fields: ['identity', 'capabilities', 'runtime'], signal })
99
+ if (info.capabilities?.features?.includes('storage.writer.lease')) {
100
+ info.storage = (await device.inspect({ fields: ['storage'], signal })).storage
101
+ }
86
102
  if (!info.identity?.board || !info.identity.firmwareVersion || info.capabilities?.protocol?.evMuxVersion !== 4 || info.runtime?.vmReady !== true) throw failure('IDENTITY_INVALID', 'Expected a ready EV-MUX v4 firmware identity')
87
- data = { identity: info.identity, features: info.capabilities.features, vmReady: info.runtime.vmReady }
103
+ data = { identity: info.identity, features: info.capabilities.features, vmReady: info.runtime.vmReady,
104
+ storage: info.storage && Object.fromEntries(Object.entries(info.storage).map(([volume, state]) => [volume, { present: state.present, mounted: state.mounted, writable: state.writable, reason: state.reason }])) }
88
105
  } else if (name === 'camera') {
89
106
  const image = await device.capture({ source: 'camera', format: 'jpeg', quality: options.quality, timeout: options.frameTimeout * 1000, signal })
90
107
  data = await saveJpeg(image, options.output)
@@ -116,14 +133,8 @@ export async function runDeviceChecks(device, options, { signal, nonce, report =
116
133
  result = { name, status: 'PASS', data }
117
134
  } catch (error) {
118
135
  cancelled(signal)
119
- const skip = unsupported(error) && !(name === 'display' && options.screenOutput)
136
+ const skip = unsupported(error) && !options.requireAll && !(name === 'display' && options.screenOutput)
120
137
  result = { name, status: skip ? 'SKIP' : 'FAIL', error: { code: error.code ?? 'DEVICE_CHECK_FAILED', message: String(error.message ?? 'Device check failed').slice(0, 240) } }
121
- if (error.code === 'WRITE_FAILED') {
122
- try {
123
- const status = await device.inspect({ fields: ['storage'], timeout: 3000, signal })
124
- result.storage = Object.fromEntries(Object.entries(status.storage).map(([volume, state]) => [volume, { present: state.present, mounted: state.mounted, status: state.status, writable: state.writable, reason: state.reason }]))
125
- } catch { /* Preserve the upload failure. */ }
126
- }
127
138
  } finally {
128
139
  if (name === 'display' && loaded) {
129
140
  try { await execute(`${namespace}['close_display']()`, 5000, AbortSignal.timeout(6000)) }
@@ -140,13 +151,7 @@ export async function runDeviceChecks(device, options, { signal, nonce, report =
140
151
  try { await execute(`if ${JSON.stringify(namespace)} in globals():\n ${namespace}.get('close_display',lambda:None)()\n del ${namespace}`, 5000, cleanupSignal) }
141
152
  catch (error) { cleanupError = error }
142
153
  }
143
- if (ownsFixture) {
144
- try {
145
- const entries = await device.files.list({ path: '/', signal: cleanupSignal })
146
- for (const entry of entries) if ([fixturePath, `${fixturePath}.tmp`].includes(entry.path)) await device.files.remove({ path: entry.path, signal: cleanupSignal })
147
- } catch (error) { cleanupError ??= error }
148
- }
149
- if (cleanupError) record({ name: 'fixture-cleanup', status: 'FAIL', error: { code: 'FIXTURE_CLEANUP_FAILED', message: 'Could not clean up the hardware fixture', path: fixturePath } })
154
+ if (cleanupError) record({ name: 'fixture-cleanup', status: 'FAIL', error: { code: 'FIXTURE_CLEANUP_FAILED', message: 'Could not release the hardware fixture namespace' } })
150
155
  }
151
156
  const summary = { passed: results.filter(result => result.status === 'PASS').length, failed: results.filter(result => result.status === 'FAIL').length, skipped: results.filter(result => result.status === 'SKIP').length }
152
157
  report({ check: 'summary', ...summary })
@@ -6,6 +6,11 @@ All transport, ownership and cleanup are supplied by the public JS SDK.
6
6
  """
7
7
 
8
8
 
9
+ def is_unsupported(exc):
10
+ message = str(exc).lower()
11
+ return any(token in message for token in ('esp_err_not_supported', 'esp_err_not_found', 'no module named', 'enodev'))
12
+
13
+
9
14
  def framesizes(opts):
10
15
  import sensor
11
16
  sensor.reset()
@@ -18,8 +23,10 @@ def framesizes(opts):
18
23
  img = sensor.snapshot()
19
24
  results.append(dict(name=name, supported=True, width=img.width(), height=img.height(),
20
25
  reportedWidth=sensor.width(), reportedHeight=sensor.height()))
21
- except Exception:
22
- results.append(dict(name=name, supported=False))
26
+ except Exception as exc:
27
+ if not is_unsupported(exc):
28
+ raise
29
+ results.append(dict(name=name, supported=False, reason=str(exc)))
23
30
  working = [item for item in results if item['supported']]
24
31
  assert working, 'no framesize produced a frame'
25
32
  for item in working:
@@ -39,8 +46,10 @@ def pixformats(opts):
39
46
  img = sensor.snapshot()
40
47
  results.append(dict(name=name, supported=True, width=img.width(), height=img.height(),
41
48
  actualBytes=img.size(), expectedBytes=img.width() * img.height() * depth))
42
- except Exception:
43
- results.append(dict(name=name, supported=False))
49
+ except Exception as exc:
50
+ if not is_unsupported(exc):
51
+ raise
52
+ results.append(dict(name=name, supported=False, reason=str(exc)))
44
53
  working = [item for item in results if item['supported']]
45
54
  assert working, 'no pixel format produced a frame'
46
55
  for item in working:
@@ -311,7 +320,6 @@ def run_check(name, opts):
311
320
  result = dict(name=name, status='PASS', data=data)
312
321
  except Exception as exc:
313
322
  message = str(exc)
314
- unsupported = ('ESP_ERR_NOT_SUPPORTED', 'ESP_ERR_NOT_FOUND', 'no module named', 'ENODEV')
315
- status = 'SKIP' if any(token.lower() in message.lower() for token in unsupported) else 'FAIL'
323
+ status = 'SKIP' if is_unsupported(exc) else 'FAIL'
316
324
  result = dict(name=name, status=status, error=dict(type=type(exc).__name__, message=message))
317
325
  print('__EVDEVICECHECK__' + json.dumps(result))
@@ -7,7 +7,7 @@ export { tcpTransport, serialTransport } from '../src/node_transport.js'
7
7
  import { createDevice } from '../src/index.js'
8
8
  import { DEVICE_CHECKS, runDeviceChecks, saveJpeg } from './device-checks.mjs'
9
9
 
10
- export const usage = 'Usage: npm run test:hardware -- (--tcp HOST[:PORT] | --serial PATH) [--role usj|usb|uart] [--python PYTHON] [--write] [--capture] [--checks all|CSV] [--frames N] [--framesize QVGA] [--pixformat RGB565] [--quality 60] [--frame-timeout SECONDS] [--display-hold SECONDS] [--output CAMERA.jpg] [--screen-output LCD.jpg] [--timeout MILLISECONDS]'
10
+ export const usage = 'Usage: npm run test:hardware -- (--tcp HOST[:PORT] | --serial PATH) [--role usj|usb|uart] [--python PYTHON] [--expect-board BOARD] [--expect-firmware-version VERSION] [--write] [--capture] [--checks all|CSV] [--require-all] [--frames N] [--framesize QVGA] [--pixformat RGB565] [--quality 60] [--frame-timeout SECONDS] [--display-hold SECONDS] [--output CAMERA.jpg] [--screen-output LCD.jpg] [--timeout MILLISECONDS]'
11
11
  const fail = (code, message) => Object.assign(new Error(message), { code })
12
12
 
13
13
  export function parseArgs(args) {
@@ -16,8 +16,8 @@ export function parseArgs(args) {
16
16
  for (let index = 0; index < args.length; index++) {
17
17
  const option = args[index]
18
18
  if (option === '--help') return { help: true }
19
- if (option === '--write' || option === '--capture') { options[option.slice(2)] = true; continue }
20
- if (!['--tcp', '--serial', '--role', '--python', '--timeout', '--checks', '--screen-output', '--output', '--frames', '--display-hold', '--framesize', '--pixformat', '--quality', '--frame-timeout'].includes(option)) throw fail('ARGUMENT', 'Unknown argument')
19
+ if (['--write', '--capture', '--require-all'].includes(option)) { options[option === '--require-all' ? 'requireAll' : option.slice(2)] = true; continue }
20
+ if (!['--tcp', '--serial', '--role', '--python', '--timeout', '--checks', '--screen-output', '--output', '--frames', '--display-hold', '--framesize', '--pixformat', '--quality', '--frame-timeout', '--expect-board', '--expect-firmware-version'].includes(option)) throw fail('ARGUMENT', 'Unknown argument')
21
21
  const value = args[++index]
22
22
  if (!value || value.startsWith('--')) throw fail('ARGUMENT', 'Missing argument value')
23
23
  const key = option.slice(2).replace(/-([a-z])/g, (_match, letter) => letter.toUpperCase())
@@ -75,13 +75,20 @@ export async function checkHardware(options, { device = createDevice({ transport
75
75
  await device.connect({ role: options.role, timeout: Math.min(options.timeout, 30000), signal: controller.signal })
76
76
  cancelled()
77
77
  const status = await device.inspect({ fields: ['identity', 'capabilities', 'runtime'], signal: controller.signal })
78
+ if (options.expectBoard && status.identity?.board !== options.expectBoard) throw fail('BOARD_MISMATCH', 'Connected board does not match the requested board')
79
+ if (options.expectFirmwareVersion && status.identity?.firmwareVersion !== options.expectFirmwareVersion) throw fail('FIRMWARE_MISMATCH', 'Running firmware does not match the selected build artifact')
78
80
  // Only selected public identity fields are logged; never network credentials or Python output.
79
- report({ check: 'inspect', ok: true, identity: { deviceId: status.identity?.deviceId ?? null, board: status.identity?.board ?? null, hostname: status.identity?.hostname ?? null } })
81
+ report({ check: 'inspect', ok: true, identity: { deviceId: status.identity?.deviceId ?? null, board: status.identity?.board ?? null, hostname: status.identity?.hostname ?? null, firmwareVersion: status.identity?.firmwareVersion ?? null } })
80
82
  const marker = `EV_HARDWARE_${nonce}`
81
83
  const execution = await device.python.execute({ source: `print('${marker}')`, timeout: 10000, maxOutputChars: 1024, signal: controller.signal })
82
84
  if (!execution.ok || !execution.output.split(/\r?\n/).some(line => line.trim() === marker)) throw fail('PYTHON_CHECK', 'Python marker was not received')
83
85
  report({ check: 'python', ok: true })
84
86
  if (options.write) {
87
+ const storage = await device.inspect({ fields: ['storage'], signal: controller.signal })
88
+ const flash = storage.storage?.flash
89
+ const ready = flash?.present === true && flash?.mounted === true && flash?.writable === true
90
+ report({ check: 'storage', ok: ready, flash: { present: flash?.present ?? null, mounted: flash?.mounted ?? null, writable: flash?.writable ?? null, reason: flash?.reason ?? null } })
91
+ if (!ready) throw fail('STORAGE_UNAVAILABLE', 'File acceptance requires a mounted writable Flash filesystem; no formatting was attempted')
85
92
  const entries = await device.files.list({ path: '/', signal: controller.signal })
86
93
  if (entries.some(entry => entry.path === path || entry.path === `${path}.tmp`)) throw fail('TEST_PATH_EXISTS', 'Test path already exists; refusing to modify it')
87
94
  ownedPath = path
@@ -0,0 +1,85 @@
1
+ // SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import test from 'node:test'
4
+ import assert from 'node:assert/strict'
5
+ import { openBrowserRom } from '../src/browser.js'
6
+ import { browserRomCompatibility } from '../src/browser_rom_compat.js'
7
+
8
+ const port = () => ({ open() { throw new Error('Port must remain passive') }, close() {} })
9
+ test('browser ROM construction and disconnect are passive without browser globals', async () => {
10
+ const adapter = openBrowserRom({ port: port() })
11
+ assert.equal(adapter.chipFamily, null)
12
+ assert.equal(adapter.chipRevision, null)
13
+ assert.equal(adapter.chipName, null)
14
+ await adapter.disconnect(); await adapter.disconnect()
15
+ await assert.rejects(adapter.initialize(), { code: 'ROM_CLOSED' })
16
+ })
17
+ test('browser ROM validates its port and cancellation before loading the browser dependency', async () => {
18
+ assert.throws(() => openBrowserRom(), /SerialPort/)
19
+ assert.throws(() => openBrowserRom({ port: port(), signal: {} }), /AbortSignal/)
20
+ const controller = new AbortController()
21
+ controller.abort(new Error('cancelled before open'))
22
+ await assert.rejects(openBrowserRom({ port: port(), signal: controller.signal }).initialize(), /cancelled before open/)
23
+ })
24
+ test('SLIP prefix compatibility retries only pre-header bytes within the original deadline', async () => {
25
+ let count = 0
26
+ const loader = browserRomCompatibility({ async readPacket() {
27
+ if (++count < 3) throw new Error('Invalid head of packet (0x47)')
28
+ return [79, 72, 65, 73]
29
+ } })
30
+ assert.deepEqual(await loader.readPacket(100), [79, 72, 65, 73])
31
+ loader.readPacket = browserRomCompatibility({ readPacket: async () => { throw new Error('Invalid SLIP escape (0xdb, 0x47)') } }).readPacket
32
+ await assert.rejects(loader.readPacket(100), /Invalid SLIP escape/)
33
+ })
34
+ test('SLIP log noise cannot extend the operation deadline indefinitely', async () => {
35
+ let count = 0
36
+ const loader = browserRomCompatibility({ async readPacket() {
37
+ count++
38
+ await new Promise(resolve => setTimeout(resolve, 5))
39
+ throw new Error('Invalid head of packet (0x47)')
40
+ } })
41
+ await assert.rejects(loader.readPacket(15), /Invalid head/)
42
+ assert.ok(count <= 4)
43
+ })
44
+ function stubFixture({ native = true, error = 'Timed out waiting for packet header', handshake = true } = {}) {
45
+ let timeout, reads = 0
46
+ const stub = { IS_STUB: true, readPacket: async () => [], checkCommand: (...args) => { timeout = args[3] } }
47
+ const raw = {
48
+ isUsbJtagOrOtg: native,
49
+ memFinish: async () => { if (error) throw new Error(error) },
50
+ readPacket: async () => { reads++; return handshake ? [79, 72, 65, 73] : [] },
51
+ async runStub(skip) {
52
+ assert.equal(skip, true)
53
+ await this.memFinish()
54
+ assert.deepEqual(await this.readPacket(2500), [79, 72, 65, 73], 'OHAI required')
55
+ return stub
56
+ },
57
+ }
58
+ return { loader: browserRomCompatibility(raw), stub, timeout: () => timeout, reads: () => reads }
59
+ }
60
+ test('native USB may miss MEM_END acknowledgement but still requires OHAI', async () => {
61
+ const fixture = stubFixture()
62
+ assert.equal(await fixture.loader.runStub(true), fixture.stub)
63
+ assert.equal(fixture.reads(), 1)
64
+ await assert.rejects(stubFixture({ handshake: false }).loader.runStub(true), /OHAI required/)
65
+ })
66
+ test('UART timeouts and native USB non-timeout errors retain their failures', async () => {
67
+ await assert.rejects(stubFixture({ native: false }).loader.runStub(true), /Timed out/)
68
+ await assert.rejects(stubFixture({ error: 'Port disconnected' }).loader.runStub(true), /Port disconnected/)
69
+ })
70
+ test('stub launch restores temporary hooks on failure and rejects ROM fallback', async () => {
71
+ const fixture = stubFixture({ handshake: false })
72
+ const finish = fixture.loader.memFinish, packet = fixture.loader.readPacket
73
+ await assert.rejects(fixture.loader.runStub(true))
74
+ assert.equal(fixture.loader.memFinish, finish)
75
+ assert.equal(fixture.loader.readPacket, packet)
76
+ await assert.rejects(browserRomCompatibility({ runStub: async () => ({ IS_STUB: false }) }).runStub(true), /no supported browser flash stub/)
77
+ })
78
+ test('stub FLASH_BEGIN timeout scales with uncompressed erase size and preserves other commands', async () => {
79
+ const fixture = stubFixture()
80
+ const stub = await fixture.loader.runStub(true)
81
+ await stub.checkCommand(2, [0, 0, 128, 0], 0, 3000)
82
+ assert.ok(fixture.timeout() > 240000)
83
+ await stub.checkCommand(3, [], 0, 1234)
84
+ assert.equal(fixture.timeout(), 1234)
85
+ })
@@ -3,6 +3,7 @@ import test from 'node:test'
3
3
  import assert from 'node:assert/strict'
4
4
  import { readFile, mkdtemp, rm } from 'node:fs/promises'
5
5
  import { join } from 'node:path'
6
+ import { EV_REPL_INPUT_MAX_BYTES, evReplExecInputBytes } from '../src/evrepl.js'
6
7
  import { tmpdir } from 'node:os'
7
8
  import { parseArgs } from './hardware-check.mjs'
8
9
  import { DEVICE_CHECKS, parseCheckResult, runDeviceChecks, saveJpeg } from './device-checks.mjs'
@@ -11,11 +12,11 @@ function devicePeer({ results = {}, board = 'TEST', screenFails = false } = {})
11
12
  const files = new Map(), calls = [], listeners = new Map()
12
13
  return {
13
14
  calls, files, listeners,
14
- async inspect({ fields }) { calls.push(['inspect', fields]); return { identity: { board, firmwareVersion: 'test' }, capabilities: { protocol: { evMuxVersion: 4 }, features: [] }, runtime: { vmReady: true } } },
15
+ 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 } } } },
15
16
  files: Object.assign(files, {
16
- async list() { return [...files.keys()].map(path => ({ path })) },
17
- async write({ path, data, overwrite }) { calls.push(['write', path]); assert.equal(overwrite, false); files.set(path, data) },
18
- async remove({ path }) { calls.push(['remove', path]); files.delete(path) },
17
+ async list() { throw new Error('Runtime checks must not access storage') },
18
+ async write() { throw Object.assign(new Error('Flash unmounted'), { code: 'FILE_OPEN_FAILED' }) },
19
+ async remove() { throw new Error('Runtime checks must not access storage') },
19
20
  }),
20
21
  python: { async execute({ source }) {
21
22
  calls.push(['execute', source])
@@ -48,10 +49,15 @@ test('device check reports reject stale, duplicated and malformed markers', () =
48
49
  for (const output of ['', valid + '\n' + valid, '__EVDEVICECHECK__bad', valid.replace('memory', 'camera'), valid.replace('PASS', 'UNKNOWN')]) assert.throws(() => parseCheckResult(output, 'memory'), { code: 'CHECK_REPORT_INVALID' })
49
50
  })
50
51
 
51
- test('functional checks stage their shared fixture once and consume preview via public events', async () => {
52
+ test('functional checks load fixtures in RAM despite unmounted storage and consume public preview events', async () => {
52
53
  const device = devicePeer(), reports = []
53
54
  await runDeviceChecks(device, parseArgs(['--tcp', 'test', '--checks', 'memory,preview,orientation']), { nonce: 'abc123', report: value => reports.push(value) })
54
- assert.equal(device.calls.filter(call => call[0] === 'write').length, 1)
55
+ const commands = device.calls.filter(call => call[0] === 'execute').map(call => call[1])
56
+ assert.ok(commands.every(source => evReplExecInputBytes(source) <= EV_REPL_INPUT_MAX_BYTES))
57
+ assert.equal(commands.filter(source => source.startsWith("exec(''.join(")).length, 1)
58
+ const loader = commands.filter(source => source.includes("['_source']") || source.includes("={'_source':"))
59
+ const uploaded = loader.filter(source => source.includes("['_source'].append(")).map(source => JSON.parse(source.slice(source.indexOf('.append(') + 8, -1))).join('')
60
+ assert.equal(uploaded, await readFile(new URL('./fixtures/device-checks.py', import.meta.url), 'utf8'))
55
61
  assert.equal(device.files.size, 0)
56
62
  assert.equal(device.listeners.size, 0)
57
63
  assert.equal(reports.find(result => result.check === 'preview').data.received, 1)
@@ -96,3 +102,36 @@ test('truncated JPEG and missing dimensions are refused before host artifact is
96
102
  await assert.rejects(saveJpeg({ data: new Uint8Array([255, 216]), mimeType: 'image/jpeg', metadata: { width: 1, height: 1 } }), { code: 'CAPTURE_INVALID' })
97
103
  await assert.rejects(saveJpeg({ data: new Uint8Array([255, 216, 255, 217]), mimeType: 'image/jpeg' }), { code: 'CAPTURE_INVALID' })
98
104
  })
105
+
106
+
107
+ test('strict acceptance fails unsupported selected checks without preventing independent identity evidence', async () => {
108
+ const device = devicePeer({ results: { memory: { name: 'memory', status: 'SKIP', error: { message: 'ESP_ERR_NOT_SUPPORTED' } } } }), reports = []
109
+ await assert.rejects(runDeviceChecks(device, parseArgs(['--tcp', 'test', '--checks', 'memory,identity', '--require-all']), { nonce: 'abc123', report: value => reports.push(value) }), { code: 'DEVICE_CHECKS_FAILED' })
110
+ assert.equal(reports.find(result => result.check === 'memory').status, 'FAIL')
111
+ assert.equal(reports.find(result => result.check === 'identity').status, 'PASS')
112
+ assert.equal(reports.find(result => result.check === 'identity').data.storage.flash.mounted, false)
113
+ })
114
+
115
+ test('failed partial RAM loading still releases its namespace without filesystem cleanup', async () => {
116
+ const device = devicePeer(), reports = [], execute = device.python.execute
117
+ let failed = false
118
+ device.python.execute = async request => {
119
+ if (!failed && request.source.includes("['_source'].append")) { failed = true; throw new Error('fixture load failed') }
120
+ return execute(request)
121
+ }
122
+ await assert.rejects(runDeviceChecks(device, parseArgs(['--tcp', 'test', '--checks', 'memory']), { nonce: 'abc123', report: value => reports.push(value) }), { code: 'DEVICE_CHECKS_FAILED' })
123
+ assert.ok(device.calls.some(call => call[0] === 'execute' && call[1].includes('del _evhil_abc123')))
124
+ assert.equal(reports.at(-1).failed, 1)
125
+ })
126
+
127
+ test('identity and RAM checks do not require optional storage inspection support', async () => {
128
+ const device = devicePeer(), inspect = device.inspect, reports = []
129
+ device.inspect = async request => {
130
+ assert.ok(!request.fields.includes('storage'))
131
+ const info = await inspect(request)
132
+ info.capabilities.features = []
133
+ return info
134
+ }
135
+ await runDeviceChecks(device, parseArgs(['--tcp', 'test', '--checks', 'identity,memory']), { nonce: 'abc123', report: value => reports.push(value) })
136
+ assert.deepEqual(reports.at(-1), { check: 'summary', passed: 2, failed: 0, skipped: 0 })
137
+ })