@esp-vision/device-core 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/NOTICE +8 -0
  3. package/README.md +24 -7
  4. package/package.json +4 -3
  5. package/schemas/device-actions-v1.json +1 -1
  6. package/scripts/release-npm.mjs +16 -0
  7. package/scripts/version-policy.mjs +71 -0
  8. package/src/browser.js +6 -2
  9. package/src/browser_rom.js +208 -4
  10. package/src/browser_rom_compat.js +86 -59
  11. package/src/browser_serial.js +130 -0
  12. package/src/device.js +5 -4
  13. package/src/device_bindings.js +5 -4
  14. package/src/device_capture.js +11 -0
  15. package/src/device_operations.js +3 -1
  16. package/src/device_session.js +263 -10
  17. package/src/evmux.js +43 -4
  18. package/src/firmware_flash.js +1 -1
  19. package/src/jpeg_validate.js +132 -0
  20. package/src/node_inventory.js +1 -1
  21. package/src/node_rom.js +76 -27
  22. package/src/node_rom.py +68 -2
  23. package/src/node_rom_port.js +211 -0
  24. package/src/node_serial.py +47 -1
  25. package/src/node_transport.js +76 -10
  26. package/src/rom_md5.js +30 -0
  27. package/tests/check-browser.mjs +76 -123
  28. package/tests/check-package.mjs +1 -1
  29. package/tests/device-checks.mjs +6 -2
  30. package/tests/fixtures/device-checks.py +11 -1
  31. package/tests/jpeg-fixtures.mjs +39 -0
  32. package/tests/test-browser-rom.mjs +204 -67
  33. package/tests/test-browser-serial.mjs +143 -0
  34. package/tests/test-device-api.mjs +40 -7
  35. package/tests/test-device-functional-checks.mjs +12 -2
  36. package/tests/test-device-operations-api.mjs +4 -3
  37. package/tests/test-device-reset.mjs +292 -0
  38. package/tests/test-firmware-flash.mjs +1 -1
  39. package/tests/test-jpeg-validation.mjs +233 -0
  40. package/tests/test-node-rom-port.mjs +305 -0
  41. package/tests/test-node-rom.mjs +160 -0
  42. package/tests/test-node-serial.mjs +206 -0
  43. package/tests/test-npm-release-policy.mjs +62 -0
  44. package/tests/test-observed-capture.mjs +131 -0
  45. package/tests/test-uart-baud.mjs +180 -0
  46. package/tests/test-version-policy.mjs +37 -0
@@ -6,170 +6,123 @@ import { tmpdir } from 'node:os'
6
6
  import { join, dirname } from 'node:path'
7
7
  import { fileURLToPath, pathToFileURL } from 'node:url'
8
8
  import vm from 'node:vm'
9
- import { webcrypto } from 'node:crypto'
9
+ import { webcrypto, createHash } from 'node:crypto'
10
10
  import { build } from 'esbuild'
11
11
 
12
12
  const root = dirname(dirname(fileURLToPath(import.meta.url)))
13
13
  const temporary = await mkdtemp(join(tmpdir(), 'esp-vision-browser-'))
14
14
  try {
15
- // Bundle the actual pinned dependency and its dynamic JSON stubs for browsers.
16
15
  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,
16
+ stdin: { contents: `export { createDevice } from './src/index.js'; export { openBrowserRom } from './src/browser.js'; export { loadReleaseManifest } from './src/firmware.js'; export { ESPLoader as TestLoader, Transport as TestTransport } from 'esptool-js'; export { browserTransportCompatibility as testTransport } from './src/browser_rom_compat.js';`, resolveDir: root },
17
+ bundle: true, platform: 'browser', format: 'iife', globalName: 'DeviceCore', write: false, metafile: true,
20
18
  })
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({ atob, btoa, EventTarget, URL, TextDecoder, TextEncoder, Uint8Array, AbortController, setTimeout, clearTimeout, console, crypto: webcrypto, structuredClone })
19
+ const code = result.outputFiles[0].text, inputs = Object.keys(result.metafile.inputs)
20
+ assert.ok(inputs.some(path => path.includes('esptool-js/lib/esploader.js')))
21
+ for (const chip of ['esp32p4', 'esp32s31']) assert.ok(inputs.some(path => path.includes(`stub_flasher/${chip}.json`)))
22
+ assert.ok(!inputs.some(path => /tasmota|src\/(node|cli)/.test(path)))
23
+ const context = vm.createContext({ atob, btoa, EventTarget, URL, TextDecoder, TextEncoder, Uint8Array, DataView, ArrayBuffer, AbortController, setTimeout, clearTimeout, console, crypto: webcrypto, structuredClone })
27
24
  vm.runInContext(code, context)
