@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/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,34 @@
|
|
|
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
|
+
|
|
20
|
+
## 0.6.1
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- Accept native Windows CRLF startup lines in the direct Python serial-failure regression so its diagnostic and payload-redaction assertions run on Windows.
|
|
25
|
+
|
|
26
|
+
- Preserve bounded serial read/write OS diagnostics and unexpected Python bridge exit codes/signals instead of reducing them to CONNECTION_CLOSED; omit arbitrary exception text and all transferred bytes, and keep intentional disconnect/cancellation quiet.
|
|
27
|
+
|
|
28
|
+
- Run package and release checks through npm's JavaScript entry point on Windows, preserving paths and arguments without a command shell; accept checkout CRLF in the generated action-schema check while still rejecting changed schema content.
|
|
29
|
+
- Observe native USB re-enumeration for up to five seconds after read-only ROM initialization failure, requiring the same MAC and physical location before one verified runtime handoff; cancellation, ambiguous descriptors and failed reads/writes cannot replay operations.
|
|
30
|
+
- Forward Node readback packet budgets to the external esptool 5.6.0 reader, using one outstanding 1 KiB packet by default and preserving its ACK/MD5 verification. Reject incompatible versions before initialization or writing and restore temporary instance settings after success or failure.
|
|
31
|
+
|
|
3
32
|
## 0.6.0
|
|
4
33
|
|
|
5
34
|
### Added
|
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.
|
|
@@ -52,7 +53,7 @@ esp-vision-device inspect --port COM3 --fields identity,capabilities
|
|
|
52
53
|
esp-vision-device inspect --tcp 192.168.1.100:3232 --fields network,storage --json
|
|
53
54
|
```
|
|
54
55
|
|
|
55
|
-
Wired access/enumeration requires Python with pyserial (`--python` overrides the interpreter); LAN discovery uses optional `multicast-dns`, normally installed by npm. Windows/Linux/macOS paths are accepted. Passive enumeration never opens a serial port; `--probe` and targeted queries acquire a session and may interrupt Python. Unidentified ports, ROM candidates and USB-to-UART adapters remain numbered and visible. Board names are unknown until firmware identification; USB descriptors are hints only. Firmware versions are fetched automatically from dl.espressif.com. Firmware flashing uses the built-in serial adapter with separately installed Python esptool
|
|
56
|
+
Wired access/enumeration requires Python with pyserial (`--python` overrides the interpreter); LAN discovery uses optional `multicast-dns`, normally installed by npm. Windows/Linux/macOS paths are accepted. Passive enumeration never opens a serial port; `--probe` and targeted queries acquire a session and may interrupt Python. Unidentified ports, ROM candidates and USB-to-UART adapters remain numbered and visible. Board names are unknown until firmware identification; USB descriptors are hints only. Firmware versions are fetched automatically from dl.espressif.com. Firmware flashing uses the built-in serial adapter with separately installed Python esptool==5.6.0; no user loader module is required. Use `firmware update --mode install --board BOARD --port PORT --version VERSION` for blank-device installation over a ROM-capable USJ/UART port. Install replaces data within the complete image range; default updates preserve the existing layout. Historical releases without qualified metadata remain listed but cannot be installed by this flow. Check the registry version before installing a newly prepared release.
|
|
56
57
|
|
|
57
58
|
## Public device API
|
|
58
59
|
|
|
@@ -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
|
@@ -27,7 +27,8 @@ const document = {
|
|
|
27
27
|
const serialized = `${JSON.stringify(document, null, 2)}\n`
|
|
28
28
|
|
|
29
29
|
if (process.argv.includes('--check')) {
|
|
30
|
-
|
|
30
|
+
const checkedIn = (await readFile(outputPath, 'utf8')).replaceAll('\r\n', '\n')
|
|
31
|
+
assert.equal(checkedIn, serialized, 'Device Action schema artifact is stale; regenerate it')
|
|
31
32
|
console.log(`Device Action Schema ${DEVICE_ACTION_SCHEMA_VERSION} is current`)
|
|
32
33
|
} else {
|
|
33
34
|
await mkdir(dirname(outputPath), { recursive: true })
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
import { existsSync } from 'node:fs'
|
|
4
|
+
import { dirname, join } from 'node:path'
|
|
5
|
+
import process from 'node:process'
|
|
6
|
+
|
|
7
|
+
// npm.cmd cannot be passed to execFile/spawn without a Windows shell. Invoke
|
|
8
|
+
// npm's JavaScript entry point instead, preserving paths and arguments verbatim.
|
|
9
|
+
export function npmCommand(args, env = process.env) {
|
|
10
|
+
if (env.npm_execpath) return [process.execPath, [env.npm_execpath, ...args]]
|
|
11
|
+
if (process.platform === 'win32') {
|
|
12
|
+
const bundled = join(dirname(process.execPath), 'node_modules', 'npm', 'bin', 'npm-cli.js')
|
|
13
|
+
if (!existsSync(bundled)) throw new Error('Run this check through npm run so npm_execpath identifies npm')
|
|
14
|
+
return [process.execPath, [bundled, ...args]]
|
|
15
|
+
}
|
|
16
|
+
return ['npm', args]
|
|
17
|
+
}
|
package/scripts/release-npm.mjs
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import assert from 'node:assert/strict'
|
|
4
4
|
import { highestStableVersion, validateVersionStep } from './version-policy.mjs'
|
|
5
5
|
import { verifyPublishedIntegrity } from './verify-registry.mjs'
|
|
6
|
+
import { npmCommand } from './npm-command.mjs'
|
|
6
7
|
import { spawnSync } from 'node:child_process'
|
|
7
8
|
import { mkdtemp, readFile, writeFile, rm } from 'node:fs/promises'
|
|
8
9
|
import { tmpdir } from 'node:os'
|
|
@@ -48,7 +49,7 @@ try {
|
|
|
48
49
|
await writeFile(config, `registry=${registry}\n//registry.npmjs.org/:_authToken=\${NPM_PUBLISH_TOKEN}\n`, { mode: 0o600 })
|
|
49
50
|
const env = dryRun ? { ...process.env } : { ...process.env, NPM_CONFIG_USERCONFIG: config }
|
|
50
51
|
function npm(args) {
|
|
51
|
-
const result = spawnSync(
|
|
52
|
+
const result = spawnSync(...npmCommand(args, env), { cwd: root, env, encoding: 'utf8' })
|
|
52
53
|
if (result.status !== 0) throw new Error(`npm ${args[0]} failed: ${result.stderr || result.error?.message}`)
|
|
53
54
|
return result.stdout
|
|
54
55
|
}
|
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
|
|