@esp-vision/device-core 0.6.1 → 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 +17 -0
- package/README.md +7 -4
- package/package.json +2 -2
- package/schemas/device-actions-v1.json +1 -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 +33 -5
- package/src/node_rom.py +26 -1
- package/src/rom_identity.js +9 -0
- package/tests/check-browser.mjs +23 -4
- package/tests/check-package.mjs +8 -1
- 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-install-erase.mjs +153 -0
- package/tests/test-node-rom.mjs +87 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.7.0
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- Allow a serial frame split across the initial 300 ms startup drain to finish within the parser's existing progress/total budgets while rejecting old frames and sending no hello. Freeze the total deadline across successive frames, fail when the parser discards an incomplete frame on timeout, and cancel boundary waits on abort or close; retain conservative failure for injected older parsers without receive-budget support. Add payload-free partial-frame timing/timeout diagnostics and regressions for split/coalesced startup data, bounded progress and cleanup.
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- Expose the browser ROM adapter's canonical factory `chipMac`, matching Node, and accept a strictly validated optional `expectedMac` before any port access. Bind initialization and baud recovery to that MAC as well as chip family/revision; mismatches preserve `ROM_IDENTITY_CHANGED` and close the adapter before another stub, erase or write. Keep initial manually selected ROM usage compatible when the option is omitted.
|
|
12
|
+
|
|
13
|
+
- Clear and verify the S31 LP download latch at its own target address after programming, preserving unrelated bits before reset so software ROM entry can return to the application.
|
|
14
|
+
|
|
15
|
+
- Explicit complete installation clears internal FAT filesystem files, including scripts and models, before writing firmware; application updates retain their existing data-preserving behavior. Hosts must explain this deletion before selecting install mode. Complete images may also replace NVS configuration; SD cards are not erased.
|
|
16
|
+
- Require complete installations to derive the unencrypted writable `ffat` range from the hash-approved ESP partition table, verify layout/MD5, application bounds, chip/security and flash capacity, then erase and verify that exact range before any firmware write. Unsupported layouts/adapters, erase/read errors and cancellation before writing fail without reset or automatic retry.
|
|
17
|
+
- Add `eraseRegion(address, size)` to the built-in browser/Node ROM stubs, using fixed official esptool primitives with sector-aligned bounds. Custom full-install adapters need the same capability; application-only adapters do not.
|
|
18
|
+
- Bind installation qualification to private metadata/data snapshots. Cancellation is honored during erase and erased-range verification; once firmware writing starts, the existing complete-verification/reset policy remains in force.
|
|
19
|
+
|
|
3
20
|
## 0.6.1
|
|
4
21
|
|
|
5
22
|
### Fixed
|
package/README.md
CHANGED
|
@@ -19,11 +19,12 @@
|
|
|
19
19
|
| Visibility | Public; installation needs no npm login |
|
|
20
20
|
| Source location | `host/device-core` in the ESP-VISION firmware repository |
|
|
21
21
|
|
|
22
|
-
This source targets **0.
|
|
22
|
+
This source targets **0.7.0**, an unpublished minor release after 0.6.1. Device Core is SDK-only: the management CLI lives in `@esp-vision/device-cli`, and Python execution/file reads have no aggregate deadline unless configured. Declare a minor-locked dependency and use a lockfile to record the resolved patch and integrity; verify the chosen version exists in the public registry before installing. For unpublished source changes, use a tarball packed from an exact reviewed source commit. CI authenticates with the protected `NPM_PUBLISH_TOKEN` and verifies that `npm whoami` returns `esp-vision` before publishing. Tokens are not part of this package or its documentation.
|
|
23
23
|
|
|
24
24
|
```sh
|
|
25
|
-
|
|
26
|
-
npm
|
|
25
|
+
# Published example; the pending 0.7.0 APIs require a candidate tarball.
|
|
26
|
+
npm view @esp-vision/device-core@0.6.1 version dist.integrity
|
|
27
|
+
npm install '@esp-vision/device-core@~0.6.1' --registry=https://registry.npmjs.org/
|
|
27
28
|
```
|
|
28
29
|
|
|
29
30
|
A source version, dry run or open merge request does not publish a package. CLI and Plugin bundles keep their own versions and embedded Core bytes; publishing Core alone does not update those installed tools.
|
|
@@ -141,7 +142,9 @@ To validate before npm publication, check out an exact reviewed commit, run `npm
|
|
|
141
142
|
|
|
142
143
|
### Browser ROM flashing
|
|
143
144
|
|
|
144
|
-
`@esp-vision/device-core/browser` exports `openBrowserRom({ port, logger?, signal?, interface?, baudRate?, onStatus? })`. Construction is passive; `initialize()` lazily loads the official `esptool-js@0.7.1` backend and synchronizes at 115200. Use a browser bundler such as webpack, Vite or esbuild to include the chip stubs. The root and `/node` entry points do not load browser modules. No Python installation or consumer-side dependency patch is needed.
|
|
145
|
+
`@esp-vision/device-core/browser` exports `openBrowserRom({ port, logger?, signal?, interface?, baudRate?, onStatus?, expectedMac? })`. Construction is passive; `initialize()` lazily loads the official `esptool-js@0.7.1` backend and synchronizes at 115200. Use a browser bundler such as webpack, Vite or esbuild to include the chip stubs. The root and `/node` entry points do not load browser modules. No Python installation or consumer-side dependency patch is needed.
|
|
146
|
+
|
|
147
|
+
The candidate 0.7.0 browser adapter exposes `chipMac`, using the same 12 lowercase hexadecimal factory-MAC representation as the Node adapter. It is `null` before identity qualification. The browser reads it through the official chip's `readMac(loader)` primitive, without duplicating eFuse layouts. Pass the verified factory MAC of the selected runtime as `expectedMac` when handing that device to ROM, for example a validated `machine.unique_id()` known to match that board's factory MAC. The host must establish this source; an arbitrary legacy `deviceId` or USB VID/PID does not establish the association. If it cannot verify that value, use an explicitly user-selected manual ROM workflow rather than infer an automatic association. The option accepts exactly 12 hexadecimal characters, normalizes uppercase and rejects separators, whitespace, `null` and other types synchronously before opening a port. A mismatch returns `ROM_IDENTITY_CHANGED`, closes the adapter and cannot start a stub, erase or write. An unreadable/malformed MAC response returns `ROM_IDENTITY_INVALID`. Without the option, manually selecting an initial ROM remains supported; all later synchronization/recovery still binds the original family, revision and MAC, rejecting a different same-model device without rebinding.
|
|
145
148
|
|
|
146
149
|
`interface` accepts `auto` (default), `uart`, `usb-otg` and `usb-jtag`. Auto detection distinguishes known USB-UART bridge descriptors from Espressif OTG and Serial/JTAG; ambiguous custom descriptors stay conservative and can be identified explicitly. A ready OTG ROM is synchronized without toggling DTR/RTS. A detached or re-enumerated device requires selecting its current download port again; the SDK never treats a stale port object as a recovered session.
|
|
147
150
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@esp-vision/device-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Shared EV-MUX, framed REPL, device session, and action contracts for ESP Vision clients.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -78,5 +78,5 @@
|
|
|
78
78
|
"devDependencies": {
|
|
79
79
|
"esbuild": "0.25.12"
|
|
80
80
|
},
|
|
81
|
-
"espVisionReleaseType": "
|
|
81
|
+
"espVisionReleaseType": "minor"
|
|
82
82
|
}
|
package/src/browser.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
// SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
|
|
2
2
|
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
import { normalizeExpectedMac } from './rom_identity.js'
|
|
3
4
|
|
|
4
5
|
export { serialTransport } from './browser_serial.js'
|
|
5
6
|
|
|
6
7
|
/** Passive browser ROM adapter. Request the SerialPort in the UI's click handler. */
|
|
7
|
-
export function openBrowserRom({ port, logger = {}, signal, interface: romInterface = 'auto', baudRate = 'auto', onStatus = () => {} } = {}) {
|
|
8
|
+
export function openBrowserRom({ port, logger = {}, signal, interface: romInterface = 'auto', baudRate = 'auto', onStatus = () => {}, expectedMac } = {}) {
|
|
8
9
|
if (!port || typeof port.open !== 'function' || typeof port.close !== 'function') throw new TypeError('A browser SerialPort is required')
|
|
9
10
|
if (signal !== undefined && (!signal || typeof signal.addEventListener !== 'function' || typeof signal.aborted !== 'boolean')) throw new TypeError('signal must be an AbortSignal')
|
|
11
|
+
const expected = normalizeExpectedMac(expectedMac)
|
|
10
12
|
let raw, initialized, closing = false, cleanup
|
|
11
13
|
const closed = () => Object.assign(new Error('Browser ROM adapter is closed'), { code: 'ROM_CLOSED' })
|
|
12
14
|
const cancelled = () => signal.reason instanceof Error ? signal.reason : Object.assign(new Error('Firmware operation cancelled'), { name: 'AbortError' })
|
|
@@ -25,6 +27,7 @@ export function openBrowserRom({ port, logger = {}, signal, interface: romInterf
|
|
|
25
27
|
get chipName() { return raw?.chipName ?? null },
|
|
26
28
|
get chipFamily() { return raw?.chipFamily ?? null },
|
|
27
29
|
get chipRevision() { return raw?.chipRevision ?? null },
|
|
30
|
+
get chipMac() { return raw?.chipMac ?? null },
|
|
28
31
|
get interface() { return raw?.interface ?? romInterface },
|
|
29
32
|
get baudRate() { return raw?.baudRate ?? null },
|
|
30
33
|
initialize() {
|
|
@@ -43,7 +46,7 @@ export function openBrowserRom({ port, logger = {}, signal, interface: romInterf
|
|
|
43
46
|
log: (...args) => logger.log?.(...args),
|
|
44
47
|
error: (...args) => logger.error?.(...args),
|
|
45
48
|
debug: (...args) => logger.debug?.(...args),
|
|
46
|
-
}, { interface: romInterface, baudRate, onStatus })
|
|
49
|
+
}, { interface: romInterface, baudRate, onStatus, signal, expectedMac: expected })
|
|
47
50
|
if (closing || signal?.aborted) { await disconnect(); check() }
|
|
48
51
|
await raw.initialize()
|
|
49
52
|
check()
|
package/src/browser_rom.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// Loaded only by openBrowserRom.initialize(), never by the root/Node entry point.
|
|
4
4
|
import { ESPLoader, Transport } from 'esptool-js'
|
|
5
5
|
import { browserRomCompatibility, browserTransportCompatibility, readBrowserFlash, romError, romWords } from './browser_rom_compat.js'
|
|
6
|
+
import { normalizeExpectedMac } from './rom_identity.js'
|
|
6
7
|
|
|
7
8
|
export const ROM_UART_BAUD_RATES = Object.freeze([2000000, 1500000, 921600, 460800, 115200])
|
|
8
9
|
const families = { 'ESP32-S3': 0x3253, 'ESP32-P4': 0x3280, 'ESP32-S31': 0x3231 }
|
|
@@ -29,8 +30,9 @@ export function createBrowserLoader(port, logger = {}, options = {}) {
|
|
|
29
30
|
// Dependency injection is internal to the package and permits lifecycle/error
|
|
30
31
|
// testing against fake official-loader dependencies without browser globals.
|
|
31
32
|
export function createOfficialBrowserLoader({ ESPLoader: Loader, Transport: SerialTransport }, port, logger = {}, {
|
|
32
|
-
interface: requestedInterface = 'auto', baudRate = 'auto', onStatus = () => {},
|
|
33
|
+
interface: requestedInterface = 'auto', baudRate = 'auto', onStatus = () => {}, signal, expectedMac,
|
|
33
34
|
} = {}) {
|
|
35
|
+
const expected = normalizeExpectedMac(expectedMac)
|
|
34
36
|
let kind = browserRomInterface(port, requestedInterface)
|
|
35
37
|
if (baudRate !== 'auto' && !ROM_UART_BAUD_RATES.includes(baudRate)) throw new RangeError('Unsupported ROM UART baudRate')
|
|
36
38
|
let loader, transport, identity, initialized = false, active = false, fault, writing = false, disconnected = false
|
|
@@ -61,8 +63,19 @@ export function createOfficialBrowserLoader({ ESPLoader: Loader, Transport: Seri
|
|
|
61
63
|
await loader.detectChip(mode, 3)
|
|
62
64
|
if (disconnected) throw romError('ROM_CLOSED', 'Browser ROM adapter is closed')
|
|
63
65
|
if (!loader.chip || !families[loader.chip.CHIP_NAME]) throw romError('ROM_CHIP_UNSUPPORTED', 'Browser flashing supports ESP32-S3, ESP32-P4 and ESP32-S31')
|
|
64
|
-
const
|
|
65
|
-
if (
|
|
66
|
+
const revision = await loader.chip.getChipRevision(loader)
|
|
67
|
+
if (typeof loader.chip.readMac !== 'function') throw romError('ROM_IDENTITY_INVALID', 'The ROM chip does not expose its factory MAC reader')
|
|
68
|
+
const rawMac = await loader.chip.readMac(loader)
|
|
69
|
+
// The pinned official chip API returns six colon-separated hex octets.
|
|
70
|
+
// Keep register layouts and byte ordering owned by that implementation.
|
|
71
|
+
if (typeof rawMac !== 'string' || rawMac.length !== 17 || !/^(?:[0-9a-f]{2}:){5}[0-9a-f]{2}$/i.test(rawMac)) throw romError('ROM_IDENTITY_INVALID', 'The ROM did not return a complete factory MAC')
|
|
72
|
+
const mac = rawMac.replaceAll(':', '').toLowerCase()
|
|
73
|
+
const current = { name: loader.chip.CHIP_NAME, family: families[loader.chip.CHIP_NAME], revision, mac }
|
|
74
|
+
if ((expected !== undefined && current.mac !== expected)
|
|
75
|
+
|| (identity && (identity.family !== current.family || identity.revision !== current.revision || identity.mac !== current.mac))) {
|
|
76
|
+
throw romError('ROM_IDENTITY_CHANGED', 'ROM identity differs from the selected device or the bound download session')
|
|
77
|
+
}
|
|
78
|
+
if (disconnected) throw romError('ROM_CLOSED', 'Browser ROM adapter is closed')
|
|
66
79
|
identity = current
|
|
67
80
|
if (kind === 'unknown') {
|
|
68
81
|
if (await loader.usesUsbOtg()) kind = 'usb-otg'
|
|
@@ -71,8 +84,13 @@ export function createOfficialBrowserLoader({ ESPLoader: Loader, Transport: Seri
|
|
|
71
84
|
initialized = true
|
|
72
85
|
status({ phase: 'rom-connected', interface: kind, baudRate: transport.baudrate })
|
|
73
86
|
} catch (error) {
|
|
74
|
-
initialized = false
|
|
87
|
+
initialized = active = false
|
|
75
88
|
await closeTransport().catch(() => {})
|
|
89
|
+
if (error.code === 'ROM_IDENTITY_CHANGED' || error.code === 'ROM_IDENTITY_INVALID') {
|
|
90
|
+
fault = error
|
|
91
|
+
disconnected = true
|
|
92
|
+
throw error
|
|
93
|
+
}
|
|
76
94
|
const lost = /device.*lost|disconnected|setSignals|networkerror/i.test(`${error.name} ${error.message}`)
|
|
77
95
|
if (lost) throw romError('ROM_PORT_LOST', 'Download port detached or re-enumerated; select its current ROM port again', error)
|
|
78
96
|
if (kind === 'usb-otg') throw romError('ROM_MANUAL_DOWNLOAD', 'USB-OTG ROM is not ready. Hold BOOT, press RESET, release BOOT, then select the ROM port again', error)
|
|
@@ -128,7 +146,7 @@ export function createOfficialBrowserLoader({ ESPLoader: Loader, Transport: Seri
|
|
|
128
146
|
return
|
|
129
147
|
} catch (error) {
|
|
130
148
|
status({ phase: 'baud-failed', interface: kind, baudRate: candidate, code: error.code ?? 'ROM_BAUD_FAILED', message: error.message })
|
|
131
|
-
if (error.code === 'ROM_IDENTITY_CHANGED' || error.code === 'ROM_SECURITY' || error.code === 'ROM_PORT_LOST') throw error
|
|
149
|
+
if (error.code === 'ROM_IDENTITY_CHANGED' || error.code === 'ROM_IDENTITY_INVALID' || error.code === 'ROM_SECURITY' || error.code === 'ROM_PORT_LOST') throw error
|
|
132
150
|
if (index === candidates.length - 1) throw romError('ROM_BAUD_FAILED', 'No requested UART download speed passed verification', error)
|
|
133
151
|
}
|
|
134
152
|
}
|
|
@@ -160,6 +178,37 @@ export function createOfficialBrowserLoader({ ESPLoader: Loader, Transport: Seri
|
|
|
160
178
|
readRegister: address => operation(() => loader.readReg(address)),
|
|
161
179
|
writeRegister: (address, value, mask = 0xffffffff) => operation(() => loader.writeReg(address, value, mask)),
|
|
162
180
|
readFlash: (address, size, progress, options) => operation(() => readBrowserFlash(loader, address, size, progress, options)),
|
|
181
|
+
eraseRegion(address, size) {
|
|
182
|
+
return operation(async () => {
|
|
183
|
+
if (!Number.isSafeInteger(address) || address < 0 || address % 4096 !== 0
|
|
184
|
+
|| !Number.isSafeInteger(size) || size <= 0 || size > 0xffffffff || size % 4096 !== 0
|
|
185
|
+
|| address + size > 0x100000000) throw new RangeError('ROM erase requires a positive, sector-aligned 32-bit address range')
|
|
186
|
+
if (loader.ESP_ERASE_REGION !== 0xd1 || typeof loader.timeoutPerMb !== 'function'
|
|
187
|
+
|| !Number.isFinite(loader.ERASE_REGION_TIMEOUT_PER_MB) || loader.ERASE_REGION_TIMEOUT_PER_MB <= 0) throw romError('ROM_ERASE_UNSUPPORTED', 'Use the qualified esptool-js region erase primitives')
|
|
188
|
+
const cancelled = () => signal.reason instanceof Error ? signal.reason : Object.assign(romError('CANCELLED', 'Flash region erase cancelled'), { name: 'AbortError' })
|
|
189
|
+
if (signal?.aborted) throw cancelled()
|
|
190
|
+
let abort
|
|
191
|
+
const aborted = new Promise((_, reject) => {
|
|
192
|
+
abort = () => { reject(cancelled()); void adapter.disconnect().catch(() => {}) }
|
|
193
|
+
})
|
|
194
|
+
signal?.addEventListener('abort', abort, { once: true })
|
|
195
|
+
try {
|
|
196
|
+
if (signal?.aborted) throw cancelled()
|
|
197
|
+
status({ phase: 'erasing-region', address, size })
|
|
198
|
+
// 0.7.1 exposes the region command and checked request primitives,
|
|
199
|
+
// but no eraseRegion convenience method. Retain its framing, status
|
|
200
|
+
// parser and size-scaled timeout rather than implementing transport.
|
|
201
|
+
const erasing = loader.checkCommand('erase region', loader.ESP_ERASE_REGION, romWords(address, size), 0, 0,
|
|
202
|
+
loader.timeoutPerMb(loader.ERASE_REGION_TIMEOUT_PER_MB, size))
|
|
203
|
+
const result = await (signal ? Promise.race([erasing, aborted]) : erasing)
|
|
204
|
+
assertReady()
|
|
205
|
+
if (signal?.aborted) throw cancelled()
|
|
206
|
+
if (!transport.device?.readable || !transport.device?.writable) throw romError('ROM_PORT_LOST', 'Serial device disconnected during Flash region erase')
|
|
207
|
+
if (!active || !loader.IS_STUB) throw romError('ROM_STUB_REQUIRED', 'Flash region erase lost its validated stub')
|
|
208
|
+
if (result !== 0) throw romError('ROM_ERASE_STATUS', `Flash region erase command failed: ${result}`)
|
|
209
|
+
} finally { signal?.removeEventListener('abort', abort) }
|
|
210
|
+
})
|
|
211
|
+
},
|
|
163
212
|
flashData(data, progress, address, _compress) {
|
|
164
213
|
return operation(async () => {
|
|
165
214
|
if (writing) throw romError('ROM_WRITE_REUSE', 'Start a fresh download session before another firmware write')
|
|
@@ -175,6 +224,7 @@ export function createOfficialBrowserLoader({ ESPLoader: Loader, Transport: Seri
|
|
|
175
224
|
get chipName() { return identity?.name ?? null },
|
|
176
225
|
get chipFamily() { return identity?.family ?? null },
|
|
177
226
|
get chipRevision() { return identity?.revision ?? null },
|
|
227
|
+
get chipMac() { return identity?.mac ?? null },
|
|
178
228
|
get interface() { return kind },
|
|
179
229
|
get baudRate() { return kind === 'uart' ? transport?.baudrate ?? 115200 : null },
|
|
180
230
|
async initialize() { if (disconnected) throw romError('ROM_CLOSED', 'Browser ROM adapter is closed'); await detect(kind === 'uart' || kind === 'usb-jtag' ? 'default_reset' : 'no_reset') },
|
package/src/device_session.js
CHANGED
|
@@ -343,6 +343,7 @@ export class DeviceSession {
|
|
|
343
343
|
this.vmReadinessProbeActive = false
|
|
344
344
|
this.pendingRpc = new Map()
|
|
345
345
|
this.draining = false
|
|
346
|
+
this.startupDrainWait = null
|
|
346
347
|
this.lastDebugImage = null
|
|
347
348
|
this.runCapture = null
|
|
348
349
|
this.runStarting = false
|
|
@@ -690,6 +691,7 @@ export class DeviceSession {
|
|
|
690
691
|
|
|
691
692
|
feedBytes(bytes) {
|
|
692
693
|
this.parser.feedBytes(bytes)
|
|
694
|
+
this.startupDrainWait?.check()
|
|
693
695
|
}
|
|
694
696
|
|
|
695
697
|
feedText(text) {
|
|
@@ -1031,6 +1033,62 @@ export class DeviceSession {
|
|
|
1031
1033
|
return tracked
|
|
1032
1034
|
}
|
|
1033
1035
|
|
|
1036
|
+
_startupFrameError(reason) {
|
|
1037
|
+
this._diagnostic('negotiate.startup_incomplete', { reason, parser: this.parser.getDiagnostics?.() })
|
|
1038
|
+
return Object.assign(new Error('Incomplete EV-MUX frame at end of startup drain'), {
|
|
1039
|
+
code: 'SERIAL_STARTUP_INCOMPLETE_FRAME',
|
|
1040
|
+
})
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
_waitForStartupFrameBoundary(signal, timeoutCount, initial, totalDeadline) {
|
|
1044
|
+
this._diagnostic('negotiate.startup_frame_wait', { parser: this.parser.getDiagnostics?.() })
|
|
1045
|
+
return new Promise((resolve, reject) => {
|
|
1046
|
+
let timer = null
|
|
1047
|
+
let settled = false
|
|
1048
|
+
let receiveDeadline = Math.min(totalDeadline, initial.progressDeadline)
|
|
1049
|
+
const finish = (error) => {
|
|
1050
|
+
if (settled) return
|
|
1051
|
+
settled = true
|
|
1052
|
+
clearTimeout(timer)
|
|
1053
|
+
signal?.removeEventListener('abort', abort)
|
|
1054
|
+
if (this.startupDrainWait === waiter) this.startupDrainWait = null
|
|
1055
|
+
if (error) reject(error)
|
|
1056
|
+
else resolve()
|
|
1057
|
+
}
|
|
1058
|
+
const abort = () => finish(abortError(signal))
|
|
1059
|
+
const check = () => {
|
|
1060
|
+
if (settled) return
|
|
1061
|
+
clearTimeout(timer)
|
|
1062
|
+
if (signal?.aborted) { abort(); return }
|
|
1063
|
+
// A parser timeout may have emptied the buffer before this callback.
|
|
1064
|
+
// Discarding an unfinished frame is never a completed startup boundary.
|
|
1065
|
+
if ((this.parser.getDiagnostics?.()?.incompleteFrameTimeouts ?? 0) !== timeoutCount) {
|
|
1066
|
+
finish(this._startupFrameError('receive_timeout'))
|
|
1067
|
+
return
|
|
1068
|
+
}
|
|
1069
|
+
if (Date.now() >= receiveDeadline) {
|
|
1070
|
+
finish(this._startupFrameError('receive_timeout'))
|
|
1071
|
+
return
|
|
1072
|
+
}
|
|
1073
|
+
if (!this.parser.hasPartialFrame?.()) { finish(); return }
|
|
1074
|
+
const current = this.parser._partialFrameBudget?.()
|
|
1075
|
+
if (!Number.isFinite(current?.totalDeadline) || !Number.isFinite(current?.progressDeadline)) {
|
|
1076
|
+
finish(this._startupFrameError('receive_budget_unavailable'))
|
|
1077
|
+
return
|
|
1078
|
+
}
|
|
1079
|
+
// New frames in a coalesced chunk must not extend the original bound.
|
|
1080
|
+
receiveDeadline = Math.min(totalDeadline, current.totalDeadline, current.progressDeadline)
|
|
1081
|
+
const remaining = receiveDeadline - Date.now()
|
|
1082
|
+
if (remaining <= 0) { finish(this._startupFrameError('receive_timeout')); return }
|
|
1083
|
+
timer = setTimeout(() => finish(this._startupFrameError('receive_timeout')), remaining)
|
|
1084
|
+
}
|
|
1085
|
+
const waiter = { check, fail: finish }
|
|
1086
|
+
this.startupDrainWait = waiter
|
|
1087
|
+
signal?.addEventListener('abort', abort, { once: true })
|
|
1088
|
+
check()
|
|
1089
|
+
})
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1034
1092
|
async _negotiate({ role = 'unknown', drainMs = 300, signal = null, acquire = true, probeRepl = true, expectedDevice = null, autoBaud = true } = {}) {
|
|
1035
1093
|
this.role = role
|
|
1036
1094
|
this.state = 'negotiating'
|
|
@@ -1039,23 +1097,40 @@ export class DeviceSession {
|
|
|
1039
1097
|
// Unclaimed TCP sockets expire in three seconds: acquire immediately.
|
|
1040
1098
|
if (role === 'tcp') drainMs = 0
|
|
1041
1099
|
if (drainMs > 0) {
|
|
1100
|
+
const timeoutCount = this.parser.getDiagnostics?.()?.incompleteFrameTimeouts ?? 0
|
|
1042
1101
|
this.draining = true
|
|
1043
1102
|
try {
|
|
1044
1103
|
await awaitWithSignal(this.sleep(drainMs), signal)
|
|
1104
|
+
if (signal?.aborted) throw abortError(signal)
|
|
1105
|
+
if ((this.parser.getDiagnostics?.()?.incompleteFrameTimeouts ?? 0) !== timeoutCount) {
|
|
1106
|
+
throw this._startupFrameError('receive_timeout')
|
|
1107
|
+
}
|
|
1108
|
+
let totalDeadline = null
|
|
1109
|
+
while (this.parser.hasPartialFrame?.()) {
|
|
1110
|
+
const initial = this.parser._partialFrameBudget?.()
|
|
1111
|
+
if (!Number.isFinite(initial?.totalDeadline) || !Number.isFinite(initial?.progressDeadline)) {
|
|
1112
|
+
throw this._startupFrameError('receive_budget_unavailable')
|
|
1113
|
+
}
|
|
1114
|
+
totalDeadline ??= initial.totalDeadline
|
|
1115
|
+
await this._waitForStartupFrameBoundary(signal, timeoutCount, initial, totalDeadline)
|
|
1116
|
+
// An adapter may synchronously deliver another chunk before the
|
|
1117
|
+
// resolved boundary promise resumes this negotiation continuation.
|
|
1118
|
+
if ((this.parser.getDiagnostics?.()?.incompleteFrameTimeouts ?? 0) !== timeoutCount) {
|
|
1119
|
+
throw this._startupFrameError('receive_timeout')
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
} catch (error) {
|
|
1123
|
+
if (error.code === 'SERIAL_STARTUP_INCOMPLETE_FRAME') {
|
|
1124
|
+
this.close(error)
|
|
1125
|
+
this.parser.reset()
|
|
1126
|
+
await this.transport.disconnect?.()
|
|
1127
|
+
}
|
|
1128
|
+
throw error
|
|
1045
1129
|
} finally {
|
|
1046
1130
|
this.draining = false
|
|
1047
1131
|
}
|
|
1048
1132
|
}
|
|
1049
1133
|
if (signal?.aborted) throw abortError(signal)
|
|
1050
|
-
if (drainMs > 0 && this.parser.hasPartialFrame?.()) {
|
|
1051
|
-
const error = Object.assign(new Error('Incomplete EV-MUX frame at end of startup drain'), {
|
|
1052
|
-
code: 'SERIAL_STARTUP_INCOMPLETE_FRAME',
|
|
1053
|
-
})
|
|
1054
|
-
this.close(error)
|
|
1055
|
-
this.parser.reset()
|
|
1056
|
-
await this.transport.disconnect?.()
|
|
1057
|
-
throw error
|
|
1058
|
-
}
|
|
1059
1134
|
|
|
1060
1135
|
let capabilities
|
|
1061
1136
|
let discovery
|
|
@@ -1843,6 +1918,7 @@ export class DeviceSession {
|
|
|
1843
1918
|
|
|
1844
1919
|
close(error = new Error('connection closed')) {
|
|
1845
1920
|
this.negotiateController?.abort(error)
|
|
1921
|
+
this.startupDrainWait?.fail(error)
|
|
1846
1922
|
this.draining = false
|
|
1847
1923
|
clearTimeout(this.keepaliveTimer)
|
|
1848
1924
|
this.keepaliveTimer = null
|
package/src/evmux.js
CHANGED
|
@@ -108,6 +108,7 @@ export class EvMuxParser {
|
|
|
108
108
|
this.frameTotalTimeoutMs = 12000
|
|
109
109
|
this.partialFrame = null
|
|
110
110
|
this.partialFrameTimer = null
|
|
111
|
+
this.incompleteFrameTimeouts = 0
|
|
111
112
|
}
|
|
112
113
|
|
|
113
114
|
setLimits({ metadataMax, payloadMax, frameProgressTimeoutMs, frameTotalTimeoutMs } = {}) {
|
|
@@ -152,6 +153,7 @@ export class EvMuxParser {
|
|
|
152
153
|
return
|
|
153
154
|
}
|
|
154
155
|
this._clearPartialFrame()
|
|
156
|
+
this.incompleteFrameTimeouts += 1
|
|
155
157
|
this.buffer = new Uint8Array(0)
|
|
156
158
|
this.rawTextDecoder = new TextDecoder('utf-8', { fatal: false })
|
|
157
159
|
this.replTextDecoders = {
|
|
@@ -180,6 +182,17 @@ export class EvMuxParser {
|
|
|
180
182
|
this.droppedFrames = 0
|
|
181
183
|
this.invalidImageFrames = 0
|
|
182
184
|
this.lastChannel = null
|
|
185
|
+
this.incompleteFrameTimeouts = 0
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
_partialFrameBudget() {
|
|
189
|
+
if (!this.partialFrame) return null
|
|
190
|
+
return {
|
|
191
|
+
startedAt: this.partialFrame.startedAt,
|
|
192
|
+
lastProgressAt: this.partialFrame.lastProgressAt,
|
|
193
|
+
progressDeadline: this.partialFrame.lastProgressAt + this.frameProgressTimeoutMs,
|
|
194
|
+
totalDeadline: this.partialFrame.startedAt + this.frameTotalTimeoutMs,
|
|
195
|
+
}
|
|
183
196
|
}
|
|
184
197
|
|
|
185
198
|
getDiagnostics() {
|
|
@@ -192,6 +205,8 @@ export class EvMuxParser {
|
|
|
192
205
|
droppedFrames: this.droppedFrames,
|
|
193
206
|
invalidImageFrames: this.invalidImageFrames,
|
|
194
207
|
lastChannel: this.lastChannel,
|
|
208
|
+
partialFrame: this._partialFrameBudget(),
|
|
209
|
+
incompleteFrameTimeouts: this.incompleteFrameTimeouts,
|
|
195
210
|
}
|
|
196
211
|
}
|
|
197
212
|
|
package/src/firmware_flash.js
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
// SPDX-FileCopyrightText: 2026 ESP-VISION-IDE
|
|
2
2
|
// SPDX-License-Identifier: Apache-2.0
|
|
3
3
|
// ROM flashing is separate from DeviceSession and its EV-MUX v4 owner protocol.
|
|
4
|
+
import { planInstallFilesystemErase } from './firmware_partitions.js'
|
|
5
|
+
|
|
6
|
+
function checkAbort(signal) {
|
|
7
|
+
if (signal?.aborted) throw signal.reason instanceof Error ? signal.reason : Object.assign(new Error('Firmware operation cancelled'), { name: 'AbortError' })
|
|
8
|
+
}
|
|
4
9
|
export async function firmwareSha256(bytes) {
|
|
5
10
|
const digest = await globalThis.crypto.subtle.digest('SHA-256', bytes)
|
|
6
11
|
return Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, '0')).join('')
|
|
@@ -35,45 +40,68 @@ export async function validateFirmwareImage(image, bytes, { mode = 'update' } =
|
|
|
35
40
|
|
|
36
41
|
/** Loader injected by the browser host; no Web Serial or ROM protocol duplicated. */
|
|
37
42
|
export class FirmwareFlashSession {
|
|
38
|
-
|
|
43
|
+
#image
|
|
44
|
+
#mode
|
|
45
|
+
#filesystem
|
|
46
|
+
#signal
|
|
47
|
+
|
|
48
|
+
constructor(loader, image, { reset = () => loader.hardReset(), mode = 'update', signal } = {}) {
|
|
39
49
|
this.mode = firmwareMode(mode)
|
|
40
50
|
this.loader = loader
|
|
41
51
|
this.reset = reset
|
|
42
52
|
this.image = image
|
|
43
53
|
this.state = 'idle'
|
|
44
54
|
this.stub = null
|
|
55
|
+
this.#signal = signal
|
|
45
56
|
}
|
|
46
57
|
|
|
47
58
|
async connect(bytes) {
|
|
48
59
|
if (this.state !== 'idle') throw new Error('Firmware loader is already in use')
|
|
49
60
|
this.state = 'connecting'
|
|
50
61
|
try {
|
|
51
|
-
|
|
62
|
+
checkAbort(this.#signal)
|
|
63
|
+
// Keep qualification independent of caller mutations during callbacks or
|
|
64
|
+
// asynchronous ROM operations. Public metadata is not an erase authority.
|
|
65
|
+
const image = this.#image = structuredClone(this.image)
|
|
66
|
+
const mode = this.#mode = firmwareMode(this.mode)
|
|
67
|
+
const payload = bytes instanceof Uint8Array ? new Uint8Array(bytes) : bytes
|
|
68
|
+
await validateFirmwareImage(image, payload, { mode })
|
|
69
|
+
this.#filesystem = mode === 'install' ? planInstallFilesystemErase(image, payload) : null
|
|
70
|
+
checkAbort(this.#signal)
|
|
52
71
|
await this.loader.initialize()
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
72
|
+
checkAbort(this.#signal)
|
|
73
|
+
const revisionMatches = image.chipRevision !== undefined ? this.loader.chipRevision === image.chipRevision
|
|
74
|
+
: Number.isInteger(image.chipRevisionMin) && Number.isInteger(image.chipRevisionMax) && this.loader.chipRevision >= image.chipRevisionMin && this.loader.chipRevision <= image.chipRevisionMax
|
|
75
|
+
if (this.loader.chipFamily !== image.chipFamily || !Number.isInteger(this.loader.chipRevision) || !revisionMatches) {
|
|
56
76
|
throw new Error('Chip or silicon revision does not match the approved board firmware')
|
|
57
77
|
}
|
|
58
78
|
const security = await this.loader.getSecurityInfo()
|
|
59
|
-
|
|
79
|
+
checkAbort(this.#signal)
|
|
80
|
+
if (!Number.isSafeInteger(security.flags) || security.flags < 0 || security.flags > 0xffffffff
|
|
81
|
+
|| !Number.isSafeInteger(security.flashCryptCnt) || security.flashCryptCnt < 0 || security.flashCryptCnt > 0xffffffff) {
|
|
60
82
|
throw new Error('Cannot qualify the device ROM security mode')
|
|
61
83
|
}
|
|
62
84
|
const encrypted = security.flashCryptCnt.toString(2).replace(/0/g, '').length % 2 === 1
|
|
63
|
-
if ((security.flags &
|
|
85
|
+
if ((security.flags & 5) !== 0 || encrypted) throw new Error('Secure boot, secure download or encrypted devices require their signed flashing workflow')
|
|
64
86
|
this.stub = await this.loader.runStub(true)
|
|
65
|
-
|
|
87
|
+
checkAbort(this.#signal)
|
|
88
|
+
if (mode === 'install') {
|
|
66
89
|
// JEDEC capacity byte encodes log2(bytes); shared with browser loaders.
|
|
67
90
|
if (typeof this.stub.flashId !== 'function') throw new Error('Installation requires ROM flash capacity detection (flashId)')
|
|
68
91
|
const id = await this.stub.flashId()
|
|
92
|
+
checkAbort(this.#signal)
|
|
69
93
|
const capacity = (id >>> 16) & 0xff
|
|
70
94
|
if (!Number.isSafeInteger(id) || id <= 0 || id > 0xffffff || capacity < 18 || capacity > 30
|
|
71
|
-
|| 2 ** capacity <
|
|
95
|
+
|| 2 ** capacity < image.flashSize) throw new Error('Detected flash is unknown or smaller than the approved complete image layout')
|
|
96
|
+
if (typeof this.stub.eraseRegion !== 'function' || typeof this.stub.readFlash !== 'function' || typeof this.stub.flashData !== 'function') {
|
|
97
|
+
throw Object.assign(new Error('Complete installation requires bounded filesystem erase and readback support'), { code: 'FIRMWARE_ERASE_UNSUPPORTED' })
|
|
98
|
+
}
|
|
72
99
|
} else {
|
|
73
|
-
const table =
|
|
100
|
+
const table = image.partitionTable
|
|
74
101
|
const existing = await this.stub.readFlash(table.offset, table.bytes)
|
|
75
102
|
if (await firmwareSha256(existing) !== table.sha256) throw new Error('Partition layout differs; select explicit install mode to replace the layout')
|
|
76
103
|
}
|
|
104
|
+
checkAbort(this.#signal)
|
|
77
105
|
this.state = 'connected'
|
|
78
106
|
} catch (error) { await this.disconnect(); throw error }
|
|
79
107
|
}
|
|
@@ -82,33 +110,63 @@ export class FirmwareFlashSession {
|
|
|
82
110
|
if (this.state !== 'connected') throw new Error('Connect and validate the device before flashing')
|
|
83
111
|
this.state = 'flashing'
|
|
84
112
|
try {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
113
|
+
checkAbort(this.#signal)
|
|
114
|
+
const image = this.#image, install = this.#mode === 'install'
|
|
115
|
+
const payload = bytes instanceof Uint8Array ? new Uint8Array(bytes) : bytes
|
|
116
|
+
await validateFirmwareImage(image, payload, { mode: this.#mode })
|
|
117
|
+
checkAbort(this.#signal)
|
|
118
|
+
if (install) {
|
|
119
|
+
const { offset, size } = this.#filesystem
|
|
120
|
+
onPhase('Erasing internal FAT filesystem; device files will be deleted')
|
|
121
|
+
checkAbort(this.#signal)
|
|
122
|
+
// Erase only the range parsed from the qualified image. No whole-chip
|
|
123
|
+
// erase, backups or automatic recovery/replay are part of installation.
|
|
124
|
+
await this.stub.eraseRegion(offset, size)
|
|
125
|
+
checkAbort(this.#signal)
|
|
126
|
+
onPhase('Verifying the erased internal FAT filesystem')
|
|
127
|
+
checkAbort(this.#signal)
|
|
128
|
+
for (let position = 0; position < size; position += 65536) {
|
|
129
|
+
const count = Math.min(65536, size - position)
|
|
130
|
+
const chunk = await this.stub.readFlash(offset + position, count, undefined,
|
|
131
|
+
{ chunkSize: 65536, blockSize: 1024, maxInFlight: 1 })
|
|
132
|
+
checkAbort(this.#signal)
|
|
133
|
+
if (!(chunk instanceof Uint8Array) || chunk.byteLength !== count || chunk.some(value => value !== 0xff)) {
|
|
134
|
+
throw Object.assign(new Error('Internal FAT filesystem erase verification failed; keep the device in download mode'), { code: 'FIRMWARE_ERASE_VERIFY_FAILED' })
|
|
135
|
+
}
|
|
136
|
+
onProgress(Math.floor(20 * (position + count) / size))
|
|
137
|
+
checkAbort(this.#signal)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
onPhase(install ? 'Installing complete firmware; internal FAT filesystem cleared' : 'Writing application; bootloader, partition table, NVS and user files are preserved')
|
|
141
|
+
checkAbort(this.#signal)
|
|
142
|
+
await this.stub.flashData(payload.buffer.slice(payload.byteOffset, payload.byteOffset + payload.byteLength),
|
|
143
|
+
(written, total) => onProgress((install ? 20 : 0) + (total ? Math.min(install ? 60 : 80, Math.round(written / total * (install ? 60 : 80))) : 0)), image.offset, true)
|
|
144
|
+
// Once firmware writing starts, preserve the existing completion policy:
|
|
145
|
+
// finish verification/reset rather than interrupting a partial image.
|
|
146
|
+
onPhase(install ? 'Verifying the complete installation image' : 'Verifying the complete application image')
|
|
90
147
|
// maxInFlight is a packet count: one 1 KiB packet bounds USB-JTAG backlog.
|
|
91
|
-
const readback = new Uint8Array(
|
|
92
|
-
for (let offset = 0; offset <
|
|
93
|
-
const count = Math.min(65536,
|
|
94
|
-
const chunk = await this.stub.readFlash(
|
|
148
|
+
const readback = new Uint8Array(payload.byteLength)
|
|
149
|
+
for (let offset = 0; offset < payload.byteLength; offset += 65536) {
|
|
150
|
+
const count = Math.min(65536, payload.byteLength - offset)
|
|
151
|
+
const chunk = await this.stub.readFlash(image.offset + offset, count, undefined,
|
|
95
152
|
{ chunkSize: 65536, blockSize: 1024, maxInFlight: 1 })
|
|
96
153
|
if (chunk.byteLength !== count) throw new Error('Written firmware readback is incomplete')
|
|
97
154
|
readback.set(chunk, offset)
|
|
98
|
-
onProgress(80 + Math.floor(20 * (offset + count) /
|
|
155
|
+
onProgress(80 + Math.min(19, Math.floor(20 * (offset + count) / payload.byteLength)))
|
|
99
156
|
}
|
|
100
|
-
if (
|
|
157
|
+
if (await firmwareSha256(readback) !== image.sha256) throw new Error('Written firmware verification failed; keep the device in download mode for recovery')
|
|
158
|
+
onProgress(100)
|
|
101
159
|
this.state = 'verified'
|
|
102
160
|
try {
|
|
103
|
-
// P4 software download requests survive a CPU reset. Clear only that
|
|
161
|
+
// P4/S31 software download requests survive a CPU reset. Clear only that
|
|
104
162
|
// latch, verify all other live fields, then apply the host reset policy.
|
|
105
|
-
if (
|
|
106
|
-
const address = 0x50110008, mask = 4
|
|
163
|
+
if (image.chipFamily === 0x3280 || image.chipFamily === 0x3231) {
|
|
164
|
+
const address = image.chipFamily === 0x3280 ? 0x50110008 : 0x20700008, mask = 4
|
|
107
165
|
const before = await this.stub.readRegister(address)
|
|
108
|
-
if (!Number.isInteger(before) || before < 0 || before > 0xffffffff) throw new Error('Invalid
|
|
166
|
+
if (!Number.isInteger(before) || before < 0 || before > 0xffffffff) throw new Error('Invalid download system control register')
|
|
109
167
|
if (before & mask) await this.stub.writeRegister(address, 0, mask)
|
|
110
168
|
const after = await this.stub.readRegister(address)
|
|
111
|
-
if (after !== ((before & ~mask) >>> 0)) throw new Error('
|
|
169
|
+
if (after !== ((before & ~mask) >>> 0)) throw new Error('Normal boot latch verification failed')
|
|
112
170
|
}
|
|
113
171
|
await this.reset()
|
|
114
172
|
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
import { romMd5 } from './rom_md5.js'
|
|
4
|
+
|
|
5
|
+
const SECTOR_BYTES = 4096
|
|
6
|
+
const invalid = message => Object.assign(new Error(`Invalid installation partition table: ${message}`), { code: 'FIRMWARE_PARTITION_INVALID' })
|
|
7
|
+
const allFF = bytes => bytes.every(value => value === 0xff)
|
|
8
|
+
|
|
9
|
+
// Call only after validateFirmwareImage has authenticated the complete image
|
|
10
|
+
// and every declared segment. Never infer an erase range from a host/UI field.
|
|
11
|
+
export function planInstallFilesystemErase(image, bytes) {
|
|
12
|
+
const table = image.partitionTable
|
|
13
|
+
if (table.offset % SECTOR_BYTES || table.bytes % 32 || table.bytes > 0xc00) throw invalid('unsupported table bounds')
|
|
14
|
+
const raw = bytes.subarray(table.offset, table.offset + table.bytes)
|
|
15
|
+
const view = new DataView(raw.buffer, raw.byteOffset, raw.byteLength)
|
|
16
|
+
const entries = [], labels = new Set()
|
|
17
|
+
let digestSeen = false, terminated = false
|
|
18
|
+
for (let cursor = 0; cursor < raw.length; cursor += 32) {
|
|
19
|
+
const entry = raw.subarray(cursor, cursor + 32)
|
|
20
|
+
if (allFF(entry)) {
|
|
21
|
+
if (!allFF(raw.subarray(cursor))) throw invalid('data after the end marker')
|
|
22
|
+
terminated = true
|
|
23
|
+
break
|
|
24
|
+
}
|
|
25
|
+
const magic = view.getUint16(cursor, true)
|
|
26
|
+
if (magic === 0xebeb && !digestSeen) {
|
|
27
|
+
if (!allFF(entry.subarray(2, 16))) throw invalid('malformed MD5 record')
|
|
28
|
+
const digest = romMd5(raw.subarray(0, cursor))
|
|
29
|
+
if (!digest.every((value, index) => value === entry[16 + index])) throw invalid('MD5 mismatch')
|
|
30
|
+
digestSeen = true
|
|
31
|
+
continue
|
|
32
|
+
}
|
|
33
|
+
if (magic !== 0x50aa || digestSeen) throw invalid('unexpected entry or data after MD5')
|
|
34
|
+
const offset = view.getUint32(cursor + 4, true), size = view.getUint32(cursor + 8, true)
|
|
35
|
+
const type = entry[2], subtype = entry[3], flags = view.getUint32(cursor + 28, true)
|
|
36
|
+
const labelBytes = entry.subarray(12, 28), end = labelBytes.indexOf(0)
|
|
37
|
+
const label = String.fromCharCode(...labelBytes.subarray(0, end < 0 ? 16 : end))
|
|
38
|
+
if (!label || labels.has(label) || (end >= 0 && labelBytes.subarray(end).some(value => value !== 0))) throw invalid('empty, duplicate or malformed label')
|
|
39
|
+
if (offset < table.offset + SECTOR_BYTES || offset % SECTOR_BYTES || !size
|
|
40
|
+
|| offset + size > image.flashSize || (type === 0 && offset % 65536)) throw invalid('unsupported partition bounds')
|
|
41
|
+
labels.add(label)
|
|
42
|
+
entries.push({ label, type, subtype, offset, size, flags })
|
|
43
|
+
}
|
|
44
|
+
if (!terminated || !entries.length) throw invalid('missing entries or end marker')
|
|
45
|
+
entries.sort((a, b) => a.offset - b.offset)
|
|
46
|
+
for (let index = 1; index < entries.length; index++) {
|
|
47
|
+
if (entries[index].offset < entries[index - 1].offset + entries[index - 1].size) throw invalid('overlapping partitions')
|
|
48
|
+
}
|
|
49
|
+
const app = image.application
|
|
50
|
+
if (!entries.some(entry => entry.type === 0 && entry.offset === app.offset
|
|
51
|
+
&& entry.size === app.partitionSize && app.bytes <= entry.size)) throw invalid('application does not match the approved layout')
|
|
52
|
+
const fat = entries.find(entry => entry.label === 'ffat')
|
|
53
|
+
if (!fat) throw Object.assign(new Error('Complete installation requires a declared internal ffat partition'), { code: 'FIRMWARE_FATFS_UNAVAILABLE' })
|
|
54
|
+
if (fat.type !== 1 || fat.subtype !== 0x81 || fat.flags !== 0 || fat.size % SECTOR_BYTES
|
|
55
|
+
|| fat.offset < Math.ceil(bytes.byteLength / SECTOR_BYTES) * SECTOR_BYTES) throw invalid('ffat is not an unencrypted, writable FAT partition outside the firmware write range')
|
|
56
|
+
return Object.freeze({ label: fat.label, offset: fat.offset, size: fat.size })
|
|
57
|
+
}
|