28
- let opens = 0, closes = 0
25
+ let opens = 0
29
26
  const adapter = context.DeviceCore.openBrowserRom({ port: {
30
- async open() { opens++; throw new Error('real dependency reached SerialPort.open') },
31
- async close() { closes++ },
27
+ getInfo: () => ({ usbVendorId: 0x10c4, usbProductId: 0xea60 }),
28
+ async open() { opens++; throw new Error('real dependency reached SerialPort.open') }, async close() {},
32
29
  } })
33
- assert.equal(opens, 0, 'import and construction must not open/reset the port')
30
+ assert.equal(opens, 0)
34
31
  await assert.rejects(adapter.initialize(), /real dependency reached SerialPort.open/)
35
32
  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
- // Exercise real upstream stub status decoding. A ROM reset consumes four
50
- // status bytes while the running stub replies with two; route reset to it.
51
- {
52
- const raw = new context.DeviceCore.TestLoader({ getInfo: () => ({ usbVendorId: 0x303a, usbProductId: 0x1001 }) }, { log() {}, error() {}, debug() {} })
53
- raw.chipFamily = 0x3280; raw.chipRevision = 302
54
- raw.memBegin = raw.memBlock = raw.memFinish = async () => {}
55
- raw.readPacket = async () => new Uint8Array([79, 72, 65, 73])
56
- context.DeviceCore.testCompatibility(raw)
57
- const stub = await raw.runStub(true)
58
- let writes = 0
59
- stub.detectUsbConnectionType = async () => true
60
- stub.getUsbMode = async () => ({ mode: 'usb-jtag-serial' })
61
- stub.sendCommand = async () => { writes++ }
62
- stub.getResponse = async () => [0, new Uint8Array([0, 0])]
63
- // Confirm the original ROM instance rejects the exact same stub response.
64
- raw.detectUsbConnectionType = stub.detectUsbConnectionType
65
- raw.getUsbMode = stub.getUsbMode
66
- raw.sendCommand = async () => {}
67
- raw.getResponse = stub.getResponse
68
- await assert.rejects(context.DeviceCore.TestLoader.prototype.hardReset.call(raw), /status|response|enough/i)
69
- await raw.hardReset()
70
- assert.equal(writes, 4, 'P4 watchdog writes must use the running stub status decoder')
71
- }
33
+ const transport = context.DeviceCore.testTransport(new context.DeviceCore.TestTransport({ getInfo: () => ({}), readable: {}, writable: {} }, false))
34
+ transport.buffer = new Uint8Array([71, 97, 114, 0xc0, 79, 72, 65, 73, 0xc0])
35
+ assert.deepEqual(Array.from(await transport.read(100)), [79, 72, 65, 73])
36
+ transport.buffer = new Uint8Array([0xc0, 0xdb, 0x47, 0xc0])
37
+ await assert.rejects(transport.read(100), /Invalid SLIP escape/)
38
+ let released = 0
39
+ transport.device.writable = { getWriter: () => ({ async write() { throw new Error('USB detached during write') }, releaseLock() { released++ } }) }
40
+ await assert.rejects(transport.write(new Uint8Array([1])), /detached/)
41
+ assert.equal(released, 1, 'failed writes release the real WebSerial writer lock')
72
42
  const prototype = context.DeviceCore.TestLoader.prototype
