@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 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.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.
@@ -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.1",
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": "patch"
81
+ "espVisionReleaseType": "minor"
82
82
  }
@@ -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.1",
4
+ "deviceCoreVersion": "0.7.0",
5
5
  "schemaVersion": 1,
6
6
  "actions": [
7
7
  {
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
 
@@ -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
- constructor(loader, image, { reset = () => loader.hardReset(), mode = 'update' } = {}) {
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
- await validateFirmwareImage(this.image, bytes, { mode: this.mode })
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
- const revisionMatches = this.image.chipRevision !== undefined ? this.loader.chipRevision === this.image.chipRevision
54
- : Number.isInteger(this.image.chipRevisionMin) && Number.isInteger(this.image.chipRevisionMax) && this.loader.chipRevision >= this.image.chipRevisionMin && this.loader.chipRevision <= this.image.chipRevisionMax
55
- if (this.loader.chipFamily !== this.image.chipFamily || !revisionMatches) {
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
- if (!Number.isSafeInteger(security.flags) || !Number.isSafeInteger(security.flashCryptCnt)) {
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 & 1) !== 0 || encrypted) throw new Error('Secure boot or encrypted devices require their signed flashing workflow')
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
- if (this.mode === 'install') {
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 < this.image.flashSize) throw new Error('Detected flash is unknown or smaller than the approved complete image layout')
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 = this.image.partitionTable
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
- await validateFirmwareImage(this.image, bytes, { mode: this.mode })
86
- onPhase(this.mode === 'install' ? 'Installing complete firmware; existing data in the written range will be replaced' : 'Writing application; bootloader, partition table, NVS and user files are preserved')
87
- await this.stub.flashData(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength),
88
- (written, total) => onProgress(total ? Math.min(80, Math.round(written / total * 80)) : 0), this.image.offset, true)
89
- onPhase(this.mode === 'install' ? 'Verifying the complete installation image' : 'Verifying the complete application image')
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(bytes.byteLength)
92
- for (let offset = 0; offset < bytes.byteLength; offset += 65536) {
93
- const count = Math.min(65536, bytes.byteLength - offset)
94
- const chunk = await this.stub.readFlash(this.image.offset + offset, count, undefined,
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) / bytes.byteLength))
155
+ onProgress(80 + Math.min(19, Math.floor(20 * (offset + count) / payload.byteLength)))
99
156
  }
100
- if (readback.byteLength !== bytes.byteLength || await firmwareSha256(readback) !== this.image.sha256) throw new Error('Written firmware verification failed; keep the device in download mode for recovery')
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 (this.loader.chipFamily === 0x3280) {
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 P4 system control register')
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('P4 normal boot latch verification failed')
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
+ }