@esp-vision/device-core 0.5.1 → 0.6.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 (46) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/NOTICE +8 -0
  3. package/README.md +24 -7
  4. package/package.json +4 -3
  5. package/schemas/device-actions-v1.json +1 -1
  6. package/scripts/release-npm.mjs +16 -0
  7. package/scripts/version-policy.mjs +71 -0
  8. package/src/browser.js +6 -2
  9. package/src/browser_rom.js +208 -4
  10. package/src/browser_rom_compat.js +86 -59
  11. package/src/browser_serial.js +130 -0
  12. package/src/device.js +5 -4
  13. package/src/device_bindings.js +5 -4
  14. package/src/device_capture.js +11 -0
  15. package/src/device_operations.js +3 -1
  16. package/src/device_session.js +263 -10
  17. package/src/evmux.js +43 -4
  18. package/src/firmware_flash.js +1 -1
  19. package/src/jpeg_validate.js +132 -0
  20. package/src/node_inventory.js +1 -1
  21. package/src/node_rom.js +76 -27
  22. package/src/node_rom.py +68 -2
  23. package/src/node_rom_port.js +211 -0
  24. package/src/node_serial.py +47 -1
  25. package/src/node_transport.js +76 -10
  26. package/src/rom_md5.js +30 -0
  27. package/tests/check-browser.mjs +76 -123
  28. package/tests/check-package.mjs +1 -1
  29. package/tests/device-checks.mjs +6 -2
  30. package/tests/fixtures/device-checks.py +11 -1
  31. package/tests/jpeg-fixtures.mjs +39 -0
  32. package/tests/test-browser-rom.mjs +204 -67
  33. package/tests/test-browser-serial.mjs +143 -0
  34. package/tests/test-device-api.mjs +40 -7
  35. package/tests/test-device-functional-checks.mjs +12 -2
  36. package/tests/test-device-operations-api.mjs +4 -3
  37. package/tests/test-device-reset.mjs +292 -0
  38. package/tests/test-firmware-flash.mjs +1 -1
  39. package/tests/test-jpeg-validation.mjs +233 -0
  40. package/tests/test-node-rom-port.mjs +305 -0
  41. package/tests/test-node-rom.mjs +160 -0
  42. package/tests/test-node-serial.mjs +206 -0
  43. package/tests/test-npm-release-policy.mjs +62 -0
  44. package/tests/test-observed-capture.mjs +131 -0
  45. package/tests/test-uart-baud.mjs +180 -0
  46. package/tests/test-version-policy.mjs +37 -0
package/src/node_rom.js CHANGED
@@ -4,14 +4,18 @@ import { spawn } from 'node:child_process'
4
4
  import { createInterface } from 'node:readline'
5
5
  import { fileURLToPath } from 'node:url'
6
6
  import { defaultPython } from './node_inventory.js'
7
+ import { prepareNodeRomPort, recoverNodeRomPort } from './node_rom_port.js'
7
8
 