73
- const originals = Object.fromEntries(['initialize', 'disconnect', 'getSecurityInfo', 'runStub', 'hardReset'].map(key => [key, prototype[key]]))
74
- let initializeCalls = 0, disconnectCalls = 0
75
- prototype.disconnect = async function () { disconnectCalls++; await this.port.close() }
43
+ const keys = ['detectChip', 'getSecurityInfo', 'runStub', 'writeFlash', 'readReg', 'writeReg', 'checkCommand']
44
+ const originals = Object.fromEntries(keys.map(key => [key, prototype[key]]))
45
+ let detects = 0, closes = 0, stubs = 0, writes = 0, resets = 0
76
46
  const fakePort = () => ({
47
+ getInfo: () => ({ usbVendorId: 0x303a, usbProductId: 32 }),
77
48
  async open() { this.readable = {}; this.writable = {} },
78
- async close() { this.readable = null; this.writable = null },
49
+ async close() { closes++; this.readable = null; this.writable = null },
50
+ async setSignals() { throw new Error('OTG must not require UART control signals') },
79
51
  })
80
52
  try {
81
- prototype.initialize = async () => { initializeCalls++ }
82
- const controller = new AbortController()
83
- const loader = context.DeviceCore.openBrowserRom({ port: fakePort(), signal: controller.signal })
53
+ prototype.detectChip = async function () { detects++; await this.transport.connect(115200); this.chip = { CHIP_NAME: 'ESP32-S31', getChipRevision: async () => 0 } }
54
+ prototype.getSecurityInfo = async () => ({ flags: 0, flashCryptCnt: 0 })
55
+ const controller = new AbortController(), loader = context.DeviceCore.openBrowserRom({ port: fakePort(), signal: controller.signal })
84
56
  const first = loader.initialize()
85
- assert.equal(loader.initialize(), first, 'initialize must share one ROM connection')
86
- await first
87
- controller.abort()
88
- assert.equal(disconnectCalls, 0, 'abort after initialization must not interrupt flash/readback')
57
+ assert.equal(loader.initialize(), first)
58
+ await first; controller.abort()
59
+ assert.equal(closes, 0, 'abort after initialization preserves ongoing flash')
89
60
  await loader.disconnect(); await loader.disconnect()
90
- assert.equal(disconnectCalls, 1)
91
- assert.equal(initializeCalls, 1)
92
- prototype.initialize = async () => { throw new Error('sync failed') }
93
- await assert.rejects(context.DeviceCore.openBrowserRom({ port: fakePort() }).initialize(), /sync failed/)
94
- assert.equal(disconnectCalls, 2, 'failed ROM sync must release the acquired port')
61
+ assert.equal(closes, 1); assert.equal(detects, 1)
62
+ const detect = prototype.detectChip
63
+ prototype.detectChip = async function () { await this.transport.connect(115200); throw new Error('sync failed') }
64
+ await assert.rejects(context.DeviceCore.openBrowserRom({ port: fakePort() }).initialize(), /USB-OTG ROM is not ready/)
65
+ assert.equal(closes, 2)
66
+ prototype.detectChip = detect
95
67
  let finishOpen
96
- const waitingPort = fakePort()
97
- const open = waitingPort.open
98
- waitingPort.open = async function () {
99
- await new Promise(resolve => { finishOpen = resolve })
100
- await open.call(this)
101
- }
102
- const cancelled = new AbortController()
103
- const late = context.DeviceCore.openBrowserRom({ port: waitingPort, signal: cancelled.signal })
68
+ const waitingPort = fakePort(), open = waitingPort.open
69
+ waitingPort.open = async function () { await new Promise(resolve => { finishOpen = resolve }); await open.call(this) }
70
+ const cancelled = new AbortController(), late = context.DeviceCore.openBrowserRom({ port: waitingPort, signal: cancelled.signal })
104
71
  const starting = late.initialize()
105
72
  while (!finishOpen) await new Promise(resolve => setTimeout(resolve, 1))
106
- cancelled.abort()
107
- await assert.rejects(starting, { name: 'AbortError' })
108
- finishOpen()
109
- await new Promise(resolve => setTimeout(resolve, 5))
110
- assert.equal(disconnectCalls, 3, 'an open completing after cancellation must close')
111
- assert.equal(waitingPort.readable, null)
73
+ cancelled.abort(); await assert.rejects(starting, { name: 'AbortError' })
74
+ finishOpen(); await new Promise(resolve => setTimeout(resolve, 5))
75
+ assert.equal(closes, 3); assert.equal(waitingPort.readable, null)
112
76
  const data = new Uint8Array([0xe9, 1, 2, 3]), table = new Uint8Array([4, 5])
113
77
  const hash = async bytes => Array.from(new Uint8Array(await webcrypto.subtle.digest('SHA-256', bytes)), byte => byte.toString(16).padStart(2, '0')).join('')
114
78
  const release = { board: 'BOARD', version: 'browser-test', image: {
115
- evMuxVersion: 4, chipFamily: 0x3280, chipRevision: 302,
116
- offset: 65536, bytes: data.byteLength, partitionSize: 4096,
79
+ evMuxVersion: 4, chipFamily: 0x3231, chipRevision: 0, offset: 65536, bytes: data.byteLength, partitionSize: 4096,
117
80
  sha256: await hash(data), partitionTable: { offset: 32768, bytes: table.byteLength, sha256: await hash(table) },
118
81
  } }
119
- let stubs = 0, writes = 0, resets = 0
120
- prototype.initialize = async function () { this.chipFamily = 0x3280; this.chipRevision = 302 }
121
- prototype.getSecurityInfo = async () => ({ flags: 1, flashCryptCnt: 0 })
122
- prototype.runStub = async () => { stubs++; throw new Error('must not launch') }
123
- const device = context.DeviceCore.createDevice()
124
- const request = {
125
- release, board: 'BOARD', data,
82
+ const device = context.DeviceCore.createDevice(), request = { release, board: 'BOARD', data,
126
83
  openLoader: ({ signal }) => context.DeviceCore.openBrowserRom({ port: fakePort(), signal }),
127
84
  }
85
+ prototype.getSecurityInfo = async () => ({ flags: 1, flashCryptCnt: 0 })
86
+ prototype.runStub = async () => { stubs++; throw new Error('must not launch') }
128
87
  await assert.rejects(device.firmware.update(request), /Secure boot/)
129
- assert.equal(stubs, 0, 'ROM security must be checked before the browser launches a stub')
88
+ assert.equal(stubs, 0)
130
89
  await assert.rejects(device.firmware.update({ ...request, data: new Uint8Array(4) }), /SHA-256/)
131
90
  assert.equal(stubs, 0)
132
91
  prototype.getSecurityInfo = async () => ({ flags: 0, flashCryptCnt: 0 })
92
+ prototype.runStub = async function () { stubs++; this.IS_STUB = true }
133
93
  const writing = new AbortController()
134
- prototype.runStub = async () => {
135
- stubs++
136
- return {
137
- IS_STUB: true,
138
- async flashData() { writes++; writing.abort() },
139
- async readFlash(offset) { return offset === 32768 ? table : data },
140
- async readRegister() { return 0 },
141
- async hardReset() { resets++ },
142
- }
94
+ prototype.writeFlash = async function (options) { assert.equal(options.compress, true); assert.equal(options.eraseAll, false); writes++; writing.abort(); options.reportProgress(0, 4, 4) }
95
+ prototype.readReg = async () => 0
96
+ prototype.writeReg = async () => { resets++ }
97
+ prototype.checkCommand = async function (description, _opcode, packet) {
98
+ assert.equal(description, 'read flash')
99
+ const address = new DataView(packet.buffer).getUint32(0, true), bytes = address === 32768 ? table : data
100
+ const digest = createHash('md5').update(bytes).digest()
101
+ const frame = chunk => [0xc0, ...Array.from(chunk).flatMap(byte => byte === 0xc0 ? [0xdb, 0xdc] : byte === 0xdb ? [0xdb, 0xdd] : [byte]), 0xc0]
102
+ this.transport.buffer = new Uint8Array([...frame(bytes), ...frame(digest)])
103
+ return 0
143
104
  }
144
- prototype.hardReset = async () => { resets++ }
145
- const beforeUpdateCloses = disconnectCalls
146
- const updated = await device.firmware.update({ ...request, signal: writing.signal })
147
- assert.equal(updated.verified, true)
148
- assert.equal(updated.needsReset, false)
149
- assert.equal(stubs, 1); assert.equal(writes, 1); assert.equal(resets, 1)
150
- assert.equal(disconnectCalls, beforeUpdateCloses + 1, 'write-time abort must preserve complete readback, reset and one cleanup')
105
+ const updatePort = fakePort(), openUpdate = updatePort.open
106
+ updatePort.open = async function (...args) { await openUpdate.apply(this, args); this.writable = { getWriter: () => ({ async write() {}, releaseLock() {} }) } }
107
+ const beforeCloses = closes
108
+ const updated = await device.firmware.update({ ...request, signal: writing.signal, openLoader: ({ signal }) => context.DeviceCore.openBrowserRom({ port: updatePort, signal }) })
109
+ assert.equal(updated.verified, true); assert.equal(updated.needsReset, false)
110
+ assert.equal(stubs, 1); assert.equal(writes, 1); assert.equal(resets, 4)
111
+ assert.equal(closes, beforeCloses + 1)
151
112
  const manifest = { schemaVersion: 1, version: 'browser-test', sourceCommit: 'a'.repeat(40), pipelineId: 1, layout: 'legacy',
152
113
  releases: [{ ...release, image: { ...release.image, url: 'https://dl.espressif.com/AE/esp-vision/bin/app.bin' } }],
153
- website: { schemaVersion: '1.0.0', release: 'browser-test', releases: ['browser-test'], binaryNameTemplate: '{board}-{tag}.bin',
154
- boards: [{ board: 'BOARD', name: 'Board', slug: 'board', flashable: true }], models: [] }, mcp: null }
114
+ website: { schemaVersion: '1.0.0', release: 'browser-test', releases: ['browser-test'], binaryNameTemplate: '{board}-{tag}.bin', boards: [{ board: 'BOARD', name: 'Board', slug: 'board', flashable: true }], models: [] }, mcp: null }
155
115
  const rawManifest = new TextEncoder().encode(JSON.stringify(manifest)), caches = []
156
116
  const snapshot = await context.DeviceCore.loadReleaseManifest({ url: 'https://dl.espressif.com/AE/esp-vision/releases/browser-test/manifest.json', sha256: await hash(rawManifest),
157
- async fetch(_url, options) {
158
- caches.push(options.cache)
159
- if (caches.length === 1) throw new TypeError('Cached response CORS failure from the host realm')
160
- return new Response(rawManifest)
161
- } })
162
- assert.equal(snapshot.version, 'browser-test')
163
- assert.deepEqual(caches, ['default', 'no-store'], 'real browser bundle must recover a fetch TypeError from host-provided fetch')
164
-
117
+ async fetch(_url, options) { caches.push(options.cache); if (caches.length === 1) throw new TypeError('Cached CORS failure from host realm'); return new Response(rawManifest) },
118
+ })
119
+ assert.equal(snapshot.version, 'browser-test'); assert.deepEqual(caches, ['default', 'no-store'])
165
120
  } finally { Object.assign(prototype, originals) }
