@esp-vision/device-core 0.2.0 → 0.4.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 +24 -0
- package/README.md +50 -7
- package/package.json +12 -6
- package/schemas/device-actions-v1.json +1 -1
- package/scripts/verify-registry.mjs +2 -2
- package/src/browser.js +61 -0
- package/src/browser_rom.js +9 -0
- package/src/browser_rom_compat.js +65 -0
- package/src/cli.js +14 -8
- package/src/firmware.js +4 -0
- package/src/firmware_feed.js +117 -3
- package/src/firmware_flash.js +39 -11
- package/src/firmware_io.js +68 -0
- package/src/firmware_release.js +47 -0
- package/src/firmware_updates.js +31 -24
- package/src/node.js +5 -0
- package/src/node_rom.js +1 -0
- package/src/node_rom.py +2 -0
- package/tests/check-browser.mjs +151 -0
- package/tests/check-package.mjs +6 -2
- package/tests/test-browser-rom.mjs +85 -0
- package/tests/test-firmware-install.mjs +126 -0
- package/tests/test-firmware-source.mjs +143 -0
- package/tests/test-release-manifest.mjs +214 -0
- package/tests/test-release-verification.mjs +12 -4
package/src/firmware_flash.js
CHANGED
|
@@ -6,18 +6,37 @@ export async function firmwareSha256(bytes) {
|
|
|
6
6
|
return Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, '0')).join('')
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
-
export
|
|
9
|
+
export function firmwareMode(mode = 'update') {
|
|
10
|
+
if (!['update', 'install'].includes(mode)) throw new TypeError('Firmware mode must be update or install')
|
|
11
|
+
return mode
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function validateFirmwareImage(image, bytes, { mode = 'update' } = {}) {
|
|
15
|
+
firmwareMode(mode)
|
|
10
16
|
if (image.evMuxVersion !== 4) throw new Error('Only approved EV-MUX v4 firmware can be installed')
|
|
11
17
|
if (!(bytes instanceof Uint8Array) || bytes.byteLength !== image.bytes) throw new Error('Firmware image size does not match the approved build')
|
|
12
|
-
|
|
13
|
-
|
|
18
|
+
const limit = mode === 'install' ? image.flashSize : image.partitionSize
|
|
19
|
+
if (!Number.isSafeInteger(image.offset) || !Number.isSafeInteger(limit)
|
|
20
|
+
|| image.offset < 0 || image.bytes <= 0 || image.bytes > limit || (mode === 'install' && image.offset !== 0)) throw new Error('Invalid firmware image bounds')
|
|
14
21
|
if (await firmwareSha256(bytes) !== image.sha256) throw new Error('Firmware SHA-256 does not match the approved build')
|
|
22
|
+
if (mode === 'install') {
|
|
23
|
+
const segments = [image.bootloader, image.partitionTable, image.application]
|
|
24
|
+
let end = 0
|
|
25
|
+
for (const segment of segments) {
|
|
26
|
+
if (!segment || !Number.isSafeInteger(segment.offset) || !Number.isSafeInteger(segment.bytes)
|
|
27
|
+
|| segment.offset < end || segment.bytes <= 0 || segment.offset + segment.bytes > bytes.byteLength
|
|
28
|
+
|| !/^[a-f0-9]{64}$/.test(segment.sha256 ?? '')) throw new Error('Complete image requires ordered, non-overlapping bootloader, partition table and application metadata')
|
|
29
|
+
if (await firmwareSha256(bytes.subarray(segment.offset, segment.offset + segment.bytes)) !== segment.sha256) throw new Error('Complete image does not contain the approved bootloader, partition table and application')
|
|
30
|
+
end = segment.offset + segment.bytes
|
|
31
|
+
}
|
|
32
|
+
}
|
|
15
33
|
return bytes
|
|
16
34
|
}
|
|
17
35
|
|
|
18
36
|
/** Loader injected by the browser host; no Web Serial or ROM protocol duplicated. */
|
|
19
37
|
export class FirmwareFlashSession {
|
|
20
|
-
constructor(loader, image, { reset = () => loader.hardReset() } = {}) {
|
|
38
|
+
constructor(loader, image, { reset = () => loader.hardReset(), mode = 'update' } = {}) {
|
|
39
|
+
this.mode = firmwareMode(mode)
|
|
21
40
|
this.loader = loader
|
|
22
41
|
this.reset = reset
|
|
23
42
|
this.image = image
|
|
@@ -29,7 +48,7 @@ export class FirmwareFlashSession {
|
|
|
29
48
|
if (this.state !== 'idle') throw new Error('Firmware loader is already in use')
|
|
30
49
|
this.state = 'connecting'
|
|
31
50
|
try {
|
|
32
|
-
await validateFirmwareImage(this.image, bytes)
|
|
51
|
+
await validateFirmwareImage(this.image, bytes, { mode: this.mode })
|
|
33
52
|
await this.loader.initialize()
|
|
34
53
|
const revisionMatches = this.image.chipRevision !== undefined ? this.loader.chipRevision === this.image.chipRevision
|
|
35
54
|
: Number.isInteger(this.image.chipRevisionMin) && Number.isInteger(this.image.chipRevisionMax) && this.loader.chipRevision >= this.image.chipRevisionMin && this.loader.chipRevision <= this.image.chipRevisionMax
|
|
@@ -43,9 +62,18 @@ export class FirmwareFlashSession {
|
|
|
43
62
|
const encrypted = security.flashCryptCnt.toString(2).replace(/0/g, '').length % 2 === 1
|
|
44
63
|
if ((security.flags & 1) !== 0 || encrypted) throw new Error('Secure boot or encrypted devices require their signed flashing workflow')
|
|
45
64
|
this.stub = await this.loader.runStub(true)
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
65
|
+
if (this.mode === 'install') {
|
|
66
|
+
// JEDEC capacity byte encodes log2(bytes); shared with browser loaders.
|
|
67
|
+
if (typeof this.stub.flashId !== 'function') throw new Error('Installation requires ROM flash capacity detection (flashId)')
|
|
68
|
+
const id = await this.stub.flashId()
|
|
69
|
+
const capacity = (id >>> 16) & 0xff
|
|
70
|
+
if (!Number.isSafeInteger(id) || id <= 0 || id > 0xffffff || capacity < 18 || capacity > 30
|
|
71
|
+
|| 2 ** capacity < this.image.flashSize) throw new Error('Detected flash is unknown or smaller than the approved complete image layout')
|
|
72
|
+
} else {
|
|
73
|
+
const table = this.image.partitionTable
|
|
74
|
+
const existing = await this.stub.readFlash(table.offset, table.bytes)
|
|
75
|
+
if (await firmwareSha256(existing) !== table.sha256) throw new Error('Partition layout differs; select explicit install mode to replace the layout')
|
|
76
|
+
}
|
|
49
77
|
this.state = 'connected'
|
|
50
78
|
} catch (error) { await this.disconnect(); throw error }
|
|
51
79
|
}
|
|
@@ -54,11 +82,11 @@ export class FirmwareFlashSession {
|
|
|
54
82
|
if (this.state !== 'connected') throw new Error('Connect and validate the device before flashing')
|
|
55
83
|
this.state = 'flashing'
|
|
56
84
|
try {
|
|
57
|
-
await validateFirmwareImage(this.image, bytes)
|
|
58
|
-
onPhase('Writing application; bootloader, partition table, NVS and user files are preserved')
|
|
85
|
+
await validateFirmwareImage(this.image, bytes, { mode: this.mode })
|
|
86
|
+
onPhase(this.mode === 'install' ? 'Installing complete firmware; existing data in the written range will be replaced' : 'Writing application; bootloader, partition table, NVS and user files are preserved')
|
|
59
87
|
await this.stub.flashData(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength),
|
|
60
88
|
(written, total) => onProgress(total ? Math.min(80, Math.round(written / total * 80)) : 0), this.image.offset, false)
|
|
61
|
-
onPhase('Verifying the complete application image')
|
|
89
|
+
onPhase(this.mode === 'install' ? 'Verifying the complete installation image' : 'Verifying the complete application image')
|
|
62
90
|
// maxInFlight is a packet count: one 1 KiB packet bounds USB-JTAG backlog.
|
|
63
91
|
const readback = new Uint8Array(bytes.byteLength)
|
|
64
92
|
for (let offset = 0; offset < bytes.byteLength; offset += 65536) {
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
export const MAX_FIRMWARE_BYTES = 256 * 1024 * 1024
|
|
4
|
+
export const MAX_MANIFEST_BYTES = 4 * 1024 * 1024
|
|
5
|
+
export const OFFICIAL_ROOT = 'https://dl.espressif.com/AE/esp-vision/'
|
|
6
|
+
export const SHA256 = /^[a-f0-9]{64}$/
|
|
7
|
+
export const SOURCE_COMMIT = /^[a-f0-9]{40}$/
|
|
8
|
+
|
|
9
|
+
export function firmwareUrl(value, { official = false } = {}) {
|
|
10
|
+
if (typeof value !== 'string') throw new TypeError('Firmware URLs must use HTTPS')
|
|
11
|
+
let url
|
|
12
|
+
try { url = new URL(value) } catch { throw new TypeError('Firmware URLs must use HTTPS') }
|
|
13
|
+
if (url.protocol !== 'https:' || url.username || url.password || url.hash
|
|
14
|
+
|| (official && (!url.href.startsWith(OFFICIAL_ROOT) || url.search || /%2f|%5c|%2e/i.test(url.pathname)))) throw new TypeError('Invalid firmware HTTPS URL or official source')
|
|
15
|
+
return url
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function responseBytes(response, { maxBytes, expectedBytes, signal } = {}) {
|
|
19
|
+
const limit = Math.min(maxBytes, expectedBytes ?? maxBytes)
|
|
20
|
+
const advertised = response.headers?.get?.('content-length')
|
|
21
|
+
if (advertised !== null && advertised !== undefined && (!/^\d+$/.test(advertised) || Number(advertised) > limit)) throw new RangeError('Firmware response exceeds approved byte limit')
|
|
22
|
+
let bytes
|
|
23
|
+
if (response.body?.getReader) {
|
|
24
|
+
const reader = response.body.getReader(), chunks = []
|
|
25
|
+
let length = 0
|
|
26
|
+
const abort = () => { void reader.cancel().catch(() => {}) }
|
|
27
|
+
signal?.addEventListener('abort', abort, { once: true })
|
|
28
|
+
try {
|
|
29
|
+
for (;;) {
|
|
30
|
+
signal?.throwIfAborted()
|
|
31
|
+
const { done, value } = await reader.read()
|
|
32
|
+
if (done) break
|
|
33
|
+
length += value.byteLength
|
|
34
|
+
if (length > limit) throw new RangeError('Firmware response exceeds approved byte limit')
|
|
35
|
+
chunks.push(value)
|
|
36
|
+
}
|
|
37
|
+
bytes = new Uint8Array(length)
|
|
38
|
+
let offset = 0
|
|
39
|
+
for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength }
|
|
40
|
+
} catch (error) { await reader.cancel().catch(() => {}); throw error }
|
|
41
|
+
finally { signal?.removeEventListener('abort', abort); reader.releaseLock() }
|
|
42
|
+
} else if (response.arrayBuffer) bytes = new Uint8Array(await response.arrayBuffer())
|
|
43
|
+
else if (response.text) bytes = new TextEncoder().encode(await response.text())
|
|
44
|
+
else throw new TypeError('Firmware response must expose bytes')
|
|
45
|
+
signal?.throwIfAborted()
|
|
46
|
+
if (bytes.byteLength > limit) throw new RangeError('Firmware response exceeds approved byte limit')
|
|
47
|
+
if (expectedBytes !== undefined && bytes.byteLength !== expectedBytes) throw new Error('Firmware response size does not match the approved build')
|
|
48
|
+
return bytes
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function downloadBytes(url, { fetch: fetcher = globalThis.fetch, signal, maxBytes, expectedBytes, official = false, immutable = false } = {}) {
|
|
52
|
+
firmwareUrl(url, { official })
|
|
53
|
+
if (typeof fetcher !== 'function') throw new TypeError('Provide fetch or in-memory firmware bytes')
|
|
54
|
+
signal?.throwIfAborted()
|
|
55
|
+
const request = cache => fetcher(url, { signal, cache, redirect: 'error' })
|
|
56
|
+
let response
|
|
57
|
+
try { response = await request(immutable ? 'default' : 'no-store') }
|
|
58
|
+
catch (error) {
|
|
59
|
+
// An older navigation/download response may be cached without CORS headers.
|
|
60
|
+
// Retry only a fetch-stage TypeError once, never HTTP, body, hash or metadata
|
|
61
|
+
// failures. Every successful response still undergoes the same hash checks.
|
|
62
|
+
if (!immutable || signal?.aborted || error?.name !== 'TypeError') throw error
|
|
63
|
+
response = await request('no-store')
|
|
64
|
+
}
|
|
65
|
+
if (!response.ok) throw Object.assign(new Error(`Firmware download failed: HTTP ${response.status}`), { code: 'FIRMWARE_DOWNLOAD_FAILED' })
|
|
66
|
+
if (response.url) firmwareUrl(response.url, { official })
|
|
67
|
+
return responseBytes(response, { maxBytes, expectedBytes, signal })
|
|
68
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
import { MAX_FIRMWARE_BYTES, firmwareUrl } from './firmware_io.js'
|
|
4
|
+
|
|
5
|
+
export function validateFirmwareRelease(value, mode = 'update') {
|
|
6
|
+
if (!value || typeof value.board !== 'string' || !value.board || typeof value.version !== 'string' || !value.version) throw new TypeError('Release requires board and version')
|
|
7
|
+
const image = value.image
|
|
8
|
+
const revisionValid = image?.chipRevision !== undefined
|
|
9
|
+
? Number.isSafeInteger(image.chipRevision) && image.chipRevision >= 0
|
|
10
|
+
: Number.isSafeInteger(image?.chipRevisionMin) && image.chipRevisionMin >= 0
|
|
11
|
+
&& Number.isSafeInteger(image?.chipRevisionMax) && image.chipRevisionMax >= image.chipRevisionMin
|
|
12
|
+
if (!image || image.evMuxVersion !== 4 || !Number.isSafeInteger(image.chipFamily)
|
|
13
|
+
|| image.chipFamily < 0 || !revisionValid || !Number.isSafeInteger(image.offset) || image.offset < 0
|
|
14
|
+
|| !Number.isSafeInteger(image.bytes) || image.bytes <= 0 || !Number.isSafeInteger(image.partitionSize) || image.bytes > image.partitionSize
|
|
15
|
+
|| !/^[a-f0-9]{64}$/.test(image.sha256 ?? '') || !Number.isSafeInteger(image.partitionTable?.offset) || image.partitionTable.offset < 0
|
|
16
|
+
|| !Number.isSafeInteger(image.partitionTable?.bytes) || image.partitionTable.bytes <= 0
|
|
17
|
+
|| !/^[a-f0-9]{64}$/.test(image.partitionTable?.sha256 ?? '')) throw new TypeError('Release requires complete EV-MUX v4 application/chip/partition/SHA-256 metadata')
|
|
18
|
+
if (mode === 'install') {
|
|
19
|
+
const install = value.install
|
|
20
|
+
if (!install || install.offset !== 0 || !Number.isSafeInteger(install.bytes) || install.bytes <= 0
|
|
21
|
+
|| !Number.isSafeInteger(install.flashSize) || install.flashSize < install.bytes || install.flashSize > 2 ** 30
|
|
22
|
+
|| !Number.isInteger(Math.log2(install.flashSize)) || install.flashSize < 2 ** 18
|
|
23
|
+
|| !/^[a-f0-9]{64}$/.test(install.sha256 ?? '') || !install.bootloader
|
|
24
|
+
|| !Number.isSafeInteger(install.chipRevisionMin) || !Number.isSafeInteger(install.chipRevisionMax)
|
|
25
|
+
|| install.chipRevisionMin < (image.chipRevision ?? image.chipRevisionMin) || install.chipRevisionMax > (image.chipRevision ?? image.chipRevisionMax)
|
|
26
|
+
|| install.chipRevisionMin < 0 || install.chipRevisionMax < install.chipRevisionMin) throw Object.assign(new TypeError('Release lacks qualified complete-install metadata'), { code: 'FIRMWARE_INSTALL_UNAVAILABLE' })
|
|
27
|
+
let end = 0
|
|
28
|
+
for (const part of [install.bootloader, image.partitionTable, image]) {
|
|
29
|
+
if (!Number.isSafeInteger(part.offset) || !Number.isSafeInteger(part.bytes) || part.bytes <= 0
|
|
30
|
+
|| part.offset < end || part.offset + part.bytes > install.bytes
|
|
31
|
+
|| !/^[a-f0-9]{64}$/.test(part.sha256 ?? '')) throw new TypeError('Invalid complete-install segment bounds or hashes')
|
|
32
|
+
end = part.offset + part.bytes
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
if (image.source !== undefined) {
|
|
36
|
+
const source = image.source, install = value.install
|
|
37
|
+
if (!source || typeof source !== 'object' || Array.isArray(source)
|
|
38
|
+
|| !Number.isSafeInteger(source.bytes) || source.bytes <= 0 || source.bytes > MAX_FIRMWARE_BYTES
|
|
39
|
+
|| !Number.isSafeInteger(source.offset) || source.offset < 0 || source.offset !== image.offset
|
|
40
|
+
|| !Number.isSafeInteger(source.offset + image.bytes) || source.offset + image.bytes > source.bytes
|
|
41
|
+
|| !/^[a-f0-9]{64}$/.test(source.sha256 ?? '') || source.url !== image.url
|
|
42
|
+
|| !install || source.url !== install.url || source.bytes !== install.bytes || source.sha256 !== install.sha256) throw new TypeError('Invalid complete BIN source bounds or install consistency')
|
|
43
|
+
firmwareUrl(source.url)
|
|
44
|
+
if (mode !== 'install') validateFirmwareRelease(value, 'install')
|
|
45
|
+
}
|
|
46
|
+
return structuredClone(value)
|
|
47
|
+
}
|
package/src/firmware_updates.js
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
* SPDX-License-Identifier: Apache-2.0
|
|
3
3
|
*/
|
|
4
4
|
import { loadOfficialFirmware } from './firmware_feed.js'
|
|
5
|
-
import { FirmwareFlashSession, validateFirmwareImage } from './firmware_flash.js'
|
|
5
|
+
import { FirmwareFlashSession, validateFirmwareImage, firmwareMode } from './firmware_flash.js'
|
|
6
|
+
import { validateFirmwareRelease as release } from './firmware_release.js'
|
|
7
|
+
import { downloadBytes, MAX_FIRMWARE_BYTES } from './firmware_io.js'
|
|
6
8
|
|
|
7
9
|
function record(value, keys, name) {
|
|
8
10
|
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new TypeError(`${name} must be an object`)
|
|
@@ -16,21 +18,7 @@ function identity(value) {
|
|
|
16
18
|
if (!value || typeof value.board !== 'string' || !value.board || typeof value.firmwareVersion !== 'string' || !value.firmwareVersion) throw new TypeError('An inspected identity with board and firmwareVersion is required')
|
|
17
19
|
return structuredClone(value)
|
|
18
20
|
}
|
|
19
|
-
|
|
20
|
-
if (!value || typeof value.board !== 'string' || !value.board || typeof value.version !== 'string' || !value.version) throw new TypeError('Release requires board and version')
|
|
21
|
-
const image = value.image
|
|
22
|
-
const revisionValid = image?.chipRevision !== undefined
|
|
23
|
-
? Number.isSafeInteger(image.chipRevision) && image.chipRevision >= 0
|
|
24
|
-
: Number.isSafeInteger(image?.chipRevisionMin) && image.chipRevisionMin >= 0
|
|
25
|
-
&& Number.isSafeInteger(image?.chipRevisionMax) && image.chipRevisionMax >= image.chipRevisionMin
|
|
26
|
-
if (!image || image.evMuxVersion !== 4 || !Number.isSafeInteger(image.chipFamily)
|
|
27
|
-
|| image.chipFamily < 0 || !revisionValid || !Number.isSafeInteger(image.offset) || image.offset < 0
|
|
28
|
-
|| !Number.isSafeInteger(image.bytes) || image.bytes <= 0 || !Number.isSafeInteger(image.partitionSize) || image.bytes > image.partitionSize
|
|
29
|
-
|| !/^[a-f0-9]{64}$/.test(image.sha256 ?? '') || !Number.isSafeInteger(image.partitionTable?.offset) || image.partitionTable.offset < 0
|
|
30
|
-
|| !Number.isSafeInteger(image.partitionTable?.bytes) || image.partitionTable.bytes <= 0
|
|
31
|
-
|| !/^[a-f0-9]{64}$/.test(image.partitionTable?.sha256 ?? '')) throw new TypeError('Release requires complete EV-MUX v4 application/chip/partition/SHA-256 metadata')
|
|
32
|
-
return structuredClone(value)
|
|
33
|
-
}
|
|
21
|
+
|
|
34
22
|
async function download(url, fetcher, signal) {
|
|
35
23
|
if (typeof url !== 'string' || new URL(url).protocol !== 'https:') throw new TypeError('Firmware URLs must use HTTPS')
|
|
36
24
|
if (typeof fetcher !== 'function') throw new TypeError('Provide fetch or an in-memory catalog/image')
|
|
@@ -41,12 +29,13 @@ async function download(url, fetcher, signal) {
|
|
|
41
29
|
}
|
|
42
30
|
|
|
43
31
|
export async function checkFirmware(request = {}, connectedIdentity) {
|
|
44
|
-
record(request, ['catalog', 'identity', 'version', 'fetch', 'signal'], 'firmware.check')
|
|
32
|
+
record(request, ['catalog', 'identity', 'version', 'mode', 'fetch', 'signal'], 'firmware.check')
|
|
45
33
|
const { signal, fetch: fetcher = globalThis.fetch } = request
|
|
46
34
|
abort(signal)
|
|
47
35
|
if (request.version !== undefined && (typeof request.version !== 'string' || !request.version.trim())) throw new TypeError('version must be a non-empty firmware version string')
|
|
36
|
+
const mode = firmwareMode(request.mode)
|
|
48
37
|
const current = identity(connectedIdentity ?? request.identity)
|
|
49
|
-
const catalog = request.catalog === undefined ? await loadOfficialFirmware({ board: current.board, fetch: fetcher, signal }) : typeof request.catalog === 'string' ? await (await download(request.catalog, fetcher, signal)).json() : structuredClone(request.catalog)
|
|
38
|
+
const catalog = request.catalog === undefined ? await loadOfficialFirmware({ board: current.board, version: request.version, fetch: fetcher, signal }) : typeof request.catalog === 'string' ? await (await download(request.catalog, fetcher, signal)).json() : structuredClone(request.catalog)
|
|
50
39
|
abort(signal)
|
|
51
40
|
if (catalog?.schemaVersion !== 1 || !Array.isArray(catalog.releases)) throw new TypeError('Catalog requires schemaVersion 1 and releases ordered newest first')
|
|
52
41
|
// Feed ordering is authoritative: firmware versions may be calendar/git labels.
|
|
@@ -56,22 +45,40 @@ export async function checkFirmware(request = {}, connectedIdentity) {
|
|
|
56
45
|
return structuredClone(item)
|
|
57
46
|
})
|
|
58
47
|
if (new Set(candidates.map(item => item.version)).size !== candidates.length) throw new TypeError('Catalog must not contain duplicate versions for one board')
|
|
59
|
-
const selected = request.version === undefined ? candidates.find(item => item.compatible !== false) ?? null : candidates.find(item => item.version === request.version)
|
|
48
|
+
const selected = request.version === undefined ? candidates.find(item => item.compatible !== false && (mode !== 'install' || item.install)) ?? null : candidates.find(item => item.version === request.version)
|
|
60
49
|
if (request.version !== undefined && !selected) throw Object.assign(new Error('Requested firmware version is unavailable for this board'), { code: 'FIRMWARE_VERSION_UNAVAILABLE' })
|
|
61
50
|
if (selected?.compatible === false) throw Object.assign(new Error('This published version lacks approved v4 update metadata'), { code: 'FIRMWARE_INCOMPATIBLE' })
|
|
51
|
+
if (selected) release(selected, mode)
|
|
62
52
|
return { available: selected !== null && (selected.firmwareVersion ?? selected.version) !== current.firmwareVersion, currentVersion: current.firmwareVersion, release: selected, releases: candidates }
|
|
63
53
|
}
|
|
64
54
|
|
|
65
55
|
export async function updateFirmware(request, connectedIdentity, disconnect) {
|
|
66
|
-
record(request, ['release', 'board', 'data', 'fetch', 'openLoader', 'reset', 'onProgress', 'onPhase', 'signal'], 'firmware.update')
|
|
56
|
+
record(request, ['release', 'board', 'mode', 'data', 'fetch', 'openLoader', 'reset', 'onProgress', 'onPhase', 'signal'], 'firmware.update')
|
|
67
57
|
const { signal, fetch: fetcher = globalThis.fetch, openLoader, reset, onProgress = () => {}, onPhase = () => {} } = request
|
|
68
58
|
abort(signal)
|
|
69
59
|
if (typeof openLoader !== 'function' || typeof onProgress !== 'function' || typeof onPhase !== 'function' || (reset !== undefined && typeof reset !== 'function')) throw new TypeError('Firmware update requires openLoader and valid callbacks')
|
|
70
|
-
const
|
|
60
|
+
const mode = firmwareMode(request.mode)
|
|
61
|
+
const target = release(request.release, mode)
|
|
62
|
+
const installation = target.install ? {
|
|
63
|
+
...target.image, offset: target.install.offset, bytes: target.install.bytes,
|
|
64
|
+
flashSize: target.install.flashSize, sha256: target.install.sha256, url: target.install.url,
|
|
65
|
+
bootloader: target.install.bootloader, application: target.image,
|
|
66
|
+
chipRevision: undefined, chipRevisionMin: target.install.chipRevisionMin, chipRevisionMax: target.install.chipRevisionMax,
|
|
67
|
+
} : null
|
|
68
|
+
const image = mode === 'install' ? installation : target.image
|
|
71
69
|
const board = connectedIdentity?.board ?? request.board
|
|
72
70
|
if (!board || board !== target.board || (request.board !== undefined && request.board !== board)) throw new TypeError('Firmware release does not match the connected or explicitly selected board')
|
|
73
|
-
const
|
|
74
|
-
|
|
71
|
+
const source = mode === 'update' ? image.source : undefined
|
|
72
|
+
const descriptor = source ?? image
|
|
73
|
+
let bytes = request.data === undefined
|
|
74
|
+
? await downloadBytes(descriptor.url, { fetch: fetcher, signal, maxBytes: MAX_FIRMWARE_BYTES, expectedBytes: descriptor.bytes, immutable: target.image.source !== undefined })
|
|
75
|
+
: request.data instanceof Uint8Array ? request.data.slice() : request.data
|
|
76
|
+
if (source) {
|
|
77
|
+
if (!(bytes instanceof Uint8Array) || bytes.byteLength !== source.bytes) throw new Error('Complete BIN source size does not match the approved build')
|
|
78
|
+
await validateFirmwareImage(installation, bytes, { mode: 'install' })
|
|
79
|
+
bytes = bytes.slice(source.offset, source.offset + image.bytes)
|
|
80
|
+
}
|
|
81
|
+
await validateFirmwareImage(image, bytes, { mode })
|
|
75
82
|
abort(signal)
|
|
76
83
|
// Validate everything before releasing EV-MUX or acquiring a download port.
|
|
77
84
|
await disconnect()
|
|
@@ -80,7 +87,7 @@ export async function updateFirmware(request, connectedIdentity, disconnect) {
|
|
|
80
87
|
let session
|
|
81
88
|
try {
|
|
82
89
|
abort(signal)
|
|
83
|
-
session = new FirmwareFlashSession(loader,
|
|
90
|
+
session = new FirmwareFlashSession(loader, image, { mode, reset: reset ? () => reset(loader) : undefined })
|
|
84
91
|
await session.connect(bytes)
|
|
85
92
|
abort(signal)
|
|
86
93
|
// No interruption after writing starts: finish verification and reset policy.
|
package/src/node.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
// Node-only adapters. Creating them does not acquire a session or serial port.
|
|
4
|
+
export { tcpTransport, serialTransport } from './node_transport.js'
|
|
5
|
+
export { openNodeRom } from './node_rom.js'
|
package/src/node_rom.js
CHANGED
|
@@ -41,6 +41,7 @@ export function openNodeRom({ port, python = defaultPython, signal } = {}) {
|
|
|
41
41
|
try { Object.assign(loader, await rpc('initialize')) }
|
|
42
42
|
finally { signal?.removeEventListener('abort', cancelled) }
|
|
43
43
|
},
|
|
44
|
+
flashId: () => rpc('flashId'),
|
|
44
45
|
getSecurityInfo: () => rpc('security'),
|
|
45
46
|
async runStub() { await rpc('stub'); return loader },
|
|
46
47
|
async readFlash(offset, bytes) { return new Uint8Array(Buffer.from(await rpc('read', [offset, bytes]), 'base64')) },
|
package/src/node_rom.py
CHANGED
|
@@ -32,6 +32,8 @@ def main():
|
|
|
32
32
|
esp = esptool.run_stub(esp)
|
|
33
33
|
esptool.attach_flash(esp)
|
|
34
34
|
esp.change_baud(460800)
|
|
35
|
+
elif method == 'flashId':
|
|
36
|
+
result = esp.flash_id()
|
|
35
37
|
elif method == 'read':
|
|
36
38
|
result = base64.b64encode(esp.read_flash(*args)).decode('ascii')
|
|
37
39
|
elif method == 'write':
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
import assert from 'node:assert/strict'
|
|
4
|
+
import { mkdtemp, readFile, writeFile, rm } from 'node:fs/promises'
|
|
5
|
+
import { tmpdir } from 'node:os'
|
|
6
|
+
import { join, dirname } from 'node:path'
|
|
7
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
8
|
+
import vm from 'node:vm'
|
|
9
|
+
import { webcrypto } from 'node:crypto'
|
|
10
|
+
import { build } from 'esbuild'
|
|
11
|
+
|
|
12
|
+
const root = dirname(dirname(fileURLToPath(import.meta.url)))
|
|
13
|
+
const temporary = await mkdtemp(join(tmpdir(), 'esp-vision-browser-'))
|
|
14
|
+
try {
|
|
15
|
+
// Bundle the actual pinned dependency and its dynamic JSON stubs for browsers.
|
|
16
|
+
const result = await build({
|
|
17
|
+
stdin: { contents: `export { createDevice } from './src/index.js'; export { openBrowserRom } from './src/browser.js'; export { loadReleaseManifest } from './src/firmware.js'; export { ESPLoader as TestLoader } from 'tasmota-webserial-esptool'; export { browserRomCompatibility as testCompatibility } from './src/browser_rom_compat.js';`, resolveDir: root },
|
|
18
|
+
bundle: true, platform: 'browser', format: 'iife', globalName: 'DeviceCore',
|
|
19
|
+
write: false, metafile: true,
|
|
20
|
+
})
|
|
21
|
+
const code = result.outputFiles[0].text
|
|
22
|
+
assert.ok(Object.keys(result.metafile.inputs).some(path => path.includes('tasmota-webserial-esptool/dist/esp_loader.js')))
|
|
23
|
+
assert.ok(Object.keys(result.metafile.inputs).some(path => path.includes('stubs/esp32p4.json')))
|
|
24
|
+
assert.ok(Object.keys(result.metafile.inputs).some(path => path.includes('stubs/esp32s31.json')))
|
|
25
|
+
assert.ok(!Object.keys(result.metafile.inputs).some(path => /src\/(node|cli)/.test(path)))
|
|
26
|
+
const context = vm.createContext({ EventTarget, URL, TextDecoder, TextEncoder, Uint8Array, AbortController, setTimeout, clearTimeout, console, crypto: webcrypto, structuredClone })
|
|
27
|
+
vm.runInContext(code, context)
|
|
28
|
+
let opens = 0, closes = 0
|
|
29
|
+
const adapter = context.DeviceCore.openBrowserRom({ port: {
|
|
30
|
+
async open() { opens++; throw new Error('real dependency reached SerialPort.open') },
|
|
31
|
+
async close() { closes++ },
|
|
32
|
+
} })
|
|
33
|
+
assert.equal(opens, 0, 'import and construction must not open/reset the port')
|
|
34
|
+
await assert.rejects(adapter.initialize(), /real dependency reached SerialPort.open/)
|
|
35
|
+
assert.equal(opens, 1)
|
|
36
|
+
// Run upstream's actual SLIP parser in both CDC and UART modes. A blank board
|
|
37
|
+
// may emit a boot log before the stub greeting; invalid escapes must still fail.
|
|
38
|
+
for (const cdc of [false, true]) {
|
|
39
|
+
const raw = new context.DeviceCore.TestLoader({}, { log() {}, error() {}, debug() {} })
|
|
40
|
+
context.DeviceCore.testCompatibility(raw)
|
|
41
|
+
raw._isCDCDevice = cdc
|
|
42
|
+
raw.__inputBuffer = []
|
|
43
|
+
raw._inputBuffer.push(71, 97, 114, 0xc0, 79, 72, 65, 73, 0xc0)
|
|
44
|
+
assert.deepEqual(Array.from(await raw.readPacket(100)), [79, 72, 65, 73])
|
|
45
|
+
raw._clearInputBuffer()
|
|
46
|
+
raw._inputBuffer.push(0xc0, 0xdb, 0x47, 0xc0)
|
|
47
|
+
await assert.rejects(raw.readPacket(100), /Invalid SLIP escape/)
|
|
48
|
+
}
|
|
49
|
+
const prototype = context.DeviceCore.TestLoader.prototype
|
|
50
|
+
const originals = Object.fromEntries(['initialize', 'disconnect', 'getSecurityInfo', 'runStub', 'hardReset'].map(key => [key, prototype[key]]))
|
|
51
|
+
let initializeCalls = 0, disconnectCalls = 0
|
|
52
|
+
prototype.disconnect = async function () { disconnectCalls++; await this.port.close() }
|
|
53
|
+
const fakePort = () => ({
|
|
54
|
+
async open() { this.readable = {}; this.writable = {} },
|
|
55
|
+
async close() { this.readable = null; this.writable = null },
|
|
56
|
+
})
|
|
57
|
+
try {
|
|
58
|
+
prototype.initialize = async () => { initializeCalls++ }
|
|
59
|
+
const controller = new AbortController()
|
|
60
|
+
const loader = context.DeviceCore.openBrowserRom({ port: fakePort(), signal: controller.signal })
|
|
61
|
+
const first = loader.initialize()
|
|
62
|
+
assert.equal(loader.initialize(), first, 'initialize must share one ROM connection')
|
|
63
|
+
await first
|
|
64
|
+
controller.abort()
|
|
65
|
+
assert.equal(disconnectCalls, 0, 'abort after initialization must not interrupt flash/readback')
|
|
66
|
+
await loader.disconnect(); await loader.disconnect()
|
|
67
|
+
assert.equal(disconnectCalls, 1)
|
|
68
|
+
assert.equal(initializeCalls, 1)
|
|
69
|
+
prototype.initialize = async () => { throw new Error('sync failed') }
|
|
70
|
+
await assert.rejects(context.DeviceCore.openBrowserRom({ port: fakePort() }).initialize(), /sync failed/)
|
|
71
|
+
assert.equal(disconnectCalls, 2, 'failed ROM sync must release the acquired port')
|
|
72
|
+
let finishOpen
|
|
73
|
+
const waitingPort = fakePort()
|
|
74
|
+
const open = waitingPort.open
|
|
75
|
+
waitingPort.open = async function () {
|
|
76
|
+
await new Promise(resolve => { finishOpen = resolve })
|
|
77
|
+
await open.call(this)
|
|
78
|
+
}
|
|
79
|
+
const cancelled = new AbortController()
|
|
80
|
+
const late = context.DeviceCore.openBrowserRom({ port: waitingPort, signal: cancelled.signal })
|
|
81
|
+
const starting = late.initialize()
|
|
82
|
+
while (!finishOpen) await new Promise(resolve => setTimeout(resolve, 1))
|
|
83
|
+
cancelled.abort()
|
|
84
|
+
await assert.rejects(starting, { name: 'AbortError' })
|
|
85
|
+
finishOpen()
|
|
86
|
+
await new Promise(resolve => setTimeout(resolve, 5))
|
|
87
|
+
assert.equal(disconnectCalls, 3, 'an open completing after cancellation must close')
|
|
88
|
+
assert.equal(waitingPort.readable, null)
|
|
89
|
+
const data = new Uint8Array([0xe9, 1, 2, 3]), table = new Uint8Array([4, 5])
|
|
90
|
+
const hash = async bytes => Array.from(new Uint8Array(await webcrypto.subtle.digest('SHA-256', bytes)), byte => byte.toString(16).padStart(2, '0')).join('')
|
|
91
|
+
const release = { board: 'BOARD', version: 'browser-test', image: {
|
|
92
|
+
evMuxVersion: 4, chipFamily: 0x3280, chipRevision: 302,
|
|
93
|
+
offset: 65536, bytes: data.byteLength, partitionSize: 4096,
|
|
94
|
+
sha256: await hash(data), partitionTable: { offset: 32768, bytes: table.byteLength, sha256: await hash(table) },
|
|
95
|
+
} }
|
|
96
|
+
let stubs = 0, writes = 0, resets = 0
|
|
97
|
+
prototype.initialize = async function () { this.chipFamily = 0x3280; this.chipRevision = 302 }
|
|
98
|
+
prototype.getSecurityInfo = async () => ({ flags: 1, flashCryptCnt: 0 })
|
|
99
|
+
prototype.runStub = async () => { stubs++; throw new Error('must not launch') }
|
|
100
|
+
const device = context.DeviceCore.createDevice()
|
|
101
|
+
const request = {
|
|
102
|
+
release, board: 'BOARD', data,
|
|
103
|
+
openLoader: ({ signal }) => context.DeviceCore.openBrowserRom({ port: fakePort(), signal }),
|
|
104
|
+
}
|
|
105
|
+
await assert.rejects(device.firmware.update(request), /Secure boot/)
|
|
106
|
+
assert.equal(stubs, 0, 'ROM security must be checked before the browser launches a stub')
|
|
107
|
+
await assert.rejects(device.firmware.update({ ...request, data: new Uint8Array(4) }), /SHA-256/)
|
|
108
|
+
assert.equal(stubs, 0)
|
|
109
|
+
prototype.getSecurityInfo = async () => ({ flags: 0, flashCryptCnt: 0 })
|
|
110
|
+
const writing = new AbortController()
|
|
111
|
+
prototype.runStub = async () => {
|
|
112
|
+
stubs++
|
|
113
|
+
return {
|
|
114
|
+
IS_STUB: true,
|
|
115
|
+
async flashData() { writes++; writing.abort() },
|
|
116
|
+
async readFlash(offset) { return offset === 32768 ? table : data },
|
|
117
|
+
async readRegister() { return 0 },
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
prototype.hardReset = async () => { resets++ }
|
|
121
|
+
const beforeUpdateCloses = disconnectCalls
|
|
122
|
+
const updated = await device.firmware.update({ ...request, signal: writing.signal })
|
|
123
|
+
assert.equal(updated.verified, true)
|
|
124
|
+
assert.equal(updated.needsReset, false)
|
|
125
|
+
assert.equal(stubs, 1); assert.equal(writes, 1); assert.equal(resets, 1)
|
|
126
|
+
assert.equal(disconnectCalls, beforeUpdateCloses + 1, 'write-time abort must preserve complete readback, reset and one cleanup')
|
|
127
|
+
const manifest = { schemaVersion: 1, version: 'browser-test', sourceCommit: 'a'.repeat(40), pipelineId: 1, layout: 'legacy',
|
|
128
|
+
releases: [{ ...release, image: { ...release.image, url: 'https://dl.espressif.com/AE/esp-vision/bin/app.bin' } }],
|
|
129
|
+
website: { schemaVersion: '1.0.0', release: 'browser-test', releases: ['browser-test'], binaryNameTemplate: '{board}-{tag}.bin',
|
|
130
|
+
boards: [{ board: 'BOARD', name: 'Board', slug: 'board', flashable: true }], models: [] }, mcp: null }
|
|
131
|
+
const rawManifest = new TextEncoder().encode(JSON.stringify(manifest)), caches = []
|
|
132
|
+
const snapshot = await context.DeviceCore.loadReleaseManifest({ url: 'https://dl.espressif.com/AE/esp-vision/releases/browser-test/manifest.json', sha256: await hash(rawManifest),
|
|
133
|
+
async fetch(_url, options) {
|
|
134
|
+
caches.push(options.cache)
|
|
135
|
+
if (caches.length === 1) throw new TypeError('Cached response CORS failure from the host realm')
|
|
136
|
+
return new Response(rawManifest)
|
|
137
|
+
} })
|
|
138
|
+
assert.equal(snapshot.version, 'browser-test')
|
|
139
|
+
assert.deepEqual(caches, ['default', 'no-store'], 'real browser bundle must recover a fetch TypeError from host-provided fetch')
|
|
140
|
+
|
|
141
|
+
} finally { Object.assign(prototype, originals) }
|
|
142
|
+
// Node import must not eagerly resolve tasmota's browser-only, extensionless ESM.
|
|
143
|
+
const { openBrowserRom } = await import(pathToFileURL(join(root, 'src/browser.js')))
|
|
144
|
+
assert.equal(typeof openBrowserRom, 'function')
|
|
145
|
+
const dependency = JSON.parse(await readFile(join(root, 'node_modules/tasmota-webserial-esptool/package.json'), 'utf8'))
|
|
146
|
+
assert.equal(dependency.version, '9.4.2')
|
|
147
|
+
const stubsLicense = await readFile(join(root, 'node_modules/tasmota-webserial-esptool/src/stubs/README.md'), 'utf8')
|
|
148
|
+
assert.match(stubsLicense, /dual licensed.*Apache License Version 2.0 or the MIT license/s)
|
|
149
|
+
await writeFile(join(temporary, 'browser.js'), code)
|
|
150
|
+
console.log(JSON.stringify({ browserBundle: true, dependency: dependency.version, bytes: code.length, passiveImports: true, p4AndS31Stubs: true, cleanupAndCancellation: true, coreSafetyChecks: true }))
|
|
151
|
+
} finally { await rm(temporary, { recursive: true, force: true }) }
|
package/tests/check-package.mjs
CHANGED
|
@@ -37,8 +37,11 @@ try {
|
|
|
37
37
|
const api = await import('@esp-vision/device-core');
|
|
38
38
|
if (Object.keys(api).sort().join(',') !== 'createDevice') throw new Error('Root must expose only the application API');
|
|
39
39
|
const { DeviceSession } = await import('@esp-vision/device-core/session');
|
|
40
|
-
const { FirmwareFlashSession } = await import('@esp-vision/device-core/firmware');
|
|
41
|
-
|
|
40
|
+
const { FirmwareFlashSession, loadOfficialFirmware, loadReleaseManifest } = await import('@esp-vision/device-core/firmware');
|
|
41
|
+
if (typeof loadOfficialFirmware !== 'function' || typeof loadReleaseManifest !== 'function') throw new Error('Missing public release manifest readers');
|
|
42
|
+
const { tcpTransport, serialTransport, openNodeRom } = await import('@esp-vision/device-core/node');
|
|
43
|
+
const { openBrowserRom } = await import('@esp-vision/device-core/browser');
|
|
44
|
+
if (typeof openBrowserRom !== 'function' || typeof openBrowserRom({ port: { open() { throw new Error('Not opened'); }, close() {} } }).initialize !== 'function') throw new Error('Missing passive browser ROM adapter');
|
|
42
45
|
if (typeof tcpTransport !== 'function' || typeof serialTransport !== 'function') throw new Error('Missing public Node transports');
|
|
43
46
|
const { createNodeDiscovery } = await import('@esp-vision/device-core/discovery/node');
|
|
44
47
|
const { EventEmitter } = await import('node:events');
|
|
@@ -49,6 +52,7 @@ try {
|
|
|
49
52
|
const device = api.createDevice({ discovery: provider });
|
|
50
53
|
if (typeof provider.list !== 'function') throw new Error('Discovery providers must expose list()');
|
|
51
54
|
if ((await device.discover({ timeout: 50 })).length !== 0 || !queries) throw new Error('Device discovery did not call its provider');
|
|
55
|
+
if (typeof openNodeRom !== 'function' || typeof openNodeRom({ port: 'not-opened' }).initialize !== 'function') throw new Error('Missing passive Node ROM adapter');
|
|
52
56
|
// Constructors must be passive: these endpoints need not exist to create adapters.
|
|
53
57
|
api.createDevice({ transport: tcpTransport({ host: '127.0.0.1', port: 1 }) });
|
|
54
58
|
api.createDevice({ transport: serialTransport({ python: 'python3', serial: 'not-opened' }) });
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
import test from 'node:test'
|
|
4
|
+
import assert from 'node:assert/strict'
|
|
5
|
+
import { openBrowserRom } from '../src/browser.js'
|
|
6
|
+
import { browserRomCompatibility } from '../src/browser_rom_compat.js'
|
|
7
|
+
|
|
8
|
+
const port = () => ({ open() { throw new Error('Port must remain passive') }, close() {} })
|
|
9
|
+
test('browser ROM construction and disconnect are passive without browser globals', async () => {
|
|
10
|
+
const adapter = openBrowserRom({ port: port() })
|
|
11
|
+
assert.equal(adapter.chipFamily, null)
|
|
12
|
+
assert.equal(adapter.chipRevision, null)
|
|
13
|
+
assert.equal(adapter.chipName, null)
|
|
14
|
+
await adapter.disconnect(); await adapter.disconnect()
|
|
15
|
+
await assert.rejects(adapter.initialize(), { code: 'ROM_CLOSED' })
|
|
16
|
+
})
|
|
17
|
+
test('browser ROM validates its port and cancellation before loading the browser dependency', async () => {
|
|
18
|
+
assert.throws(() => openBrowserRom(), /SerialPort/)
|
|
19
|
+
assert.throws(() => openBrowserRom({ port: port(), signal: {} }), /AbortSignal/)
|
|
20
|
+
const controller = new AbortController()
|
|
21
|
+
controller.abort(new Error('cancelled before open'))
|
|
22
|
+
await assert.rejects(openBrowserRom({ port: port(), signal: controller.signal }).initialize(), /cancelled before open/)
|
|
23
|
+
})
|
|
24
|
+
test('SLIP prefix compatibility retries only pre-header bytes within the original deadline', async () => {
|
|
25
|
+
let count = 0
|
|
26
|
+
const loader = browserRomCompatibility({ async readPacket() {
|
|
27
|
+
if (++count < 3) throw new Error('Invalid head of packet (0x47)')
|
|
28
|
+
return [79, 72, 65, 73]
|
|
29
|
+
} })
|
|
30
|
+
assert.deepEqual(await loader.readPacket(100), [79, 72, 65, 73])
|
|
31
|
+
loader.readPacket = browserRomCompatibility({ readPacket: async () => { throw new Error('Invalid SLIP escape (0xdb, 0x47)') } }).readPacket
|
|
32
|
+
await assert.rejects(loader.readPacket(100), /Invalid SLIP escape/)
|
|
33
|
+
})
|
|
34
|
+
test('SLIP log noise cannot extend the operation deadline indefinitely', async () => {
|
|
35
|
+
let count = 0
|
|
36
|
+
const loader = browserRomCompatibility({ async readPacket() {
|
|
37
|
+
count++
|
|
38
|
+
await new Promise(resolve => setTimeout(resolve, 5))
|
|
39
|
+
throw new Error('Invalid head of packet (0x47)')
|
|
40
|
+
} })
|
|
41
|
+
await assert.rejects(loader.readPacket(15), /Invalid head/)
|
|
42
|
+
assert.ok(count <= 4)
|
|
43
|
+
})
|
|
44
|
+
function stubFixture({ native = true, error = 'Timed out waiting for packet header', handshake = true } = {}) {
|
|
45
|
+
let timeout, reads = 0
|
|
46
|
+
const stub = { IS_STUB: true, readPacket: async () => [], checkCommand: (...args) => { timeout = args[3] } }
|
|
47
|
+
const raw = {
|
|
48
|
+
isUsbJtagOrOtg: native,
|
|
49
|
+
memFinish: async () => { if (error) throw new Error(error) },
|
|
50
|
+
readPacket: async () => { reads++; return handshake ? [79, 72, 65, 73] : [] },
|
|
51
|
+
async runStub(skip) {
|
|
52
|
+
assert.equal(skip, true)
|
|
53
|
+
await this.memFinish()
|
|
54
|
+
assert.deepEqual(await this.readPacket(2500), [79, 72, 65, 73], 'OHAI required')
|
|
55
|
+
return stub
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
return { loader: browserRomCompatibility(raw), stub, timeout: () => timeout, reads: () => reads }
|
|
59
|
+
}
|
|
60
|
+
test('native USB may miss MEM_END acknowledgement but still requires OHAI', async () => {
|
|
61
|
+
const fixture = stubFixture()
|
|
62
|
+
assert.equal(await fixture.loader.runStub(true), fixture.stub)
|
|
63
|
+
assert.equal(fixture.reads(), 1)
|
|
64
|
+
await assert.rejects(stubFixture({ handshake: false }).loader.runStub(true), /OHAI required/)
|
|
65
|
+
})
|
|
66
|
+
test('UART timeouts and native USB non-timeout errors retain their failures', async () => {
|
|
67
|
+
await assert.rejects(stubFixture({ native: false }).loader.runStub(true), /Timed out/)
|
|
68
|
+
await assert.rejects(stubFixture({ error: 'Port disconnected' }).loader.runStub(true), /Port disconnected/)
|
|
69
|
+
})
|
|
70
|
+
test('stub launch restores temporary hooks on failure and rejects ROM fallback', async () => {
|
|
71
|
+
const fixture = stubFixture({ handshake: false })
|
|
72
|
+
const finish = fixture.loader.memFinish, packet = fixture.loader.readPacket
|
|
73
|
+
await assert.rejects(fixture.loader.runStub(true))
|
|
74
|
+
assert.equal(fixture.loader.memFinish, finish)
|
|
75
|
+
assert.equal(fixture.loader.readPacket, packet)
|
|
76
|
+
await assert.rejects(browserRomCompatibility({ runStub: async () => ({ IS_STUB: false }) }).runStub(true), /no supported browser flash stub/)
|
|
77
|
+
})
|
|
78
|
+
test('stub FLASH_BEGIN timeout scales with uncompressed erase size and preserves other commands', async () => {
|
|
79
|
+
const fixture = stubFixture()
|
|
80
|
+
const stub = await fixture.loader.runStub(true)
|
|
81
|
+
await stub.checkCommand(2, [0, 0, 128, 0], 0, 3000)
|
|
82
|
+
assert.ok(fixture.timeout() > 240000)
|
|
83
|
+
await stub.checkCommand(3, [], 0, 1234)
|
|
84
|
+
assert.equal(fixture.timeout(), 1234)
|
|
85
|
+
})
|