8
- export function openNodeRom({ port, python = defaultPython, signal } = {}) {
9
+ export function openNodeRom({ port, python = defaultPython, signal } = {}, dependencies = {}) {
9
10
  if (!port) throw new TypeError('Firmware update requires a local --port')
10
- let child, input, pending, sequence = 0, closed = false
11
+ const lifecycle = new AbortController()
12
+ signal = signal ? AbortSignal.any([signal, lifecycle.signal]) : lifecycle.signal
13
+ let child, input, pending, sequence = 0, closed = false, initializing = false, romReady = false
11
14
  const fail = code => Object.assign(new Error(code === 'ESPTOOL_UNAVAILABLE' ? 'Install Python esptool>=5.3,<6 for the built-in flasher' : 'ROM operation failed'), { code })
12
15
  const reject = code => { if (pending) { clearTimeout(pending.timer); pending.reject(fail(code)); pending = null } }
13
16
  function rpc(method, args = []) {
14
17
  if (closed) return Promise.reject(fail('ROM_CLOSED'))
18
+ if (!child || (method !== 'initialize' && !romReady)) return Promise.reject(fail('ROM_NOT_INITIALIZED'))
15
19
  if (pending) return Promise.reject(fail('ROM_BUSY'))
16
20
  return new Promise((resolve, rejectPromise) => {
17
21
  const id = ++sequence
@@ -19,27 +23,77 @@ export function openNodeRom({ port, python = defaultPython, signal } = {}) {
19
23
  child.stdin.write(JSON.stringify({ id, method, args }) + '\n', error => { if (error) reject('ROM_IO_FAILED') })
20
24
  })
21
25
  }
26
+ async function closeProcess() {
27
+ const process = child
28
+ child = null; romReady = false; reject('ROM_CLOSED'); input?.close(); input = null
29
+ if (!process || process.exitCode !== null) return
30
+ await new Promise(resolve => {
31
+ const timer = setTimeout(() => { process.kill(); resolve() }, 2000)
32
+ process.once('exit', () => { clearTimeout(timer); resolve() })
33
+ process.stdin.end()
34
+ })
35
+ }
36
+ function startProcess(selected) {
37
+ signal.throwIfAborted()
38
+ const process = (dependencies.spawn ?? spawn)(python, ['-u', fileURLToPath(new URL('./node_rom.py', import.meta.url)), selected.port], { stdio: ['pipe', 'pipe', 'ignore'], windowsHide: true })
39
+ child = process
40
+ const active = action => { if (child === process) action() }
41
+ process.on('error', () => active(() => reject('ESPTOOL_UNAVAILABLE')))
42
+ process.on('exit', () => active(() => reject('ROM_CLOSED')))
43
+ process.stdin.on('error', () => active(() => reject('ROM_IO_FAILED')))
44
+ input = createInterface({ input: process.stdout })
45
+ input.on('line', line => {
46
+ if (child !== process) return
47
+ try {
48
+ const message = JSON.parse(line)
49
+ if (message.error && !message.id) return reject(message.error)
50
+ if (!pending || message.id !== pending.id) return
51
+ const operation = pending; pending = null; clearTimeout(operation.timer)
52
+ if (message.error) operation.reject(Object.assign(fail(message.error), { operation: message.method, cause: message.detail })); else operation.resolve(message.result)
53
+ } catch { reject('ROM_RESPONSE_INVALID') }
54
+ })
55
+ return rpc('initialize')
56
+ }
22
57
  const loader = {
23
58
  async initialize() {
24
- signal?.throwIfAborted()
25
- child = spawn(python, ['-u', fileURLToPath(new URL('./node_rom.py', import.meta.url)), port], { stdio: ['pipe', 'pipe', 'ignore'], windowsHide: true })
26
- child.on('error', () => reject('ESPTOOL_UNAVAILABLE'))
27
- child.on('exit', () => reject('ROM_CLOSED'))
28
- child.stdin.on('error', () => reject('ROM_IO_FAILED'))
29
- input = createInterface({ input: child.stdout })
30
- input.on('line', line => {
31
- try {
32
- const message = JSON.parse(line)
33
- if (message.error && !message.id) return reject(message.error)
34
- if (!pending || message.id !== pending.id) return
35
- const operation = pending; pending = null; clearTimeout(operation.timer)
36
- if (message.error) operation.reject(Object.assign(fail(message.error), { operation: message.method, cause: message.detail })); else operation.resolve(message.result)
37
- } catch { reject('ROM_RESPONSE_INVALID') }
38
- })
59
+ signal.throwIfAborted()
60
+ if (closed) throw fail('ROM_CLOSED')
61
+ if (initializing || child) throw fail('ROM_ALREADY_INITIALIZED')
62
+ initializing = true
63
+ let selected
39
64
  const cancelled = () => { reject('CANCELLED'); void loader.disconnect() }
40
- signal?.addEventListener('abort', cancelled, { once: true })
41
- try { Object.assign(loader, await rpc('initialize')) }
42
- finally { signal?.removeEventListener('abort', cancelled) }
65
+ signal.addEventListener('abort', cancelled, { once: true })
66
+ try {
67
+ selected = await (dependencies.preparePort ?? prepareNodeRomPort)({ port, python, signal })
68
+ signal.throwIfAborted()
69
+ let identity
70
+ try { identity = await startProcess(selected) }
71
+ catch (error) {
72
+ await closeProcess()
73
+ signal.throwIfAborted()
74
+ // No writes have been accepted. A ROM/stub can return to its app
75
+ // between enumeration and SYNC. Recover only after observing that
76
+ // same physical device's runtime; never repeat a previous handoff.
77
+ if (error.code !== 'ROM_OPERATION_FAILED' || error.operation !== 'initialize'
78
+ || !selected.expectedMac || selected.transition?.observed !== 'rom'
79
+ || selected.transition.requested !== false) throw error
80
+ const recovered = await (dependencies.recoverPort ?? recoverNodeRomPort)(selected, { python, signal })
81
+ if (!recovered) throw error
82
+ selected = recovered
83
+ selected.transition.previousInitialization = { code: error.code, operation: error.operation, detail: String(error.cause ?? '').slice(0, 1024) }
84
+ signal.throwIfAborted()
85
+ identity = await startProcess(selected)
86
+ }
87
+ if (selected.expectedMac && identity.chipMac !== selected.expectedMac) {
88
+ throw Object.assign(new Error('ROM MAC differs from the selected USB device'), { code: 'ROM_IDENTITY_CHANGED' })
89
+ }
90
+ Object.assign(loader, identity, { port: selected.port, usbTransition: selected.transition })
91
+ romReady = true
92
+ } catch (error) {
93
+ if (error && typeof error === 'object' && selected?.transition) error.transition ??= selected.transition
94
+ await loader.disconnect()
95
+ throw error
96
+ } finally { initializing = false; signal.removeEventListener('abort', cancelled) }
43
97
  },
44
98
  flashId: () => rpc('flashId'),
45
99
  getSecurityInfo: () => rpc('security'),
@@ -51,13 +105,8 @@ export function openNodeRom({ port, python = defaultPython, signal } = {}) {
51
105
  hardReset: () => rpc('reset'),
52
106
  async disconnect() {
53
107
  if (closed) return
54
- closed = true; reject('ROM_CLOSED'); input?.close()
55
- if (!child || child.exitCode !== null) return
56
- await new Promise(resolve => {
57
- const timer = setTimeout(() => { child.kill(); resolve() }, 2000)
58
- child.once('exit', () => { clearTimeout(timer); resolve() })
59
- child.stdin.end()
60
- })
108
+ closed = true; lifecycle.abort(fail('ROM_CLOSED'))
109
+ await closeProcess()
61
110
  },
62
111
  }
63
112
  return loader
package/src/node_rom.py CHANGED
@@ -4,9 +4,61 @@
4
4
  import base64
5
5
  import contextlib
6
6
  import json
7
+ import os
7
8
  import sys
8
9
 
9
10
 
11
+ def _port_key(port):
12
+ name = str(port)
13
+ if name.startswith('\\\\.\\'):
14
+ name = name[4:]
15
+ if name.upper().startswith('COM') and name[3:].isdigit():
16
+ return name.upper()
17
+ return os.path.realpath(name)
18
+
19
+
20
+ def _reset_supported(port):
21
+ # Descriptors permit a reset policy, never establish firmware/board identity.
22
+ # Unknown/native USB ports must not receive the UART DTR/RTS sequence.
23
+ try:
24
+ from serial.tools.list_ports import comports
25
+ matches = {(item.vid, item.pid) for item in comports()
26
+ if _port_key(item.device) == _port_key(port)}
27
+ except (ImportError, OSError, ValueError):
28
+ return False
29
+ if len(matches) != 1:
30
+ return False
31
+ vid, pid = next(iter(matches))
32
+ return vid in {0x10c4, 0x1a86, 0x0403, 0x067b} or (vid, pid) == (0x303a, 0x1001)
33
+
34
+
35
+ def _open_rom(esptool, port):
36
+ import serial
37
+ modes = ['no-reset', 'default-reset'] if _reset_supported(port) else ['no-reset']
38
+ first_error = None
39
+ for mode in modes:
40
+ if mode == 'default-reset' and not _reset_supported(port):
41
+ raise RuntimeError(f'Port no longer permits reset after synchronization failed: {first_error}') from first_error
42
+ connection = serial.serial_for_url(port, baudrate=115200, exclusive=True, do_not_open=True)
43
+ try:
44
+ # Set before open on every OS, as esptool does on Windows. Opening
45
+ # with asserted RTS can otherwise perturb an already-running stub.
46
+ connection.rts = False
47
+ connection.dtr = False
48
+ connection.open()
49
+ loader = esptool.detect_chip(port=connection, connect_mode=mode,
50
+ connect_attempts=1 if mode == 'no-reset' else 7)
51
+ return loader, mode
52
+ except Exception as error:
53
+ if connection.is_open:
54
+ connection.close()
55
+ if mode == modes[-1]:
56
+ if first_error is not None:
57
+ raise RuntimeError(f'No-reset synchronization failed: {first_error}; reset connection failed: {error}') from error
58
+ raise
59
+ first_error = error
60
+
61
+
10
62
  def main():
11
63
  with contextlib.redirect_stdout(sys.stderr):
12
64
  import esptool
@@ -22,9 +74,23 @@ def main():
22
74
  with contextlib.redirect_stdout(sys.stderr):
23
75
  method, args = request['method'], request.get('args', [])
24
76
  result = None
77
+ if method != 'initialize' and esp is None:
78
+ raise RuntimeError('Initialize the ROM adapter before issuing operations')
25
79
  if method == 'initialize':
26
- esp = esptool.detect_chip(port=sys.argv[1])
27
- result = {'chipFamily': families.get(esp.CHIP_NAME), 'chipRevision': esp.get_chip_revision()}
80
+ if esp is not None:
81
+ raise RuntimeError('ROM adapter is already initialized')
82
+ candidate, mode = _open_rom(esptool, sys.argv[1])
83
+ try:
84
+ mac = bytes(candidate.read_mac())
85
+ if len(mac) != 6:
86
+ raise RuntimeError('ROM did not return a six-byte factory MAC')
87
+ result = {'chipFamily': families.get(candidate.CHIP_NAME), 'chipRevision': candidate.get_chip_revision(),
88
+ 'connectionMode': mode, 'stubRunning': bool(candidate.IS_STUB), 'chipMac': mac.hex()}
89
+ except Exception:
90
+ if candidate._port.is_open:
91
+ candidate._port.close()
92
+ raise
93
+ esp = candidate
28
94
  elif method == 'security':
29
95
  security = esp.get_security_info()
30
96
  result = {'flags': security['flags'], 'flashCryptCnt': security['flash_crypt_cnt']}
@@ -0,0 +1,211 @@
1
+ // SPDX-FileCopyrightText: 2026 ESP-VISION-IDE
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import { realpath } from 'node:fs/promises'
4
+ import { setTimeout as delay } from 'node:timers/promises'
5
+ import { createDevice } from './index.js'
6
+ import { defaultPython, listSerial } from './node_inventory.js'
7
+ import { serialTransport } from './node_transport.js'
8
+
9
+ const ROM_PIDS = new Set([2, 9, 18, 32])
10
+ // Confirmed ESP-VISION runtime composites: CDC+MSC and CDC+MSC+UVC.
11
+ const RUNTIME_PIDS = new Set([0x4003, 0x4023])
12
+ const UNIQUE_ID_SOURCE = "import machine,ubinascii\nprint('__EV_ROM_ID__'+ubinascii.hexlify(machine.unique_id()).decode())"
13
+ function fail(code, message, cause) { return Object.assign(new Error(message), { code, ...(cause ? { cause } : {}) }) }
14
+ function checkAbort(signal) {
15
+ if (!signal?.aborted) return
16
+ throw signal.reason instanceof Error ? signal.reason : Object.assign(new Error('ROM port preparation cancelled'), { name: 'AbortError' })
17
+ }
18
+ function errorInfo(error) { return { code: error?.code ?? error?.name ?? 'ERROR', message: String(error?.message ?? error).slice(0, 512) } }
19
+ function mac(value) {
20
+ if (typeof value !== 'string') return null
21
+ const result = value.replace(/[:-]/g, '').toLowerCase()
22
+ return /^[0-9a-f]{12}$/.test(result) ? result : null
23
+ }
24
+ function comKey(value) {
25
+ return typeof value === 'string' && /^(?:\\\\\.\\)?COM[1-9]\d*$/i.test(value)
26
+ ? value.replace(/^\\\\\.\\/, '').toUpperCase() : null
27
+ }
28
+ function physicalLocation(value) {
29
+ // pyserial adds :configuration.interface (Windows uses :x.interface).
30
+ return typeof value === 'string' && value.trim() ? value.trim().replace(/:(?:\d+|x)\.\d+$/i, '') : null
31
+ }
32
+ function nativeRom(port) { return port.vendorId === 0x303a && ROM_PIDS.has(port.productId) }
33
+ function nativeRuntime(port) { return port.vendorId === 0x303a && RUNTIME_PIDS.has(port.productId) }
34
+
35
+ /** Resolve only the selected port. Descriptors authorize a handoff, never a flash. */
36
+ export async function prepareNodeRomPort({ port, python = defaultPython, signal, timeout = 5000, expectedIdentity } = {}, dependencies = {}) {
37
+ if (typeof port !== 'string' || !port || /[\x00-\x1f\x7f]/.test(port)) throw new TypeError('ROM preparation requires a local port')
38
+ if (!Number.isSafeInteger(timeout) || timeout <= 0 || timeout > 5000) throw new RangeError('ROM preparation timeout must be positive and at most 5000 ms')
39
+ const required = expectedIdentity == null ? null : { mac: mac(expectedIdentity.mac), location: physicalLocation(expectedIdentity.location) }
40
+ if (required && (!required.mac || !required.location)) throw new TypeError('Bound ROM preparation requires a MAC and physical USB location')
41
+ const enumerate = dependencies.listSerial ?? listSerial
42
+ const resolvePath = dependencies.realpath ?? realpath
43
+ const now = dependencies.now ?? (() => performance.now())
44
+ const sleep = dependencies.sleep ?? ((ms, abortSignal) => delay(ms, undefined, { signal: abortSignal }))
45
+ const makeDevice = dependencies.createDevice ?? createDevice
46
+ const makeTransport = dependencies.serialTransport ?? serialTransport
47
+ checkAbort(signal)
48
+ const settleDeadline = now() + timeout
49
+ let canonical = comKey(port)
50
+ while (!canonical) {
51
+ checkAbort(signal)
52
+ try {
53
+ canonical = await resolvePath(port)
54
+ if (typeof canonical !== 'string' || !canonical) throw fail('ROM_PORT_LOST', 'The selected path has no canonical serial address')
55
+ }
56
+ catch (error) {
57
+ checkAbort(signal)
58
+ if (error.code !== 'ENOENT' || now() >= settleDeadline) throw fail('ROM_PORT_LOST', 'The selected serial path is unavailable; select its current port', error)
59
+ await sleep(Math.min(50, settleDeadline - now()), signal)
60
+ }
61
+ }
62
+ checkAbort(signal)
63
+ let ports
64
+ try { ports = await enumerate({ python, signal, timeout }) }
65
+ catch (error) {
66
+ checkAbort(signal)
67
+ if (required) throw fail('ROM_PORT_IDENTITY_REQUIRED', 'Cannot confirm the bound runtime descriptors', error)
68
+ // A known OS path may still be used without descriptors, but never probed
69
+ // as an EV-MUX runtime or rebound to a different port.
70
+ return { port: canonical, expectedMac: null, transition: null }
71
+ }
72
+ checkAbort(signal)
73
+ if (!Array.isArray(ports)) throw fail('ROM_PORT_ENUMERATION_FAILED', 'Serial enumeration returned invalid descriptors')
74
+ const selected = ports.filter(item => item.path === port || item.path === canonical || (comKey(item.path) && comKey(item.path) === canonical))
75
+ if (selected.length > 1) throw fail('ROM_PORT_AMBIGUOUS', 'The selected port has ambiguous descriptors')
76
+ if (!selected.length) {
77
+ if (required) throw fail('ROM_PORT_LOST', 'The bound runtime port is no longer enumerated')
78
+ return { port: canonical, expectedMac: null, transition: null }
79
+ }
80
+ const current = selected[0]
81
+ const expectedMac = mac(current.serialNumber)
82
+ const location = physicalLocation(current.location)
83
+ if (required && (expectedMac !== required.mac || location !== required.location)) throw fail('ROM_RUNTIME_IDENTITY_MISMATCH', 'The selected runtime descriptors changed before recovery')
84
+ if (nativeRom(current)) return {
85
+ port: current.path, expectedMac,
86
+ transition: { requested: false, observed: 'rom', selectedPort: port, port: current.path, serial: expectedMac, location },
87
+ }
88
+ if (!nativeRuntime(current)) return { port: canonical, expectedMac: null, transition: null }
89
+ if (!expectedMac || !location) throw fail('ROM_PORT_IDENTITY_REQUIRED', 'Native USB runtime requires a MAC serial and physical USB location')
90
+
91
+ const transition = { requested: false, selectedPort: port, runtimePort: current.path, expectedMac, location,
92
+ command: { attempted: false, acknowledged: false, outcome: 'not_attempted' }, cleanup: null, observed: null }
93
+ let device, primary
94
+ try {
95
+ device = makeDevice({ transport: makeTransport({ serial: current.path, role: 'usb', python }) })
96
+ const connected = await device.connect({ role: 'usb', probeRepl: false, autoBaud: false, timeout, signal })
97
+ checkAbort(signal)
98
+ const owners = connected?.capabilities?.sinks?.filter(sink => sink.owner === true) ?? []
99
+ if (owners.length !== 1 || owners[0].id !== 'usb' || owners[0].kind !== 'usb_otg_cdc' || owners[0].state !== 'active' || owners[0].ready !== true) {
100
+ throw fail('ROM_RUNTIME_INTERFACE_MISMATCH', 'The selected runtime did not establish the active USB owner')
101
+ }
102
+ let runtimeMac = connected?.identity?.deviceId
103
+ let identitySource = 'runtime.deviceId'
104
+ if (runtimeMac == null) {
105
+ const result = await device.python.execute({ source: UNIQUE_ID_SOURCE, completion: 'wait', timeout, maxOutputChars: 512, signal })
106
+ checkAbort(signal)
107
+ const matches = typeof result?.output === 'string' ? [...result.output.matchAll(/^__EV_ROM_ID__([0-9a-f]{12})\r?$/gim)] : []
108
+ if (result?.ok !== true || result.outputTruncated !== false || matches.length !== 1 || /Traceback/.test(result.output)) {
109
+ throw fail('ROM_RUNTIME_IDENTITY_UNAVAILABLE', 'The legacy runtime did not return one complete unique_id')
110
+ }
111
+ runtimeMac = matches[0][1]
112
+ identitySource = 'machine.unique_id'
113
+ }
114
+ if (mac(runtimeMac) !== expectedMac) throw fail('ROM_RUNTIME_IDENTITY_MISMATCH', 'Runtime identity does not match the selected USB serial')
115
+ transition.identitySource = identitySource
116
+ checkAbort(signal)
117
+ transition.requested = true
118
+ transition.command = { attempted: true, acknowledged: false, outcome: 'unknown' }
119
+ try {
120
+ const result = await device.control({ action: 'bootloader', timeout, signal })
121
+ checkAbort(signal)
122
+ if (result?.ok !== true || result.operation !== 'bootloader') throw fail('ROM_BOOTLOADER_REJECTED', 'Runtime did not accept the bootloader command')
123
+ transition.command = { attempted: true, acknowledged: result.acknowledged === true, outcome: result.outcome ?? 'unknown' }
124
+ } catch (error) {
125
+ checkAbort(signal)
126
+ if (error.reset?.outcome !== 'unknown' && error.outcome !== 'unknown') throw error
127
+ transition.command = { attempted: true, acknowledged: false, outcome: 'unknown', error: errorInfo(error) }
128
+ }
129
+ } catch (error) { primary = error }
130
+ finally {
131
+ if (device) {
132
+ try { await device.disconnect(); transition.cleanup = { ok: true } }
133
+ catch (error) { transition.cleanup = { ok: false, error: errorInfo(error) } }
134
+ }
135
+ }
136
+ try { checkAbort(signal) } catch (error) { primary ??= error }
137
+ if (primary) {
138
+ primary.transition = transition
139
+ if (transition.cleanup?.ok === false) primary.cleanupError = transition.cleanup.error
140
+ throw primary
141
+ }
142
+
143
+ // No reset replay: only the selected physical device's ROM may finish the
144
+ // handoff. A successful match does not turn an unknown command into an ACK.
145
+ const deadline = now() + timeout
146
+ while (now() < deadline) {
147
+ checkAbort(signal)
148
+ const remaining = Math.max(1, Math.ceil(deadline - now()))
149
+ let candidates
150
+ try { candidates = await enumerate({ python, signal, timeout: remaining }) }
151
+ catch (error) {
152
+ checkAbort(signal)
153
+ throw Object.assign(fail('ROM_PORT_REBIND_FAILED', 'Cannot enumerate the selected device after bootloader submission', error), { transition })
154
+ }
155
+ checkAbort(signal)
156
+ if (now() >= deadline) break
157
+ if (!Array.isArray(candidates)) throw Object.assign(fail('ROM_PORT_ENUMERATION_FAILED', 'Serial enumeration returned invalid descriptors'), { transition })
158
+ const matches = candidates.filter(item => nativeRom(item) && mac(item.serialNumber) === expectedMac && physicalLocation(item.location) === location)
159
+ if (matches.length > 1) throw Object.assign(fail('ROM_PORT_AMBIGUOUS', 'Multiple ROM ports match the selected device'), { transition })
160
+ if (matches.length === 1) {
161
+ const selectedRom = matches[0]
162
+ transition.observed = 'rom'
163
+ transition.port = selectedRom.path
164
+ return { port: selectedRom.path, expectedMac, transition }
165
+ }
166
+ await sleep(Math.min(50, Math.max(1, deadline - now())), signal)
167
+ }
168
+ throw Object.assign(fail('ROM_PORT_REBIND_TIMEOUT', 'The selected device did not expose one matching ROM port within the deadline'), { transition })
169
+ }
170
+
171
+
172
+ /** Only for the caller's first read-only initialize failure, never read/write. */
173
+ export async function recoverNodeRomPort(previous, { python = defaultPython, signal, timeout = 5000 } = {}, dependencies = {}) {
174
+ checkAbort(signal)
175
+ if (!Number.isSafeInteger(timeout) || timeout <= 0 || timeout > 5000) throw new RangeError('ROM recovery timeout must be positive and at most 5000 ms')
176
+ const expectedMac = mac(previous?.expectedMac)
177
+ const location = physicalLocation(previous?.transition?.location)
178
+ if (!expectedMac || !location || previous?.transition?.observed !== 'rom' || previous.transition.requested !== false) return null
179
+ if (mac(previous.transition.serial) !== expectedMac) return null
180
+ const enumerate = dependencies.listSerial ?? listSerial
181
+ const now = dependencies.now ?? (() => performance.now())
182
+ const deadline = now() + timeout
183
+ let ports
184
+ try { ports = await enumerate({ python, signal, timeout }) }
185
+ catch (error) {
186
+ checkAbort(signal)
187
+ throw fail('ROM_PORT_ENUMERATION_FAILED', 'Cannot verify the selected device for read-only initialization recovery', error)
188
+ }
189
+ checkAbort(signal)
190
+ if (now() >= deadline) throw fail('ROM_PORT_REBIND_TIMEOUT', 'Read-only initialization recovery enumeration exceeded its deadline')
191
+ if (!Array.isArray(ports)) throw fail('ROM_PORT_ENUMERATION_FAILED', 'Serial enumeration returned invalid descriptors')
192
+ const sameDevice = item => mac(item.serialNumber) === expectedMac && physicalLocation(item.location) === location
193
+ const existingRom = ports.filter(item => nativeRom(item) && sameDevice(item))
194
+ if (existingRom.length > 1) throw fail('ROM_PORT_AMBIGUOUS', 'Multiple ROM ports match the selected device')
195
+ if (existingRom.length) return null
196
+ const runtimes = ports.filter(nativeRuntime)
197
+ const conflicting = runtimes.filter(item => (mac(item.serialNumber) === expectedMac || physicalLocation(item.location) === location) && !sameDevice(item))
198
+ if (conflicting.length) throw fail('ROM_RUNTIME_IDENTITY_MISMATCH', 'Runtime descriptors do not match the previously selected ROM device')
199
+ const matches = runtimes.filter(sameDevice)
200
+ if (matches.length > 1) throw fail('ROM_PORT_AMBIGUOUS', 'Multiple runtime ports match the selected device')
201
+ if (!matches.length) return null
202
+ // Revalidate at prepare's fresh enumeration, before opening EV-MUX. An OS port
203
+ // may have been reused for a different board since the recovery snapshot.
204
+ const result = await prepareNodeRomPort({ port: matches[0].path, python, signal, timeout,
205
+ expectedIdentity: { mac: expectedMac, location } }, dependencies)
206
+ if (result.expectedMac !== expectedMac || result.transition?.observed !== 'rom' || physicalLocation(result.transition.location) !== location) {
207
+ throw fail('ROM_RUNTIME_IDENTITY_MISMATCH', 'Recovery did not preserve the selected physical device identity')
208
+ }
209
+ result.transition.recovery = { previousPort: previous.port, runtimePid: matches[0].productId }
210
+ return result
211
+ }
@@ -4,6 +4,7 @@
4
4
  import json
5
5
  import os
6
6
  import signal
7
+ import struct
7
8
  import sys
8
9
  import threading
9
10
 
@@ -17,6 +18,18 @@ def _configure_serial_exclusive(port, platform=os.name):
17
18
  port.exclusive = True
18
19
 
19
20
 
21
+ def _read_exact(stream, size):
22
+ data = bytearray()
23
+ while len(data) < size:
24
+ chunk = stream.read(size - len(data))
25
+ if not chunk:
26
+ if data:
27
+ raise OSError('Truncated serial bridge record')
28
+ return None
29
+ data.extend(chunk)
30
+ return bytes(data)
31
+
32
+
20
33
  def main():
21
34
  if sys.argv[1] == '--list':
22
35
  from serial.tools.list_ports import comports
@@ -55,7 +68,40 @@ def main():
55
68
  thread = threading.Thread(target=receive, daemon=True)
56
69
  thread.start()
57
70
  print('READY', file=sys.stderr, flush=True)
58
- while data := sys.stdin.buffer.read1(8192):
71
+ while header := _read_exact(sys.stdin.buffer, 5):
72
+ kind, size = struct.unpack('>BI', header)
73
+ if kind not in (0, 1, 2) or size > 1048576:
74
+ raise OSError('Invalid serial bridge record')
75
+ data = _read_exact(sys.stdin.buffer, size)
76
+ if data is None:
77
+ raise OSError('Truncated serial bridge record')
78
+ if kind == 1:
79
+ request = json.loads(data)
80
+ try:
81
+ baud = request['baudRate']
82
+ if not isinstance(baud, int) or isinstance(baud, bool) or baud <= 0:
83
+ raise ValueError('Invalid baudrate')
84
+ port.flush()
85
+ port.baudrate = baud
86
+ response = {'id': request['id'], 'ok': True, 'baudRate': baud}
87
+ except (KeyError, ValueError, OSError, serial.SerialException):
88
+ response = {'id': request.get('id'), 'ok': False}
89
+ print(json.dumps(response), file=sys.stderr, flush=True)
90
+ continue
91
+ if kind == 2:
92
+ request = json.loads(data)
93
+ try:
94
+ request_id = request['id']
95
+ if not isinstance(request_id, int) or isinstance(request_id, bool) or request_id <= 0:
96
+ raise ValueError('Invalid drain request ID')
97
+ # stdin records are processed in order. All preceding byte
98
+ # records have completed port.write before this drain ACK.
99
+ port.flush()
100
+ response = {'id': request_id, 'ok': True, 'drained': True}
101
+ except (KeyError, ValueError, OSError, serial.SerialException):
102
+ response = {'id': request.get('id'), 'ok': False}
103
+ print(json.dumps(response), file=sys.stderr, flush=True)
104
+ continue
59
105
  offset = 0
60
106
  while offset < len(data):
61
107
  written = port.write(data[offset:])
@@ -39,14 +39,27 @@ export function tcpTransport(options) {
39
39
 
40
40
  export function serialTransport(options) {
41
41
  const events = subscribers()
42
- let child
42
+ let child, controlId = 0, baudRate = 115200
43
+ const pending = new Map()
44
+ const rejectControls = error => {
45
+ for (const request of pending.values()) request.reject(error)
46
+ pending.clear()
47
+ }
48
+ const send = (kind, bytes) => new Promise((resolveWrite, reject) => {
49
+ if (!child?.stdin.writable) return reject(fail('SERIAL_CLOSED', 'Serial adapter closed'))
50
+ const record = Buffer.allocUnsafe(5 + bytes.byteLength)
51
+ record.writeUInt8(kind, 0)
52
+ record.writeUInt32BE(bytes.byteLength, 1)
53
+ record.set(bytes, 5)
54
+ child.stdin.write(record, error => error ? reject(fail('SERIAL_WRITE', 'Serial write failed')) : resolveWrite())
55
+ })
43
56
  return {
44
57
  subscribe: events.subscribe,
45
58
  connect({ signal }) {
46
59
  return new Promise((resolveConnect, reject) => {
47
60
  const adapter = fileURLToPath(new URL('./node_serial.py', import.meta.url))
48
61
  child = spawn(options.python, ['-u', adapter, options.serial], { stdio: ['pipe', 'pipe', 'pipe'] })
49
- let ready = false, settled = false, runtimeErrorReported = false, stderr = ''
62
+ let ready = false, settled = false, runtimeErrorReported = false, stderr = '', lines = ''
50
63
  const abort = () => {
51
64
  child.kill('SIGTERM')
52
65
  if (!settled) {
@@ -76,25 +89,78 @@ export function serialTransport(options) {
76
89
  child.stdin.on('error', () => { if (ready) reportRuntimeError() })
77
90
  child.stdout.on('data', bytes => events.emit('data', bytes))
78
91
  child.stderr.on('data', bytes => {
79
- if (ready) return
80
92
  // Do not print Python errors: they can contain host paths or device data.
81
93
  stderr = (stderr + bytes.toString()).slice(-128)
82
- if (stderr.split(/\r?\n/).includes('READY')) {
83
- ready = true
84
- settled = true
85
- cleanup()
86
- resolveConnect()
94
+ lines = (lines + bytes.toString()).slice(-8192)
95
+ for (let newline; (newline = lines.indexOf('\n')) !== -1;) {
96
+ const line = lines.slice(0, newline).trim()
97
+ lines = lines.slice(newline + 1)
98
+ if (line === 'READY' && !ready) {
99
+ ready = true
100
+ settled = true
101
+ baudRate = 115200
102
+ cleanup()
103
+ resolveConnect()
104
+ continue
105
+ }
106
+ let response
107
+ try { response = JSON.parse(line) } catch { continue }
108
+ const request = pending.get(response.id)
109
+ if (!request) continue
110
+ pending.delete(response.id)
111
+ if (request.kind === 'drain') {
112
+ if (response.ok === true && response.drained === true) request.resolve()
113
+ else request.reject(fail('SERIAL_DRAIN_FAILED', 'Serial adapter could not drain pending output'))
114
+ } else if (response.ok && response.baudRate === request.baudRate) {
115
+ baudRate = response.baudRate
116
+ request.resolve()
117
+ } else request.reject(fail('SERIAL_BAUD_UNSUPPORTED', 'Serial adapter rejected the requested baudrate'))
87
118
  }
88
119
  })
89
120
  // Wait for close rather than exit: stdout/stderr may still have buffered
90
121
  // data after exit, including the adapter's stable SERIAL_BUSY marker.
91
- child.on('close', () => { cleanup(); if (!ready) failBeforeReady(); events.emit('close') })
122
+ child.on('close', () => { cleanup(); rejectControls(fail('SERIAL_CLOSED', 'Serial adapter closed')); if (!ready) failBeforeReady(); events.emit('close') })
92
123
  signal.addEventListener('abort', abort, { once: true })
93
124
  if (signal.aborted) abort()
94
125
  })
95
126
  },
96
- writeBytes(bytes) { return new Promise((resolveWrite, reject) => child?.stdin.writable ? child.stdin.write(bytes, error => error ? reject(fail('SERIAL_WRITE', 'Serial write failed')) : resolveWrite()) : reject(fail('SERIAL_CLOSED', 'Serial adapter closed'))) },
127
+ writeBytes(bytes) { return send(0, bytes) },
128
+ drain({ signal } = {}) {
129
+ if (signal?.aborted) return Promise.reject(signal.reason ?? fail('CANCELLED', 'Serial drain cancelled'))
130
+ const id = ++controlId
131
+ return new Promise((resolve, reject) => {
132
+ const cleanup = () => { clearTimeout(timer); signal?.removeEventListener('abort', abort); pending.delete(id) }
133
+ const abort = () => { cleanup(); reject(signal.reason ?? fail('CANCELLED', 'Serial drain cancelled')) }
134
+ const timer = setTimeout(() => { cleanup(); reject(fail('SERIAL_DRAIN_TIMEOUT', 'Serial drain timed out')) }, 2000)
135
+ pending.set(id, {
136
+ kind: 'drain',
137
+ resolve() { cleanup(); resolve() },
138
+ reject(error) { cleanup(); reject(error) },
139
+ })
140
+ signal?.addEventListener('abort', abort, { once: true })
141
+ send(2, Buffer.from(JSON.stringify({ id }))).catch(error => pending.get(id)?.reject(error))
142
+ })
143
+ },
144
+ setBaudRate(speed, { signal } = {}) {
145
+ if (!Number.isSafeInteger(speed) || speed <= 0) return Promise.reject(new RangeError('baudRate must be a positive integer'))
146
+ if (signal?.aborted) return Promise.reject(signal.reason ?? fail('CANCELLED', 'Serial speed change cancelled'))
147
+ const id = ++controlId
148
+ return new Promise((resolve, reject) => {
149
+ const cleanup = () => { clearTimeout(timer); signal?.removeEventListener('abort', abort); pending.delete(id) }
150
+ const abort = () => { cleanup(); reject(signal.reason ?? fail('CANCELLED', 'Serial speed change cancelled')) }
151
+ const timer = setTimeout(() => { cleanup(); reject(fail('SERIAL_BAUD_TIMEOUT', 'Serial speed change timed out')) }, 2000)
152
+ pending.set(id, {
153
+ baudRate: speed,
154
+ resolve() { cleanup(); resolve() },
155
+ reject(error) { cleanup(); reject(error) },
156
+ })
157
+ signal?.addEventListener('abort', abort, { once: true })
158
+ send(1, Buffer.from(JSON.stringify({ id, baudRate: speed }))).catch(error => pending.get(id)?.reject(error))
159
+ })
160
+ },
161
+ getDiagnostics() { return { baudRate } },
97
162
  async disconnect() {
163
+ rejectControls(fail('SERIAL_CLOSED', 'Serial adapter closed'))
98
164
  if (!child?.pid || child.exitCode !== null || child.signalCode !== null) return
99
165
  await new Promise(resolveExit => {
100
166
  const timer = setTimeout(() => { child.kill('SIGKILL') }, 2000)
package/src/rom_md5.js ADDED
@@ -0,0 +1,30 @@
1
+ // SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ // MD5 verifies the flasher stub's transport digest; approved firmware still uses
4
+ // full SHA-256. Web Crypto intentionally does not offer the legacy MD5 algorithm.
5
+ export function romMd5(bytes) {
6
+ const input = new Uint8Array(Math.ceil((bytes.length + 9) / 64) * 64)
7
+ input.set(bytes); input[bytes.length] = 0x80
8
+ const view = new DataView(input.buffer)
9
+ view.setUint32(input.length - 8, (bytes.length * 8) >>> 0, true)
10
+ view.setUint32(input.length - 4, Math.floor(bytes.length / 0x20000000), true)
11
+ const rotations = [[7, 12, 17, 22], [5, 9, 14, 20], [4, 11, 16, 23], [6, 10, 15, 21]]
12
+ const state = [0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476]
13
+ for (let offset = 0; offset < input.length; offset += 64) {
14
+ let [a, b, c, d] = state
15
+ for (let i = 0; i < 64; i++) {
16
+ const round = i >>> 4
17
+ const f = round === 0 ? (b & c) | (~b & d) : round === 1 ? (d & b) | (~d & c) : round === 2 ? b ^ c ^ d : c ^ (b | ~d)
18
+ const index = round === 0 ? i : round === 1 ? (5 * i + 1) % 16 : round === 2 ? (3 * i + 5) % 16 : (7 * i) % 16
19
+ const value = (a + f + Math.floor(Math.abs(Math.sin(i + 1)) * 0x100000000) + view.getUint32(offset + index * 4, true)) | 0
20
+ const shift = rotations[round][i % 4]
21
+ const next = (b + ((value << shift) | (value >>> (32 - shift)))) | 0
22
+ a = d; d = c; c = b; b = next
23
+ }
24
+ state[0] = (state[0] + a) >>> 0; state[1] = (state[1] + b) >>> 0
25
+ state[2] = (state[2] + c) >>> 0; state[3] = (state[3] + d) >>> 0
26
+ }
27
+ const output = new Uint8Array(16), result = new DataView(output.buffer)
28
+ state.forEach((value, index) => result.setUint32(index * 4, value, true))
29
+ return output
30
+ }