166
- // Node import must not eagerly resolve tasmota's browser-only, extensionless ESM.
167
121
  const { openBrowserRom } = await import(pathToFileURL(join(root, 'src/browser.js')))
168
122
  assert.equal(typeof openBrowserRom, 'function')
169
- const dependency = JSON.parse(await readFile(join(root, 'node_modules/tasmota-webserial-esptool/package.json'), 'utf8'))
170
- assert.equal(dependency.version, '9.4.2')
171
- const stubsLicense = await readFile(join(root, 'node_modules/tasmota-webserial-esptool/src/stubs/README.md'), 'utf8')
172
- assert.match(stubsLicense, /dual licensed.*Apache License Version 2.0 or the MIT license/s)
123
+ const dependency = JSON.parse(await readFile(join(root, 'node_modules/esptool-js/package.json'), 'utf8'))
124
+ assert.equal(dependency.version, '0.7.1'); assert.equal(dependency.license, 'Apache-2.0')
125
+ assert.match(await readFile(join(root, 'node_modules/esptool-js/LICENSE'), 'utf8'), /Apache License/)
173
126
  await writeFile(join(temporary, 'browser.js'), code)
174
- console.log(JSON.stringify({ browserBundle: true, dependency: dependency.version, bytes: code.length, passiveImports: true, p4AndS31Stubs: true, cleanupAndCancellation: true, coreSafetyChecks: true }))
127
+ console.log(JSON.stringify({ browserBundle: true, dependency: dependency.version, bytes: code.length, passiveImports: true, p4AndS31Stubs: true, cleanupAndCancellation: true, coreSafetyChecks: true, compressedWrite: true, fullReadbackAndMd5: true }))
175
128
  } finally { await rm(temporary, { recursive: true, force: true }) }
