@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.
Files changed (39) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +8 -5
  3. package/package.json +1 -1
  4. package/schemas/device-actions-v1.json +1 -1
  5. package/scripts/generate-action-schema.mjs +2 -1
  6. package/scripts/npm-command.mjs +17 -0
  7. package/scripts/release-npm.mjs +2 -1
  8. package/src/browser.js +5 -2
  9. package/src/browser_rom.js +55 -5
  10. package/src/device_session.js +85 -9
  11. package/src/evmux.js +15 -0
  12. package/src/firmware_flash.js +84 -26
  13. package/src/firmware_partitions.js +57 -0
  14. package/src/firmware_updates.js +5 -2
  15. package/src/node_rom.js +49 -7
  16. package/src/node_rom.py +92 -5
  17. package/src/node_rom_port.js +27 -19
  18. package/src/node_serial.py +75 -12
  19. package/src/node_transport.js +63 -14
  20. package/src/rom_identity.js +9 -0
  21. package/tests/check-browser.mjs +23 -4
  22. package/tests/check-package.mjs +15 -7
  23. package/tests/device-checks.mjs +89 -7
  24. package/tests/firmware-fixtures.mjs +60 -0
  25. package/tests/fixtures/device-checks.py +357 -1
  26. package/tests/hardware-check.mjs +3 -2
  27. package/tests/test-browser-rom.mjs +169 -3
  28. package/tests/test-ci-check-selection.mjs +31 -0
  29. package/tests/test-device-functional-checks.mjs +275 -3
  30. package/tests/test-device-v4-alignment.mjs +261 -7
  31. package/tests/test-firmware-flash.mjs +22 -20
  32. package/tests/test-firmware-install.mjs +8 -35
  33. package/tests/test-firmware-source.mjs +10 -7
  34. package/tests/test-host-checks.mjs +47 -0
  35. package/tests/test-install-erase.mjs +153 -0
  36. package/tests/test-node-rom-port.mjs +81 -1
  37. package/tests/test-node-rom.mjs +159 -4
  38. package/tests/test-node-serial.mjs +231 -2
  39. 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.6.0**, a minor release after 0.5.1. The **0.6.x** package line 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.
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
- npm view @esp-vision/device-core@0.6.0 version dist.integrity
26
- npm install '@esp-vision/device-core@~0.6.0' --registry=https://registry.npmjs.org/
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>=5.3,<6; 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
+ 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@esp-vision/device-core",
3
- "version": "0.6.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": {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "title": "ESP Vision Device Actions",
4
- "deviceCoreVersion": "0.6.0",
4
+ "deviceCoreVersion": "0.7.0",
5
5
  "schemaVersion": 1,
6
6
  "actions": [
7
7
  {
@@ -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
- assert.equal(await readFile(outputPath, 'utf8'), serialized, 'Device Action schema artifact is stale; regenerate it')
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
+ }
@@ -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('npm', args, { cwd: root, env, encoding: 'utf8' })
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()
@@ -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 current = { name: loader.chip.CHIP_NAME, family: families[loader.chip.CHIP_NAME], revision: await loader.chip.getChipRevision(loader) }
65
- if (identity && (identity.family !== current.family || identity.revision !== current.revision)) throw romError('ROM_IDENTITY_CHANGED', 'Chip identity changed while recovering the download session')
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') },
@@ -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