@esp-vision/device-core 0.5.1 → 0.6.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.
- package/CHANGELOG.md +37 -0
- package/NOTICE +8 -0
- package/README.md +25 -8
- package/package.json +4 -3
- 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 +18 -1
- package/scripts/version-policy.mjs +71 -0
- package/src/browser.js +6 -2
- package/src/browser_rom.js +208 -4
- package/src/browser_rom_compat.js +86 -59
- package/src/browser_serial.js +130 -0
- package/src/device.js +5 -4
- package/src/device_bindings.js +5 -4
- package/src/device_capture.js +11 -0
- package/src/device_operations.js +3 -1
- package/src/device_session.js +263 -10
- package/src/evmux.js +43 -4
- package/src/firmware_flash.js +1 -1
- package/src/jpeg_validate.js +132 -0
- package/src/node_inventory.js +1 -1
- package/src/node_rom.js +92 -29
- package/src/node_rom.py +134 -6
- package/src/node_rom_port.js +219 -0
- package/src/node_serial.py +122 -13
- package/src/node_transport.js +136 -21
- package/src/rom_md5.js +30 -0
- package/tests/check-browser.mjs +76 -123
- package/tests/check-package.mjs +8 -7
- package/tests/device-checks.mjs +6 -2
- package/tests/fixtures/device-checks.py +11 -1
- package/tests/jpeg-fixtures.mjs +39 -0
- package/tests/test-browser-rom.mjs +204 -67
- package/tests/test-browser-serial.mjs +143 -0
- package/tests/test-device-api.mjs +40 -7
- package/tests/test-device-functional-checks.mjs +12 -2
- package/tests/test-device-operations-api.mjs +4 -3
- package/tests/test-device-reset.mjs +292 -0
- package/tests/test-firmware-flash.mjs +1 -1
- package/tests/test-host-checks.mjs +47 -0
- package/tests/test-jpeg-validation.mjs +233 -0
- package/tests/test-node-rom-port.mjs +385 -0
- package/tests/test-node-rom.mjs +231 -0
- package/tests/test-node-serial.mjs +436 -1
- package/tests/test-npm-release-policy.mjs +56 -0
- package/tests/test-observed-capture.mjs +131 -0
- package/tests/test-uart-baud.mjs +180 -0
- package/tests/test-version-policy.mjs +37 -0
package/tests/check-browser.mjs
CHANGED
|
@@ -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 '
|
|
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(
|
|
23
|
-
assert.ok(
|
|
24
|
-
assert.ok(
|
|
25
|
-
|
|
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
|
|
25
|
+
let opens = 0
|
|
29
26
|
const adapter = context.DeviceCore.openBrowserRom({ port: {
|
|
30
|
-
|
|
31
|
-
async
|
|
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
|
|
30
|
+
assert.equal(opens, 0)
|
|
34
31
|
await assert.rejects(adapter.initialize(), /real dependency reached SerialPort.open/)
|
|
35
32
|
assert.equal(opens, 1)
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
|
74
|
-
|
|
75
|
-
|
|
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.
|
|
82
|
-
|
|
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
|
|
86
|
-
await first
|
|
87
|
-
|
|
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(
|
|
91
|
-
|
|
92
|
-
prototype.
|
|
93
|
-
await assert.rejects(context.DeviceCore.openBrowserRom({ port: fakePort() }).initialize(), /
|
|
94
|
-
assert.equal(
|
|
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
|
-
|
|
98
|
-
|
|
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
|
|
108
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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.
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
assert.equal(updated.needsReset, false)
|
|
149
|
-
assert.equal(stubs, 1); assert.equal(writes, 1); assert.equal(resets,
|
|
150
|
-
assert.equal(
|
|
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
|
-
|
|
159
|
-
|
|
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/
|
|
170
|
-
assert.equal(dependency.version, '
|
|
171
|
-
|
|
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 }) }
|
package/tests/check-package.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import { tmpdir } from 'node:os'
|
|
|
7
7
|
import { dirname, join, resolve } from 'node:path'
|
|
8
8
|
import { fileURLToPath } from 'node:url'
|
|
9
9
|
import process from 'node:process'
|
|
10
|
+
import { npmCommand } from '../scripts/npm-command.mjs'
|
|
10
11
|
|
|
11
12
|
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
|
12
13
|
const manifest = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'))
|
|
@@ -14,7 +15,7 @@ assert.equal(manifest.license, 'Apache-2.0')
|
|
|
14
15
|
assert.match(await readFile(join(root, 'LICENSE'), 'utf8'), /Apache License[\s\S]*Version 2\.0/)
|
|
15
16
|
const temporary = await mkdtemp(join(tmpdir(), 'esp-vision-device-core-package-'))
|
|
16
17
|
try {
|
|
17
|
-
const [packed] = JSON.parse(execFileSync(
|
|
18
|
+
const [packed] = JSON.parse(execFileSync(...npmCommand(['pack', '--json', '--ignore-scripts', '--pack-destination', temporary]), { cwd: root, encoding: 'utf8' }))
|
|
18
19
|
assert.ok(packed.files.some(({ path }) => path === 'LICENSE'))
|
|
19
20
|
assert.ok(packed.files.some(({ path }) => path === 'NOTICE'))
|
|
20
21
|
assert.ok(packed.files.some(({ path }) => path === 'schemas/device-actions-v1.json'))
|
|
@@ -25,7 +26,7 @@ try {
|
|
|
25
26
|
assert.ok(!packed.files.some(({ path }) => path.includes('__pycache__') || path.endsWith('.pyc')), 'Never ship host Python bytecode')
|
|
26
27
|
const consumer = join(temporary, 'consumer')
|
|
27
28
|
await mkdir(consumer)
|
|
28
|
-
execFileSync(
|
|
29
|
+
execFileSync(...npmCommand(['install', '--offline', '--ignore-scripts', '--no-audit', '--no-fund', '--package-lock=false', join(temporary, packed.filename)]), { cwd: consumer, stdio: 'pipe' })
|
|
29
30
|
const installed = join(consumer, 'node_modules/@esp-vision/device-core')
|
|
30
31
|
assert.equal(await readFile(join(installed, 'LICENSE'), 'utf8'), await readFile(join(root, 'LICENSE'), 'utf8'))
|
|
31
32
|
const notice = await readFile(join(installed, 'NOTICE'), 'utf8')
|
|
@@ -62,22 +63,22 @@ try {
|
|
|
62
63
|
api.createDevice({ transport: serialTransport({ python: 'python3', serial: 'not-opened' }) });
|
|
63
64
|
if (typeof DeviceSession !== 'function' || typeof FirmwareFlashSession !== 'function') throw new Error('Missing host adapter APIs');`
|
|
64
65
|
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']) {
|
|
66
|
+
for (const required of ['src/node_serial.py', 'src/jpeg_validate.js', 'scripts/run-tests.mjs', 'tests/hardware-check.mjs', 'tests/device-checks.mjs', 'tests/jpeg-fixtures.mjs', 'tests/test-jpeg-validation.mjs', 'tests/fixtures/device-checks.py']) {
|
|
66
67
|
assert.ok(packed.files.some(({ path }) => path === required), `Missing installed check asset: ${required}`)
|
|
67
68
|
}
|
|
68
|
-
const unitOutput = execFileSync(
|
|
69
|
+
const unitOutput = execFileSync(...npmCommand(['test']), { cwd: installed, encoding: 'utf8', maxBuffer: 4 * 1024 * 1024 })
|
|
69
70
|
const unitCount = Number(/^(?:# tests|ℹ tests) (\d+)$/m.exec(unitOutput)?.[1] ?? 0)
|
|
70
71
|
assert.ok(unitCount > 0, 'Installed npm test must execute a nonempty suite')
|
|
71
|
-
const hardwareHelp = execFileSync(
|
|
72
|
+
const hardwareHelp = execFileSync(...npmCommand(['run', 'test:hardware', '--', '--help']), { cwd: installed, encoding: 'utf8' })
|
|
72
73
|
assert.ok(hardwareHelp.includes('--serial PATH'), 'Installed hardware runner must load and show its help')
|
|
73
74
|
// A damaged tarball must fail instead of reporting a successful zero-test run.
|
|
74
75
|
await rm(join(installed, 'tests'), { recursive: true })
|
|
75
76
|
await mkdir(join(installed, 'tests'))
|
|
76
|
-
const empty = spawnSync(
|
|
77
|
+
const empty = spawnSync(...npmCommand(['test']), { cwd: installed, encoding: 'utf8' })
|
|
77
78
|
assert.equal(empty.status, 1, 'An empty suite must fail closed')
|
|
78
79
|
assert.match(empty.stderr, /No Device Core unit tests found/)
|
|
79
80
|
await rm(join(installed, 'tests'), { recursive: true })
|
|
80
|
-
const missing = spawnSync(
|
|
81
|
+
const missing = spawnSync(...npmCommand(['test']), { cwd: installed, encoding: 'utf8' })
|
|
81
82
|
assert.equal(missing.status, 1, 'Missing tests must fail closed')
|
|
82
83
|
console.log(JSON.stringify({ package: packed.name, version: packed.version, entryPoints, unitCount, standalone: true }))
|
|
83
84
|
} finally {
|
package/tests/device-checks.mjs
CHANGED
|
@@ -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'
|
|
27
|
-
|
|
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
|
+
}
|