@@ -62,7 +62,7 @@ try {
62
62
  api.createDevice({ transport: serialTransport({ python: 'python3', serial: 'not-opened' }) });
63
63
  if (typeof DeviceSession !== 'function' || typeof FirmwareFlashSession !== 'function') throw new Error('Missing host adapter APIs');`
64
64
  execFileSync(process.execPath, ['--input-type=module', '-e', script], { cwd: consumer, stdio: 'pipe' })
65
- for (const required of ['src/node_serial.py', 'scripts/run-tests.mjs', 'tests/hardware-check.mjs', 'tests/device-checks.mjs', 'tests/fixtures/device-checks.py']) {
65
+ for (const required of ['src/node_serial.py', 'src/jpeg_validate.js', 'scripts/run-tests.mjs', 'tests/hardware-check.mjs', 'tests/device-checks.mjs', 'tests/jpeg-fixtures.mjs', 'tests/test-jpeg-validation.mjs', 'tests/fixtures/device-checks.py']) {
66
66
  assert.ok(packed.files.some(({ path }) => path === required), `Missing installed check asset: ${required}`)
67
67
  }
68
68
  const unitOutput = execFileSync('npm', ['test'], { cwd: installed, encoding: 'utf8', maxBuffer: 4 * 1024 * 1024 })
@@ -3,6 +3,7 @@
3
3
  import { readFile, mkdir, writeFile } from 'node:fs/promises'
4
4
  import { dirname } from 'node:path'
5
5
  import { EV_REPL_INPUT_MAX_BYTES, evReplExecInputBytes } from '../src/evrepl.js'
6
+ import { validateJpegImage } from '../src/jpeg_validate.js'
6
7
 
7
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'])
8
9
  const MARKER = '__EVDEVICECHECK__'
@@ -23,8 +24,8 @@ export function parseCheckResult(output, name) {
23
24
 
24
25
  export async function saveJpeg(image, output) {
25
26
  const data = image?.data
26
- if (image?.mimeType !== 'image/jpeg' || !(data instanceof Uint8Array) || data.length < 4 || data[0] !== 255 || data[1] !== 216 || data.at(-2) !== 255 || data.at(-1) !== 217) throw failure('CAPTURE_INVALID', 'Capture is not a complete JPEG')
27
- if (!(image.metadata?.width > 0 && image.metadata?.height > 0)) throw failure('CAPTURE_INVALID', 'Capture has no valid image dimensions')
27
+ if (image?.mimeType !== 'image/jpeg') throw failure('CAPTURE_INVALID', 'Capture is not a JPEG')
28
+ try { validateJpegImage(data, image.metadata) } catch { throw failure('CAPTURE_INVALID', 'Capture is not a structurally valid JPEG with matching dimensions') }
28
29
  if (output) { await mkdir(dirname(output), { recursive: true }); await writeFile(output, data) }
29
30
  return { bytes: data.length, width: image.metadata.width, height: image.metadata.height }
30
31
  }
@@ -103,6 +104,9 @@ export async function runDeviceChecks(device, options, { signal, nonce, report =
103
104
  data = { identity: info.identity, features: info.capabilities.features, vmReady: info.runtime.vmReady,
104
105
  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 }])) }
105
106
  } else if (name === 'camera') {
107
+ // --capture explicitly opts into application camera setup. The public
108
+ // capture API itself only observes a previously published safe frame.
109
+ requirePass(await invoke('camera'))
106
110
  const image = await device.capture({ source: 'camera', format: 'jpeg', quality: options.quality, timeout: options.frameTimeout * 1000, signal })
107
111
  data = await saveJpeg(image, options.output)
108
112
  data.sensor = requirePass(await invoke('sensor_state'))
@@ -11,6 +11,16 @@ def is_unsupported(exc):
11
11
  return any(token in message for token in ('esp_err_not_supported', 'esp_err_not_found', 'no module named', 'enodev'))
12
12
 
13
13
 
14
+ def camera(opts):
15
+ """Explicitly publish an application snapshot before passive observation."""
16
+ import sensor
17
+ sensor.reset()
18
+ sensor.set_pixformat(getattr(sensor, opts['pixformat']))
19
+ sensor.set_framesize(getattr(sensor, opts['framesize']))
20
+ frame = sensor.snapshot()
21
+ return dict(width=frame.width(), height=frame.height())
22
+
23
+
14
24
  def framesizes(opts):
15
25
  import sensor
16
26
  sensor.reset()
@@ -510,7 +520,7 @@ def run_check(name, opts):
510
520
  import json
511
521
  if isinstance(opts, str):
512
522
  opts = json.loads(opts)
513
- functions = dict(memory=memory, sensor_state=sensor_state, framesizes=framesizes,
523
+ functions = dict(memory=memory, camera=camera, sensor_state=sensor_state, framesizes=framesizes,
514
524
  pixformats=pixformats, orientation=orientation, ipa_stats=ipa_stats,
515
525
  snapshot_rate=snapshot_rate, preview=preview, image_ops=image_ops,
516
526
  display=display, display_stream=display_stream,
@@ -0,0 +1,39 @@
1
+ // SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ // Self-created RGB pixel patterns encoded and independently decoded with Pillow
4
+ // 10.2.0, JPEG quality=80/optimize=True; progressive only for that named fixture.
5
+ // No third-party images. Tests and installed-package checks need no Pillow.
6
+ export const JPEG_FIXTURES = {
7
+ "tiny": {
8
+ "width": 2,
9
+ "height": 1,
10
+ "base64": "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAABAAIDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAj/xAAZEAEAAgMAAAAAAAAAAAAAAAAAAQMzcrH/xAAVAQEBAAAAAAAAAAAAAAAAAAAEBf/EABgRAAIDAAAAAAAAAAAAAAAAAAABAjEy/9oADAMBAAIRAxEAPwCbKMFescALVFmOUf/Z"
11
+ },
12
+ "alternate": {
13
+ "width": 2,
14
+ "height": 1,
15
+ "base64": "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAABAAIDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAf/xAAZEAEAAgMAAAAAAAAAAAAAAAAAAQMzcrH/xAAVAQEBAAAAAAAAAAAAAAAAAAAFBv/EABgRAAIDAAAAAAAAAAAAAAAAAAABAjEy/9oADAMBAAIRAxEAPwCbUYK9Y4ANVF3HKP/Z"
16
+ },
17
+ "small": {
18
+ "width": 4,
19
+ "height": 3,
20
+ "base64": "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAADAAQDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAT/xAAaEAACAgMAAAAAAAAAAAAAAAAAAwUGFSSR/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAP/xAAeEQABBAEFAAAAAAAAAAAAAAABAAIDBBIUISIxQf/aAAwDAQACEQMRAD8AgrVciMSrQTwACGR+A3KtRszadnM9D0r/2Q=="
21
+ },
22
+ "progressive": {
23
+ "width": 4,
24
+ "height": 3,
25
+ "base64": "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wgARCAADAAQDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAT/xAAVAQEBAAAAAAAAAAAAAAAAAAAAAv/aAAwDAQACEAMQAAABnC//xAAWEAEBAQAAAAAAAAAAAAAAAAAEAxP/2gAIAQEAAQUCMeWX/8QAGREAAQUAAAAAAAAAAAAAAAAAAwABAhMh/9oACAEDAQE/AQEnW2r/xAAXEQADAQAAAAAAAAAAAAAAAAAAAQIR/9oACAECAQE/AYp4f//EABYQAQEBAAAAAAAAAAAAAAAAAAEAIv/aAAgBAQAGPwIwX//EABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQEAAT8hO//aAAwDAQACAAMAAAAQ8//EABcRAAMBAAAAAAAAAAAAAAAAAAABMUH/2gAIAQMBAT8QqcWs/8QAFxEAAwEAAAAAAAAAAAAAAAAAAAERIf/aAAgBAgEBPxCHrP/EABUQAQEAAAAAAAAAAAAAAAAAAADB/9oACAEBAAE/EJ2f/9k="
26
+ },
27
+ "screen": {
28
+ "width": 240,
29
+ "height": 320,
30
+ "base64": "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAFAAPADASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAf/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAb/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCdgKpMgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP/2Q=="
31
+ }
32
+ }
33
+
34
+
35
+ export function jpegFixture(name = 'tiny') {
36
+ const fixture = JPEG_FIXTURES[name]
37
+ return { width: fixture.width, height: fixture.height,
38
+ data: Uint8Array.from(atob(fixture.base64), character => character.charCodeAt(0)) }
39
+ }