@esp-vision/device-core 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/README.md +8 -5
- package/package.json +1 -1
- package/schemas/device-actions-v1.json +1 -1
- package/scripts/generate-action-schema.mjs +2 -1
- package/scripts/npm-command.mjs +17 -0
- package/scripts/release-npm.mjs +2 -1
- package/src/browser.js +5 -2
- package/src/browser_rom.js +55 -5
- package/src/device_session.js +85 -9
- package/src/evmux.js +15 -0
- package/src/firmware_flash.js +84 -26
- package/src/firmware_partitions.js +57 -0
- package/src/firmware_updates.js +5 -2
- package/src/node_rom.js +49 -7
- package/src/node_rom.py +92 -5
- package/src/node_rom_port.js +27 -19
- package/src/node_serial.py +75 -12
- package/src/node_transport.js +63 -14
- package/src/rom_identity.js +9 -0
- package/tests/check-browser.mjs +23 -4
- package/tests/check-package.mjs +15 -7
- package/tests/device-checks.mjs +89 -7
- package/tests/firmware-fixtures.mjs +60 -0
- package/tests/fixtures/device-checks.py +357 -1
- package/tests/hardware-check.mjs +3 -2
- package/tests/test-browser-rom.mjs +169 -3
- package/tests/test-ci-check-selection.mjs +31 -0
- package/tests/test-device-functional-checks.mjs +275 -3
- package/tests/test-device-v4-alignment.mjs +261 -7
- package/tests/test-firmware-flash.mjs +22 -20
- package/tests/test-firmware-install.mjs +8 -35
- package/tests/test-firmware-source.mjs +10 -7
- package/tests/test-host-checks.mjs +47 -0
- package/tests/test-install-erase.mjs +153 -0
- package/tests/test-node-rom-port.mjs +81 -1
- package/tests/test-node-rom.mjs +159 -4
- package/tests/test-node-serial.mjs +231 -2
- package/tests/test-npm-release-policy.mjs +3 -9
package/src/node_serial.py
CHANGED
|
@@ -11,6 +11,57 @@ import threading
|
|
|
11
11
|
import serial
|
|
12
12
|
|
|
13
13
|
|
|
14
|
+
def _report_serial_error(stage, error):
|
|
15
|
+
"""Emit bounded OS diagnostics, never exception text or transferred bytes."""
|
|
16
|
+
number = getattr(error, 'errno', None)
|
|
17
|
+
winerror = getattr(error, 'winerror', None)
|
|
18
|
+
cause = error.__cause__ or error.__context__
|
|
19
|
+
if not isinstance(number, int) and cause is not None:
|
|
20
|
+
number = getattr(cause, 'errno', None)
|
|
21
|
+
if not isinstance(winerror, int) and cause is not None:
|
|
22
|
+
winerror = getattr(cause, 'winerror', None)
|
|
23
|
+
def bounded_number(value):
|
|
24
|
+
if isinstance(value, int) and not isinstance(value, bool) and -0x80000000 <= value <= 0xffffffff:
|
|
25
|
+
return value
|
|
26
|
+
return None
|
|
27
|
+
number = bounded_number(number)
|
|
28
|
+
winerror = bounded_number(winerror)
|
|
29
|
+
# Pyserial sometimes wraps a Windows API failure without preserving errno.
|
|
30
|
+
# Only recognize fixed API names/messages; arbitrary exception text may
|
|
31
|
+
# contain application bytes and must not cross this diagnostic channel.
|
|
32
|
+
text = str(error)[:512]
|
|
33
|
+
message = 'Serial ' + stage + ' failed'
|
|
34
|
+
if number is not None and 0 < number < 4096:
|
|
35
|
+
message = os.strerror(number)
|
|
36
|
+
else:
|
|
37
|
+
for needle, safe in (
|
|
38
|
+
('ClearCommError', 'ClearCommError failed'),
|
|
39
|
+
('GetOverlappedResult', 'GetOverlappedResult failed'),
|
|
40
|
+
('ReadFile', 'ReadFile failed'),
|
|
41
|
+
('WriteFile', 'WriteFile failed'),
|
|
42
|
+
('readiness to read but returned no data', 'Device reports readiness to read but returned no data'),
|
|
43
|
+
('write timeout', 'Serial write timed out'),
|
|
44
|
+
('Zero-byte serial write', 'Serial write returned zero bytes'),
|
|
45
|
+
):
|
|
46
|
+
if needle.lower() in text.lower():
|
|
47
|
+
message = safe
|
|
48
|
+
break
|
|
49
|
+
record = {'kind': 'serial.error', 'stage': stage, 'type': type(error).__name__[:64],
|
|
50
|
+
'errno': number, 'winerror': winerror, 'message': message[:160]}
|
|
51
|
+
# One small line keeps READY/control records intact when the reader thread
|
|
52
|
+
# fails concurrently with the main thread. stderr failure is terminal too.
|
|
53
|
+
line = (json.dumps(record, ensure_ascii=True) + '\n').encode()
|
|
54
|
+
try:
|
|
55
|
+
offset = 0
|
|
56
|
+
while offset < len(line):
|
|
57
|
+
count = os.write(2, line[offset:])
|
|
58
|
+
if count <= 0:
|
|
59
|
+
break
|
|
60
|
+
offset += count
|
|
61
|
+
except OSError:
|
|
62
|
+
pass
|
|
63
|
+
|
|
64
|
+
|
|
14
65
|
def _configure_serial_exclusive(port, platform=os.name):
|
|
15
66
|
# pyserial implements this as a non-blocking advisory lock on POSIX. The
|
|
16
67
|
# Windows backend does not support this option, so leave its behavior alone.
|
|
@@ -50,16 +101,24 @@ def main():
|
|
|
50
101
|
raise SystemExit(0)
|
|
51
102
|
|
|
52
103
|
def receive():
|
|
53
|
-
|
|
54
|
-
|
|
104
|
+
while not stopped.is_set():
|
|
105
|
+
try:
|
|
55
106
|
data = port.read(8192)
|
|
56
|
-
|
|
107
|
+
except (OSError, serial.SerialException) as error:
|
|
108
|
+
if not stopped.is_set():
|
|
109
|
+
_report_serial_error('read', error)
|
|
110
|
+
os._exit(2)
|
|
111
|
+
return
|
|
112
|
+
if data:
|
|
113
|
+
try:
|
|
57
114
|
offset = 0
|
|
58
115
|
while offset < len(data):
|
|
59
116
|
offset += os.write(1, data[offset:])
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
117
|
+
except OSError as error:
|
|
118
|
+
if not stopped.is_set():
|
|
119
|
+
_report_serial_error('stdout', error)
|
|
120
|
+
os._exit(2)
|
|
121
|
+
return
|
|
63
122
|
|
|
64
123
|
signal.signal(signal.SIGTERM, terminate)
|
|
65
124
|
signal.signal(signal.SIGINT, terminate)
|
|
@@ -102,12 +161,16 @@ def main():
|
|
|
102
161
|
response = {'id': request.get('id'), 'ok': False}
|
|
103
162
|
print(json.dumps(response), file=sys.stderr, flush=True)
|
|
104
163
|
continue
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
164
|
+
try:
|
|
165
|
+
offset = 0
|
|
166
|
+
while offset < len(data):
|
|
167
|
+
written = port.write(data[offset:])
|
|
168
|
+
if not written:
|
|
169
|
+
raise OSError('Zero-byte serial write')
|
|
170
|
+
offset += written
|
|
171
|
+
except (OSError, serial.SerialException) as error:
|
|
172
|
+
_report_serial_error('write', error)
|
|
173
|
+
raise SystemExit(2) from None
|
|
111
174
|
finally:
|
|
112
175
|
stopped.set()
|
|
113
176
|
port.close()
|
package/src/node_transport.js
CHANGED
|
@@ -39,7 +39,7 @@ export function tcpTransport(options) {
|
|
|
39
39
|
|
|
40
40
|
export function serialTransport(options) {
|
|
41
41
|
const events = subscribers()
|
|
42
|
-
let child, controlId = 0, baudRate = 115200
|
|
42
|
+
let child, connection, lastError = null, controlId = 0, baudRate = 115200
|
|
43
43
|
const pending = new Map()
|
|
44
44
|
const rejectControls = error => {
|
|
45
45
|
for (const request of pending.values()) request.reject(error)
|
|
@@ -58,35 +58,65 @@ export function serialTransport(options) {
|
|
|
58
58
|
connect({ signal }) {
|
|
59
59
|
return new Promise((resolveConnect, reject) => {
|
|
60
60
|
const adapter = fileURLToPath(new URL('./node_serial.py', import.meta.url))
|
|
61
|
-
child = spawn(options.python, ['-u', adapter, options.serial], { stdio: ['pipe', 'pipe', 'pipe'] })
|
|
61
|
+
const bridge = child = spawn(options.python, ['-u', adapter, options.serial], { stdio: ['pipe', 'pipe', 'pipe'] })
|
|
62
|
+
const current = connection = { intentional: false }
|
|
63
|
+
lastError = null
|
|
64
|
+
let adapterCause = null, processCause = null
|
|
65
|
+
const safeCause = value => {
|
|
66
|
+
if (value?.kind !== 'serial.error' || !['read', 'write', 'stdout'].includes(value.stage)
|
|
67
|
+
|| typeof value.type !== 'string' || typeof value.message !== 'string') return null
|
|
68
|
+
const integer = n => Number.isSafeInteger(n) && n >= -0x80000000 && n <= 0xffffffff ? n : null
|
|
69
|
+
return { stage: value.stage, type: value.type.slice(0, 64), errno: integer(value.errno),
|
|
70
|
+
winerror: integer(value.winerror), message: value.message.replace(/[\r\n\x00-\x1f\x7f]/g, ' ').slice(0, 160) }
|
|
71
|
+
}
|
|
72
|
+
const errorDetails = (exitCode = null, signal = null) => ({
|
|
73
|
+
...(adapterCause ?? processCause ?? { stage: 'process', type: 'ProcessExit', errno: null, winerror: null,
|
|
74
|
+
message: 'Serial adapter exited unexpectedly' }),
|
|
75
|
+
exitCode: Number.isInteger(exitCode) ? exitCode : null,
|
|
76
|
+
signal: typeof signal === 'string' ? signal.slice(0, 32) : null,
|
|
77
|
+
})
|
|
62
78
|
let ready = false, settled = false, runtimeErrorReported = false, stderr = '', lines = ''
|
|
63
79
|
const abort = () => {
|
|
64
|
-
|
|
80
|
+
current.intentional = true
|
|
81
|
+
bridge.kill('SIGTERM')
|
|
65
82
|
if (!settled) {
|
|
66
83
|
settled = true
|
|
67
84
|
reject(fail('CANCELLED', 'Serial connection cancelled'))
|
|
68
85
|
}
|
|
69
86
|
}
|
|
70
87
|
const cleanup = () => signal.removeEventListener('abort', abort)
|
|
71
|
-
const serialError =
|
|
72
|
-
const code = ['SERIAL_ACCESS_DENIED', 'SERIAL_BUSY'].find(code => stderr.includes(code)) ?? 'SERIAL_ERROR'
|
|
73
|
-
|
|
88
|
+
const serialError = details => {
|
|
89
|
+
const code = ready ? 'SERIAL_ERROR' : ['SERIAL_ACCESS_DENIED', 'SERIAL_BUSY'].find(code => stderr.includes(code)) ?? 'SERIAL_ERROR'
|
|
90
|
+
const message = ready && details?.stage !== 'process'
|
|
91
|
+
? `Serial adapter ${details.stage} failed: ${details.message}`
|
|
92
|
+
: 'Serial adapter failed; ensure Python, pyserial and port access are available'
|
|
93
|
+
const error = fail(code, message)
|
|
94
|
+
error.details = details ?? errorDetails()
|
|
95
|
+
error.cause = Object.assign(new Error(error.details.message), { ...error.details, name: error.details.type })
|
|
96
|
+
return error
|
|
74
97
|
}
|
|
75
|
-
const failBeforeReady =
|
|
98
|
+
const failBeforeReady = details => {
|
|
76
99
|
if (settled) return
|
|
77
100
|
settled = true
|
|
78
101
|
cleanup()
|
|
79
|
-
const error = serialError()
|
|
102
|
+
const error = serialError(details)
|
|
80
103
|
reject(error)
|
|
81
104
|
events.emit('error', error)
|
|
82
105
|
}
|
|
83
|
-
const reportRuntimeError =
|
|
106
|
+
const reportRuntimeError = details => {
|
|
84
107
|
if (runtimeErrorReported) return
|
|
85
108
|
runtimeErrorReported = true
|
|
86
|
-
|
|
109
|
+
const error = serialError(details)
|
|
110
|
+
lastError = { ...error.details }
|
|
111
|
+
events.emit('error', error)
|
|
112
|
+
}
|
|
113
|
+
const recordProcessError = (stage, error) => {
|
|
114
|
+
processCause = { stage, type: 'ProcessError', errno: null, winerror: null,
|
|
115
|
+
message: typeof error?.code === 'string' && /^[A-Z0-9_]{1,32}$/.test(error.code)
|
|
116
|
+
? `Serial adapter ${stage} failed (${error.code})` : `Serial adapter ${stage} failed` }
|
|
87
117
|
}
|
|
88
|
-
child.on('error',
|
|
89
|
-
child.stdin.on('error',
|
|
118
|
+
child.on('error', error => recordProcessError('process', error))
|
|
119
|
+
child.stdin.on('error', error => recordProcessError('stdin', error))
|
|
90
120
|
child.stdout.on('data', bytes => events.emit('data', bytes))
|
|
91
121
|
child.stderr.on('data', bytes => {
|
|
92
122
|
// Do not print Python errors: they can contain host paths or device data.
|
|
@@ -105,6 +135,8 @@ export function serialTransport(options) {
|
|
|
105
135
|
}
|
|
106
136
|
let response
|
|
107
137
|
try { response = JSON.parse(line) } catch { continue }
|
|
138
|
+
const diagnostic = safeCause(response)
|
|
139
|
+
if (diagnostic) { adapterCause = diagnostic; continue }
|
|
108
140
|
const request = pending.get(response.id)
|
|
109
141
|
if (!request) continue
|
|
110
142
|
pending.delete(response.id)
|
|
@@ -119,7 +151,23 @@ export function serialTransport(options) {
|
|
|
119
151
|
})
|
|
120
152
|
// Wait for close rather than exit: stdout/stderr may still have buffered
|
|
121
153
|
// data after exit, including the adapter's stable SERIAL_BUSY marker.
|
|
122
|
-
child.on('close', () => {
|
|
154
|
+
child.on('close', (code, signal) => {
|
|
155
|
+
cleanup()
|
|
156
|
+
if (connection !== current) return
|
|
157
|
+
const details = errorDetails(code, signal)
|
|
158
|
+
if (current.intentional) {
|
|
159
|
+
const error = fail('SERIAL_CLOSED', 'Serial adapter closed')
|
|
160
|
+
rejectControls(error)
|
|
161
|
+
if (!settled) { settled = true; reject(error) }
|
|
162
|
+
} else {
|
|
163
|
+
lastError = { ...details }
|
|
164
|
+
const error = serialError(details)
|
|
165
|
+
rejectControls(error)
|
|
166
|
+
if (!ready) failBeforeReady(details)
|
|
167
|
+
else reportRuntimeError(details)
|
|
168
|
+
}
|
|
169
|
+
events.emit('close')
|
|
170
|
+
})
|
|
123
171
|
signal.addEventListener('abort', abort, { once: true })
|
|
124
172
|
if (signal.aborted) abort()
|
|
125
173
|
})
|
|
@@ -158,8 +206,9 @@ export function serialTransport(options) {
|
|
|
158
206
|
send(1, Buffer.from(JSON.stringify({ id, baudRate: speed }))).catch(error => pending.get(id)?.reject(error))
|
|
159
207
|
})
|
|
160
208
|
},
|
|
161
|
-
getDiagnostics() { return { baudRate } },
|
|
209
|
+
getDiagnostics() { return { baudRate, ...(lastError ? { lastError: { ...lastError } } : {}) } },
|
|
162
210
|
async disconnect() {
|
|
211
|
+
if (connection) connection.intentional = true
|
|
163
212
|
rejectControls(fail('SERIAL_CLOSED', 'Serial adapter closed'))
|
|
164
213
|
if (!child?.pid || child.exitCode !== null || child.signalCode !== null) return
|
|
165
214
|
await new Promise(resolveExit => {
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
// This option is validated before opening a port or loading a ROM backend.
|
|
5
|
+
export function normalizeExpectedMac(value) {
|
|
6
|
+
if (value === undefined) return undefined
|
|
7
|
+
if (typeof value !== 'string' || value.length !== 12 || !/^[0-9a-f]{12}$/i.test(value)) throw new TypeError('expectedMac must contain exactly 12 hexadecimal characters')
|
|
8
|
+
return value.toLowerCase()
|
|
9
|
+
}
|
package/tests/check-browser.mjs
CHANGED
|
@@ -50,12 +50,13 @@ try {
|
|
|
50
50
|
async setSignals() { throw new Error('OTG must not require UART control signals') },
|
|
51
51
|
})
|
|
52
52
|
try {
|
|
53
|
-
prototype.detectChip = async function () { detects++; await this.transport.connect(115200); this.chip = { CHIP_NAME: 'ESP32-S31', getChipRevision: async () => 0 } }
|
|
53
|
+
prototype.detectChip = async function () { detects++; await this.transport.connect(115200); this.chip = { CHIP_NAME: 'ESP32-S31', getChipRevision: async () => 0, readMac: async () => '30:ed:a0:ed:a1:b0' } }
|
|
54
54
|
prototype.getSecurityInfo = async () => ({ flags: 0, flashCryptCnt: 0 })
|
|
55
|
-
const controller = new AbortController(), loader = context.DeviceCore.openBrowserRom({ port: fakePort(), signal: controller.signal })
|
|
55
|
+
const controller = new AbortController(), loader = context.DeviceCore.openBrowserRom({ port: fakePort(), signal: controller.signal, expectedMac: '30EDA0EDA1B0' })
|
|
56
|
+
assert.equal(loader.chipMac, null)
|
|
56
57
|
const first = loader.initialize()
|
|
57
58
|
assert.equal(loader.initialize(), first)
|
|
58
|
-
await first; controller.abort()
|
|
59
|
+
await first; assert.equal(loader.chipMac, '30eda0eda1b0'); controller.abort()
|
|
59
60
|
assert.equal(closes, 0, 'abort after initialization preserves ongoing flash')
|
|
60
61
|
await loader.disconnect(); await loader.disconnect()
|
|
61
62
|
assert.equal(closes, 1); assert.equal(detects, 1)
|
|
@@ -73,6 +74,18 @@ try {
|
|
|
73
74
|
cancelled.abort(); await assert.rejects(starting, { name: 'AbortError' })
|
|
74
75
|
finishOpen(); await new Promise(resolve => setTimeout(resolve, 5))
|
|
75
76
|
assert.equal(closes, 3); assert.equal(waitingPort.readable, null)
|
|
77
|
+
prototype.runStub = async function () { this.IS_STUB = true }
|
|
78
|
+
let finishErase
|
|
79
|
+
prototype.checkCommand = () => new Promise(resolve => { finishErase = resolve })
|
|
80
|
+
const eraseCancel = new AbortController(), erasePort = fakePort()
|
|
81
|
+
const eraseLoader = context.DeviceCore.openBrowserRom({ port: erasePort, signal: eraseCancel.signal })
|
|
82
|
+
await eraseLoader.initialize(); const eraseStub = await eraseLoader.runStub()
|
|
83
|
+
const erasing = eraseStub.eraseRegion(0x710000, 4096)
|
|
84
|
+
const failedErase = assert.rejects(erasing, { name: 'AbortError' })
|
|
85
|
+
eraseCancel.abort(); await failedErase
|
|
86
|
+
finishErase(0)
|
|
87
|
+
assert.equal(closes, 4); assert.equal(erasePort.readable, null)
|
|
88
|
+
await assert.rejects(eraseStub.eraseRegion(0x710000, 4096))
|
|
76
89
|
const data = new Uint8Array([0xe9, 1, 2, 3]), table = new Uint8Array([4, 5])
|
|
77
90
|
const hash = async bytes => Array.from(new Uint8Array(await webcrypto.subtle.digest('SHA-256', bytes)), byte => byte.toString(16).padStart(2, '0')).join('')
|
|
78
91
|
const release = { board: 'BOARD', version: 'browser-test', image: {
|
|
@@ -102,10 +115,16 @@ try {
|
|
|
102
115
|
this.transport.buffer = new Uint8Array([...frame(bytes), ...frame(digest)])
|
|
103
116
|
return 0
|
|
104
117
|
}
|
|
118
|
+
const wrongPort = fakePort(), wrong = context.DeviceCore.openBrowserRom({ port: wrongPort, expectedMac: '30eda0eda1b1' })
|
|
119
|
+
const beforeStubs = stubs, beforeWrites = writes
|
|
120
|
+
await assert.rejects(wrong.initialize(), { code: 'ROM_IDENTITY_CHANGED' })
|
|
121
|
+
assert.equal(wrong.chipMac, null); assert.equal(wrongPort.readable, null)
|
|
122
|
+
await assert.rejects(async () => wrong.runStub(), { code: 'ROM_CLOSED' })
|
|
123
|
+
assert.equal(stubs, beforeStubs); assert.equal(writes, beforeWrites)
|
|
105
124
|
const updatePort = fakePort(), openUpdate = updatePort.open
|
|
106
125
|
updatePort.open = async function (...args) { await openUpdate.apply(this, args); this.writable = { getWriter: () => ({ async write() {}, releaseLock() {} }) } }
|
|
107
126
|
const beforeCloses = closes
|
|
108
|
-
const updated = await device.firmware.update({ ...request, signal: writing.signal, openLoader: ({ signal }) => context.DeviceCore.openBrowserRom({ port: updatePort, signal }) })
|
|
127
|
+
const updated = await device.firmware.update({ ...request, signal: writing.signal, openLoader: ({ signal }) => context.DeviceCore.openBrowserRom({ port: updatePort, signal, expectedMac: '30eda0eda1b0' }) })
|
|
109
128
|
assert.equal(updated.verified, true); assert.equal(updated.needsReset, false)
|
|
110
129
|
assert.equal(stubs, 1); assert.equal(writes, 1); assert.equal(resets, 4)
|
|
111
130
|
assert.equal(closes, beforeCloses + 1)
|
package/tests/check-package.mjs
CHANGED
|
@@ -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')
|
|
@@ -44,6 +45,13 @@ try {
|
|
|
44
45
|
const { defaultPython, filterSerialPorts, inventory, listSerial, serialEntry, tcpTransport, serialTransport, transportFor, openNodeRom } = await import('@esp-vision/device-core/node');
|
|
45
46
|
const { openBrowserRom } = await import('@esp-vision/device-core/browser');
|
|
46
47
|
if (typeof openBrowserRom !== 'function' || typeof openBrowserRom({ port: { open() { throw new Error('Not opened'); }, close() {} } }).initialize !== 'function') throw new Error('Missing passive browser ROM adapter');
|
|
48
|
+
const romPort = { open() { throw new Error('Not opened'); }, close() {} };
|
|
49
|
+
const browserRom = openBrowserRom({ port: romPort, expectedMac: '30EDA0EDA1B0' });
|
|
50
|
+
if (!Object.hasOwn(browserRom, 'chipMac') || browserRom.chipMac !== null) throw new Error('Missing passive browser ROM MAC contract');
|
|
51
|
+
let rejectedExpectedMac = false;
|
|
52
|
+
try { openBrowserRom({ port: romPort, expectedMac: '30:ed:a0:ed:a1:b0' }); } catch (error) { rejectedExpectedMac = error instanceof TypeError; }
|
|
53
|
+
if (!rejectedExpectedMac) throw new Error('Installed browser ROM must validate expectedMac before opening');
|
|
54
|
+
await browserRom.disconnect();
|
|
47
55
|
if (typeof tcpTransport !== 'function' || typeof serialTransport !== 'function') throw new Error('Missing public Node transports');
|
|
48
56
|
if (typeof inventory !== 'function' || typeof listSerial !== 'function' || typeof filterSerialPorts !== 'function'
|
|
49
57
|
|| typeof serialEntry !== 'function' || typeof transportFor !== 'function' || typeof defaultPython !== 'string') throw new Error('Missing public Node inventory APIs');
|
|
@@ -62,22 +70,22 @@ try {
|
|
|
62
70
|
api.createDevice({ transport: serialTransport({ python: 'python3', serial: 'not-opened' }) });
|
|
63
71
|
if (typeof DeviceSession !== 'function' || typeof FirmwareFlashSession !== 'function') throw new Error('Missing host adapter APIs');`
|
|
64
72
|
execFileSync(process.execPath, ['--input-type=module', '-e', script], { cwd: consumer, stdio: 'pipe' })
|
|
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']) {
|
|
73
|
+
for (const required of ['src/node_serial.py', 'src/jpeg_validate.js', 'src/rom_identity.js', 'scripts/run-tests.mjs', 'tests/hardware-check.mjs', 'tests/device-checks.mjs', 'tests/jpeg-fixtures.mjs', 'tests/test-jpeg-validation.mjs', 'tests/fixtures/device-checks.py']) {
|
|
66
74
|
assert.ok(packed.files.some(({ path }) => path === required), `Missing installed check asset: ${required}`)
|
|
67
75
|
}
|
|
68
|
-
const unitOutput = execFileSync(
|
|
76
|
+
const unitOutput = execFileSync(...npmCommand(['test']), { cwd: installed, encoding: 'utf8', maxBuffer: 4 * 1024 * 1024 })
|
|
69
77
|
const unitCount = Number(/^(?:# tests|ℹ tests) (\d+)$/m.exec(unitOutput)?.[1] ?? 0)
|
|
70
78
|
assert.ok(unitCount > 0, 'Installed npm test must execute a nonempty suite')
|
|
71
|
-
const hardwareHelp = execFileSync(
|
|
79
|
+
const hardwareHelp = execFileSync(...npmCommand(['run', 'test:hardware', '--', '--help']), { cwd: installed, encoding: 'utf8' })
|
|
72
80
|
assert.ok(hardwareHelp.includes('--serial PATH'), 'Installed hardware runner must load and show its help')
|
|
73
81
|
// A damaged tarball must fail instead of reporting a successful zero-test run.
|
|
74
82
|
await rm(join(installed, 'tests'), { recursive: true })
|
|
75
83
|
await mkdir(join(installed, 'tests'))
|
|
76
|
-
const empty = spawnSync(
|
|
84
|
+
const empty = spawnSync(...npmCommand(['test']), { cwd: installed, encoding: 'utf8' })
|
|
77
85
|
assert.equal(empty.status, 1, 'An empty suite must fail closed')
|
|
78
86
|
assert.match(empty.stderr, /No Device Core unit tests found/)
|
|
79
87
|
await rm(join(installed, 'tests'), { recursive: true })
|
|
80
|
-
const missing = spawnSync(
|
|
88
|
+
const missing = spawnSync(...npmCommand(['test']), { cwd: installed, encoding: 'utf8' })
|
|
81
89
|
assert.equal(missing.status, 1, 'Missing tests must fail closed')
|
|
82
90
|
console.log(JSON.stringify({ package: packed.name, version: packed.version, entryPoints, unitCount, standalone: true }))
|
|
83
91
|
} finally {
|
package/tests/device-checks.mjs
CHANGED
|
@@ -2,10 +2,15 @@
|
|
|
2
2
|
// Application-level HIL checks. All device I/O goes through createDevice's API.
|
|
3
3
|
import { readFile, mkdir, writeFile } from 'node:fs/promises'
|
|
4
4
|
import { dirname } from 'node:path'
|
|
5
|
+
import { fileURLToPath } from 'node:url'
|
|
6
|
+
import { createHash } from 'node:crypto'
|
|
5
7
|
import { EV_REPL_INPUT_MAX_BYTES, evReplExecInputBytes } from '../src/evrepl.js'
|
|
6
8
|
import { validateJpegImage } from '../src/jpeg_validate.js'
|
|
7
9
|
|
|
8
|
-
export const DEVICE_CHECKS = Object.freeze(['identity', 'memory', 'camera', 'framesizes', 'pixformats', 'orientation', 'ipa_stats', 'snapshot_rate', 'preview', 'image_ops', 'display', 'display_stream', 'camera_display_lifecycle', 'image_copy_to_fb', 'image_transform_argument', 'sensor_snapshot_buffer', 'memory_policy'])
|
|
10
|
+
export const DEVICE_CHECKS = Object.freeze(['identity', 'memory', 'camera', 'framesizes', 'pixformats', 'orientation', 'ipa_stats', 'snapshot_rate', 'preview', 'image_ops', 'display', 'display_stream', 'camera_display_lifecycle', 'image_copy_to_fb', 'image_transform_argument', 'sensor_snapshot_buffer', 'memory_policy', 'espdl_inference', 'isp_ccm_frames', 'isp_lsc_display'])
|
|
11
|
+
// The npm package ships tests without models; repository checkouts use the bundled face detector.
|
|
12
|
+
export const DEFAULT_ESPDL_SHA256 = '04eec2220adbf642a843c953817fb219a06c094aedb76c181ba2a958c65201dc'
|
|
13
|
+
export const DEFAULT_ESPDL_MODEL = fileURLToPath(new URL('../../../models/espdet/pico/face/espdet_pico_224_224_face.espdl', import.meta.url))
|
|
9
14
|
const MARKER = '__EVDEVICECHECK__'
|
|
10
15
|
const failure = (code, message) => Object.assign(new Error(message), { code })
|
|
11
16
|
const unsupported = error => error?.code === 'UNSUPPORTED_OPERATION' || /ESP_ERR_NOT_SUPPORTED|ESP_ERR_NOT_FOUND|no module named|ENODEV/i.test(error?.message ?? '')
|
|
@@ -44,7 +49,7 @@ export async function runDeviceChecks(device, options, { signal, nonce, report =
|
|
|
44
49
|
const results = [], namespace = `_evhil_${nonce}`
|
|
45
50
|
let loaded = false, namespaceStarted = false, loadError = null, inputLimit = EV_REPL_INPUT_MAX_BYTES
|
|
46
51
|
const parameters = { frames: options.frames, framesize: options.framesize, pixformat: options.pixformat, displayHold: options.displayHold, screenCapture: Boolean(options.screenOutput) }
|
|
47
|
-
const timeoutFor = name => 1000 * (name === 'display' ? 30 + options.displayHold * 3 : name === 'camera_display_lifecycle' ? options.frameTimeout + 80 : name === 'memory_policy' ? options.frameTimeout + 60 : name === 'sensor_snapshot_buffer' ? options.frameTimeout + 40 : name === 'image_copy_to_fb' ? 30 : name === 'ipa_stats' ? options.frameTimeout + 40 : options.frameTimeout + Math.max(30, options.frames * (name === 'display_stream' ? 2 : 1)))
|
|
52
|
+
const timeoutFor = name => 1000 * (name === 'display' ? 30 + options.displayHold * 3 : name === 'camera_display_lifecycle' ? options.frameTimeout + 80 : name === 'memory_policy' ? options.frameTimeout + 60 : name === 'sensor_snapshot_buffer' ? options.frameTimeout + 40 : name === 'image_copy_to_fb' ? 30 : name === 'espdl_inference' ? options.frameTimeout + 60 + options.frames * 2 : name === 'ipa_stats' ? options.frameTimeout + 40 : options.frameTimeout + Math.max(30, options.frames * (name === 'display_stream' ? 2 : 1)))
|
|
48
53
|
const execute = async (source, timeout = 10000, operationSignal = signal) => {
|
|
49
54
|
// Larger python.execute() requests use temporary files in Device Core.
|
|
50
55
|
// Keep fixtures in RAM by respecting the actual encoded REPL-line budget.
|
|
@@ -53,6 +58,34 @@ export async function runDeviceChecks(device, options, { signal, nonce, report =
|
|
|
53
58
|
if (result.ok === false || result.outputTruncated) throw failure('PYTHON_CHECK', 'Device Python check did not complete')
|
|
54
59
|
return result.output
|
|
55
60
|
}
|
|
61
|
+
const acknowledged = (output, operation, expected) => {
|
|
62
|
+
let reply
|
|
63
|
+
try {
|
|
64
|
+
const line = output.trim()
|
|
65
|
+
if (!line.startsWith('__EVHILACK__')) throw new Error()
|
|
66
|
+
reply = JSON.parse(line.slice('__EVHILACK__'.length))
|
|
67
|
+
} catch { throw failure('MODEL_ACK_INVALID', 'Missing or malformed hardware fixture acknowledgement') }
|
|
68
|
+
// Python dictionaries need not preserve the host's insertion order.
|
|
69
|
+
if (!Array.isArray(reply) || reply.length !== 3 || reply[0] !== nonce || reply[1] !== operation || !reply[2] || typeof reply[2] !== 'object' || Array.isArray(reply[2]) ||
|
|
70
|
+
Object.keys(reply[2]).length !== Object.keys(expected).length || Object.entries(expected).some(([key, value]) => reply[2][key] !== value)) {
|
|
71
|
+
throw failure('MODEL_ACK_INVALID', 'Hardware fixture acknowledgement does not match the operation')
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const modelSource = (operation, args) => `${namespace}['model_command'](${JSON.stringify(operation)}${args.length ? ',' + args.map(value => JSON.stringify(value)).join(',') : ''})`
|
|
75
|
+
const modelCall = async (operation, args, expected, { retry = false, timeout = 10000, operationSignal = signal } = {}) => {
|
|
76
|
+
for (let attempt = 1; ; attempt++) {
|
|
77
|
+
cancelled(operationSignal)
|
|
78
|
+
try {
|
|
79
|
+
// Re-enter the public API for a fresh source variable and its existing
|
|
80
|
+
// Ctrl-C + exec('1') synchronization. Never replay an SDK += line.
|
|
81
|
+
acknowledged(await execute(modelSource(operation, args), timeout, operationSignal), operation, expected)
|
|
82
|
+
return attempt - 1
|
|
83
|
+
} catch (error) {
|
|
84
|
+
cancelled(operationSignal)
|
|
85
|
+
if (!retry || error.code !== 'TIMEOUT' || attempt === 3) throw error
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
56
89
|
const load = async () => {
|
|
57
90
|
if (loaded) return
|
|
58
91
|
if (loadError) throw loadError
|
|
@@ -62,7 +95,7 @@ export async function runDeviceChecks(device, options, { signal, nonce, report =
|
|
|
62
95
|
if (Number.isSafeInteger(advertised) && advertised > 0) inputLimit = Math.min(inputLimit, advertised)
|
|
63
96
|
const source = await readFile(new URL('./fixtures/device-checks.py', import.meta.url), 'utf8')
|
|
64
97
|
namespaceStarted = true
|
|
65
|
-
await execute(`${namespace}={'_source':[]}`)
|
|
98
|
+
await execute(`${namespace}={'_source':[],'_fixture_nonce':${JSON.stringify(nonce)}}`)
|
|
66
99
|
const append = chunk => `${namespace}['_source'].append(${JSON.stringify(chunk)})`
|
|
67
100
|
let chunk = ''
|
|
68
101
|
for (const character of source) {
|
|
@@ -78,15 +111,58 @@ export async function runDeviceChecks(device, options, { signal, nonce, report =
|
|
|
78
111
|
loaded = true
|
|
79
112
|
} catch (error) { loadError = error; throw error }
|
|
80
113
|
}
|
|
81
|
-
const invoke = async name => {
|
|
114
|
+
const invoke = async (name, extra = {}) => {
|
|
82
115
|
await load()
|
|
83
|
-
const
|
|
116
|
+
const selected = name === 'espdl_inference' ? { frames: parameters.frames, framesize: parameters.framesize, ...extra } : { ...parameters, ...extra }
|
|
117
|
+
const output = await execute(`${namespace}['run_check'](${JSON.stringify(name)},${JSON.stringify(JSON.stringify(selected))})`, timeoutFor(name))
|
|
84
118
|
return parseCheckResult(output, name)
|
|
85
119
|
}
|
|
86
120
|
const requirePass = result => {
|
|
87
121
|
if (result.status !== 'PASS') throw Object.assign(failure(result.status === 'SKIP' ? 'UNSUPPORTED_OPERATION' : 'DEVICE_CHECK_FAILED', result.error.message), { checkResult: result })
|
|
88
122
|
return result.data
|
|
89
123
|
}
|
|
124
|
+
// ESPDet accepts VFS streams, including a read-only BytesIO-backed mount.
|
|
125
|
+
// Keep inference acceptance independent of persistent storage provisioning.
|
|
126
|
+
const espdlInference = async () => {
|
|
127
|
+
let model
|
|
128
|
+
try { model = new Uint8Array(await readFile(options.model ?? DEFAULT_ESPDL_MODEL)) }
|
|
129
|
+
catch (error) { throw failure('MODEL_UNAVAILABLE', `Cannot read ESP-DL model: ${error.code ?? 'read failed'}`) }
|
|
130
|
+
if (!model.length || model.length > 2 * 1024 * 1024) throw failure('MODEL_INVALID', 'ESP-DL acceptance model must be 1 byte to 2 MiB')
|
|
131
|
+
const sha256 = createHash('sha256').update(model).digest('hex')
|
|
132
|
+
const expected = options.modelSha256 ?? (options.model ? null : DEFAULT_ESPDL_SHA256)
|
|
133
|
+
if (expected && expected !== sha256) throw failure('MODEL_HASH_MISMATCH', 'ESP-DL model differs from the expected SHA-256')
|
|
134
|
+
await load()
|
|
135
|
+
const path = `/evhil_${nonce}`
|
|
136
|
+
let started = false, chunks = 0, retries = 0
|
|
137
|
+
const retryOffsets = []
|
|
138
|
+
const transferStarted = Date.now()
|
|
139
|
+
try {
|
|
140
|
+
started = true
|
|
141
|
+
await modelCall('begin', [nonce, model.length, path], { size: model.length, offset: 0, path })
|
|
142
|
+
const append = (chunk, offset) => modelSource('append', [offset, Buffer.from(chunk).toString('base64')])
|
|
143
|
+
// Base64 carries binary model bytes without the temporary-file execution
|
|
144
|
+
// path or a second host protocol implementation. Honor the live limit.
|
|
145
|
+
const chunkBytes = Math.floor((inputLimit - evReplExecInputBytes(append([], model.length))) / 4) * 3
|
|
146
|
+
if (chunkBytes < 3) throw failure('REPL_INPUT_LIMIT', 'Device REPL input limit cannot load the inference model')
|
|
147
|
+
for (let offset = 0; offset < model.length; offset += chunkBytes) {
|
|
148
|
+
cancelled(signal)
|
|
149
|
+
const chunk = model.subarray(offset, offset + chunkBytes), end = offset + chunk.length
|
|
150
|
+
const recovered = await modelCall('append', [offset, Buffer.from(chunk).toString('base64')], { offset, end, bytes: chunk.length, current: end }, { retry: true })
|
|
151
|
+
retries += recovered
|
|
152
|
+
if (recovered) retryOffsets.push({ offset, retries: recovered })
|
|
153
|
+
chunks++
|
|
154
|
+
}
|
|
155
|
+
await modelCall('mount', [sha256], { path, bytes: model.length, sha256 })
|
|
156
|
+
const modelTransferMs = Date.now() - transferStarted
|
|
157
|
+
const data = requirePass(await invoke('espdl_inference', { modelPath: `${path}/model.espdl` }))
|
|
158
|
+
return { ...data, modelBytes: model.length, modelSha256: sha256, modelStorage: 'ram-vfs', modelChunks: chunks, modelTransferMs, modelRetries: retries, modelRetryOffsets: retryOffsets }
|
|
159
|
+
} finally {
|
|
160
|
+
if (started) {
|
|
161
|
+
try { await modelCall('close', [], { bytes: 0, offset: 0, mounted: false }, { timeout: 5000, operationSignal: AbortSignal.timeout(6000) }) }
|
|
162
|
+
catch { throw failure('MODEL_CLEANUP_FAILED', 'Could not release the temporary RAM model mount') }
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
90
166
|
const record = result => { results.push(result); report({ check: result.name, ...result, name: undefined }) }
|
|
91
167
|
try {
|
|
92
168
|
for (const name of options.checks) {
|
|
@@ -119,6 +195,8 @@ export async function runDeviceChecks(device, options, { signal, nonce, report =
|
|
|
119
195
|
if (!count) throw failure('PREVIEW_EMPTY', 'No preview frame arrived')
|
|
120
196
|
data.received = count
|
|
121
197
|
} finally { unsubscribe() }
|
|
198
|
+
} else if (name === 'espdl_inference') {
|
|
199
|
+
data = await espdlInference()
|
|
122
200
|
} else {
|
|
123
201
|
const response = await invoke(name)
|
|
124
202
|
if (name === 'ipa_stats' && response.status === 'SKIP') {
|
|
@@ -137,7 +215,7 @@ export async function runDeviceChecks(device, options, { signal, nonce, report =
|
|
|
137
215
|
result = { name, status: 'PASS', data }
|
|
138
216
|
} catch (error) {
|
|
139
217
|
cancelled(signal)
|
|
140
|
-
const skip = unsupported(error) && !options.requireAll && !(name === 'display' && options.screenOutput)
|
|
218
|
+
const skip = (unsupported(error) || error.code === 'MODEL_UNAVAILABLE') && !options.requireAll && !(name === 'display' && options.screenOutput)
|
|
141
219
|
result = { name, status: skip ? 'SKIP' : 'FAIL', error: { code: error.code ?? 'DEVICE_CHECK_FAILED', message: String(error.message ?? 'Device check failed').slice(0, 240) } }
|
|
142
220
|
} finally {
|
|
143
221
|
if (name === 'display' && loaded) {
|
|
@@ -152,7 +230,11 @@ export async function runDeviceChecks(device, options, { signal, nonce, report =
|
|
|
152
230
|
const cleanupSignal = AbortSignal.timeout(10000)
|
|
153
231
|
let cleanupError
|
|
154
232
|
if (namespaceStarted) {
|
|
155
|
-
try {
|
|
233
|
+
try {
|
|
234
|
+
await execute(`if ${JSON.stringify(namespace)} in globals():\n ${namespace}.get('close_display',lambda:None)()\n ${namespace}.get('close_model',lambda:None)()\n del ${namespace}`, 5000, cleanupSignal)
|
|
235
|
+
const marker = '__EVHILACK__' + JSON.stringify([nonce, 'cleanup', { absent: true }])
|
|
236
|
+
acknowledged(await execute(`assert ${JSON.stringify(namespace)} not in globals();print(${JSON.stringify(marker)})`, 5000, cleanupSignal), 'cleanup', { absent: true })
|
|
237
|
+
}
|
|
156
238
|
catch (error) { cleanupError = error }
|
|
157
239
|
}
|
|
158
240
|
if (cleanupError) record({ name: 'fixture-cleanup', status: 'FAIL', error: { code: 'FIXTURE_CLEANUP_FAILED', message: 'Could not release the hardware fixture namespace' } })
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
import { createHash } from 'node:crypto'
|
|
4
|
+
import { createDevice } from '../src/index.js'
|
|
5
|
+
import { firmwareSha256 } from '../src/firmware_flash.js'
|
|
6
|
+
|
|
7
|
+
export function partitionTable(entries = [
|
|
8
|
+
{ label: 'nvs', type: 1, subtype: 2, offset: 0x9000, size: 0x6000 },
|
|
9
|
+
{ label: 'phy_init', type: 1, subtype: 1, offset: 0xf000, size: 0x1000 },
|
|
10
|
+
{ label: 'factory', type: 0, subtype: 0, offset: 0x10000, size: 0x10000 },
|
|
11
|
+
{ label: 'ffat', type: 1, subtype: 0x81, offset: 0x20000, size: 0x21000 },
|
|
12
|
+
], { md5 = true, size = 0xc00 } = {}) {
|
|
13
|
+
const bytes = new Uint8Array(size).fill(0xff), view = new DataView(bytes.buffer)
|
|
14
|
+
entries.forEach((entry, index) => {
|
|
15
|
+
const offset = index * 32
|
|
16
|
+
bytes.fill(0, offset, offset + 32)
|
|
17
|
+
view.setUint16(offset, 0x50aa, true)
|
|
18
|
+
bytes[offset + 2] = entry.type
|
|
19
|
+
bytes[offset + 3] = entry.subtype
|
|
20
|
+
view.setUint32(offset + 4, entry.offset, true)
|
|
21
|
+
view.setUint32(offset + 8, entry.size, true)
|
|
22
|
+
bytes.set(new TextEncoder().encode(entry.label), offset + 12)
|
|
23
|
+
view.setUint32(offset + 28, entry.flags ?? 0, true)
|
|
24
|
+
})
|
|
25
|
+
if (md5) {
|
|
26
|
+
const offset = entries.length * 32
|
|
27
|
+
bytes.set([0xeb, 0xeb], offset)
|
|
28
|
+
bytes.set(createHash('md5').update(bytes.subarray(0, offset)).digest(), offset + 16)
|
|
29
|
+
}
|
|
30
|
+
return bytes
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function installFixture() {
|
|
34
|
+
const data = new Uint8Array(0x11000).fill(0xff), calls = []
|
|
35
|
+
data.set([0xe9, 1, 2, 3], 0x2000)
|
|
36
|
+
data.set(partitionTable(), 0x8000)
|
|
37
|
+
data.set([0xe9, 6, 7, 8], 0x10000)
|
|
38
|
+
const part = async (offset, size = 4) => ({ offset, bytes: size, sha256: await firmwareSha256(data.subarray(offset, offset + size)) })
|
|
39
|
+
const release = { board: 'BOARD', version: 'new', image: {
|
|
40
|
+
evMuxVersion: 4, chipFamily: 0x3280, chipRevision: 302, ...(await part(0x10000)),
|
|
41
|
+
partitionSize: 0x10000, partitionTable: await part(0x8000, 0xc00), url: 'https://example.test/app.bin',
|
|
42
|
+
}, install: { offset: 0, bytes: data.length, flashSize: 0x1000000, chipRevisionMin: 302, chipRevisionMax: 302,
|
|
43
|
+
sha256: await firmwareSha256(data), bootloader: await part(0x2000), url: 'https://example.test/full.bin' } }
|
|
44
|
+
const flash = new Uint8Array(0x41000).fill(0x5a)
|
|
45
|
+
const loader = {
|
|
46
|
+
chipFamily: 0x3280, chipRevision: 302,
|
|
47
|
+
async initialize() { calls.push('initialize') },
|
|
48
|
+
async getSecurityInfo() { return { flags: 0, flashCryptCnt: 0 } },
|
|
49
|
+
async runStub() { calls.push('stub'); return loader },
|
|
50
|
+
async flashId() { calls.push('capacity'); return 0x1840ef },
|
|
51
|
+
async eraseRegion(offset, size) { calls.push(['erase', offset, size]); flash.fill(0xff, offset, offset + size) },
|
|
52
|
+
async flashData(buffer, progress, offset) { calls.push(['write', offset]); flash.set(new Uint8Array(buffer), offset); progress(buffer.byteLength, buffer.byteLength) },
|
|
53
|
+
async readFlash(offset, count) { calls.push(['read', offset]); return flash.slice(offset, offset + count) },
|
|
54
|
+
async readRegister() { return 0 },
|
|
55
|
+
async hardReset() { calls.push('reset') },
|
|
56
|
+
async disconnect() { calls.push('close') },
|
|
57
|
+
}
|
|
58
|
+
const device = createDevice(), request = { mode: 'install', release, board: 'BOARD', data, async openLoader() { calls.push('open'); return loader } }
|
|
59
|
+
return { data, calls, release, loader, device, request, flash }
|
|
60
|
+
}
|