@esp-vision/device-core 0.5.1 → 0.6.1

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 (49) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/NOTICE +8 -0
  3. package/README.md +25 -8
  4. package/package.json +4 -3
  5. package/schemas/device-actions-v1.json +1 -1
  6. package/scripts/generate-action-schema.mjs +2 -1
  7. package/scripts/npm-command.mjs +17 -0
  8. package/scripts/release-npm.mjs +18 -1
  9. package/scripts/version-policy.mjs +71 -0
  10. package/src/browser.js +6 -2
  11. package/src/browser_rom.js +208 -4
  12. package/src/browser_rom_compat.js +86 -59
  13. package/src/browser_serial.js +130 -0
  14. package/src/device.js +5 -4
  15. package/src/device_bindings.js +5 -4
  16. package/src/device_capture.js +11 -0
  17. package/src/device_operations.js +3 -1
  18. package/src/device_session.js +263 -10
  19. package/src/evmux.js +43 -4
  20. package/src/firmware_flash.js +1 -1
  21. package/src/jpeg_validate.js +132 -0
  22. package/src/node_inventory.js +1 -1
  23. package/src/node_rom.js +92 -29
  24. package/src/node_rom.py +134 -6
  25. package/src/node_rom_port.js +219 -0
  26. package/src/node_serial.py +122 -13
  27. package/src/node_transport.js +136 -21
  28. package/src/rom_md5.js +30 -0
  29. package/tests/check-browser.mjs +76 -123
  30. package/tests/check-package.mjs +8 -7
  31. package/tests/device-checks.mjs +6 -2
  32. package/tests/fixtures/device-checks.py +11 -1
  33. package/tests/jpeg-fixtures.mjs +39 -0
  34. package/tests/test-browser-rom.mjs +204 -67
  35. package/tests/test-browser-serial.mjs +143 -0
  36. package/tests/test-device-api.mjs +40 -7
  37. package/tests/test-device-functional-checks.mjs +12 -2
  38. package/tests/test-device-operations-api.mjs +4 -3
  39. package/tests/test-device-reset.mjs +292 -0
  40. package/tests/test-firmware-flash.mjs +1 -1
  41. package/tests/test-host-checks.mjs +47 -0
  42. package/tests/test-jpeg-validation.mjs +233 -0
  43. package/tests/test-node-rom-port.mjs +385 -0
  44. package/tests/test-node-rom.mjs +231 -0
  45. package/tests/test-node-serial.mjs +436 -1
  46. package/tests/test-npm-release-policy.mjs +56 -0
  47. package/tests/test-observed-capture.mjs +131 -0
  48. package/tests/test-uart-baud.mjs +180 -0
  49. 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 fail = code => Object.assign(new Error(code === 'ESPTOOL_UNAVAILABLE' ? 'Install Python esptool>=5.3,<6 for the built-in flasher' : 'ROM operation failed'), { code })
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
14
+ const fail = code => Object.assign(new Error(code === 'ESPTOOL_UNAVAILABLE' ? 'Install Python esptool==5.6.0 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,45 +23,104 @@ 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'),
46
100
  async runStub() { await rpc('stub'); return loader },
47
- async readFlash(offset, bytes) { return new Uint8Array(Buffer.from(await rpc('read', [offset, bytes]), 'base64')) },
101
+ async readFlash(offset, bytes, progress, options) {
102
+ if (!Number.isSafeInteger(offset) || offset < 0 || !Number.isSafeInteger(bytes) || bytes <= 0 || bytes > 0xffffffff
103
+ || offset + bytes > 0x100000000) throw new RangeError('ROM read requires a positive 32-bit address range')
104
+ if (progress !== undefined && typeof progress !== 'function') throw new TypeError('ROM read progress must be a function')
105
+ if (options !== undefined && (!options || typeof options !== 'object' || Array.isArray(options))) throw new TypeError('ROM read options must be an object')
106
+ if (options && Object.keys(options).some(key => !['blockSize', 'maxInFlight', 'chunkSize'].includes(key))) throw new TypeError('Unsupported ROM read option')
107
+ const blockSize = options?.blockSize === undefined ? 1024 : options.blockSize
108
+ const maxInFlight = options?.maxInFlight === undefined ? 1 : options.maxInFlight
109
+ if (![1024, 4096].includes(blockSize) || !Number.isSafeInteger(maxInFlight) || maxInFlight < 1 || maxInFlight > 64) throw new RangeError('ROM read requires 1024 or 4096 byte blocks and 1..64 outstanding packets')
110
+ if (options?.chunkSize !== undefined && (!Number.isSafeInteger(options.chunkSize) || options.chunkSize <= 0 || bytes > options.chunkSize)) throw new RangeError('ROM read exceeds the requested chunk size')
111
+ const data = new Uint8Array(Buffer.from(await rpc('read', [offset, bytes, { blockSize, maxInFlight }]), 'base64'))
112
+ if (data.length !== bytes) throw Object.assign(fail('ROM_READ_LENGTH'), { operation: 'read' })
113
+ progress?.(data, data.length, bytes)
114
+ return data
115
+ },
48
116
  async flashData(bytes, progress, offset) { await rpc('write', [offset, Buffer.from(bytes).toString('base64')]); progress(bytes.byteLength, bytes.byteLength) },
49
117
  readRegister: address => rpc('readRegister', [address]),
50
118
  writeRegister: (address, value, mask) => rpc('writeRegister', [address, value, mask]),
51
119
  hardReset: () => rpc('reset'),
52
120
  async disconnect() {
53
121
  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
- })
122
+ closed = true; lifecycle.abort(fail('ROM_CLOSED'))
123
+ await closeProcess()
61
124
  },
62
125
  }
63
126
  return loader
package/src/node_rom.py CHANGED
@@ -4,15 +4,129 @@
4
4
  import base64
5
5
  import contextlib
6
6
  import json
7
+ import os
8
+ import struct
7
9
  import sys
8
10
 
9
11
 
12
+ def _port_key(port):
13
+ name = str(port)
14
+ if name.startswith('\\\\.\\'):
15
+ name = name[4:]
16
+ if name.upper().startswith('COM') and name[3:].isdigit():
17
+ return name.upper()
18
+ return os.path.realpath(name)
19
+
20
+
21
+ def _reset_supported(port):
22
+ # Descriptors permit a reset policy, never establish firmware/board identity.
23
+ # Unknown/native USB ports must not receive the UART DTR/RTS sequence.
24
+ try:
25
+ from serial.tools.list_ports import comports
26
+ matches = {(item.vid, item.pid) for item in comports()
27
+ if _port_key(item.device) == _port_key(port)}
28
+ except (ImportError, OSError, ValueError):
29
+ return False
30
+ if len(matches) != 1:
31
+ return False
32
+ vid, pid = next(iter(matches))
33
+ return vid in {0x10c4, 0x1a86, 0x0403, 0x067b} or (vid, pid) == (0x303a, 0x1001)
34
+
35
+
36
+ def _open_rom(esptool, port):
37
+ import serial
38
+ modes = ['no-reset', 'default-reset'] if _reset_supported(port) else ['no-reset']
39
+ first_error = None
40
+ for mode in modes:
41
+ if mode == 'default-reset' and not _reset_supported(port):
42
+ raise RuntimeError(f'Port no longer permits reset after synchronization failed: {first_error}') from first_error
43
+ connection = serial.serial_for_url(port, baudrate=115200, exclusive=True, do_not_open=True)
44
+ try:
45
+ # Set before open on every OS, as esptool does on Windows. Opening
46
+ # with asserted RTS can otherwise perturb an already-running stub.
47
+ connection.rts = False
48
+ connection.dtr = False
49
+ connection.open()
50
+ loader = esptool.detect_chip(port=connection, connect_mode=mode,
51
+ connect_attempts=1 if mode == 'no-reset' else 7)
52
+ return loader, mode
53
+ except Exception as error:
54
+ if connection.is_open:
55
+ connection.close()
56
+ if mode == modes[-1]:
57
+ if first_error is not None:
58
+ raise RuntimeError(f'No-reset synchronization failed: {first_error}; reset connection failed: {error}') from error
59
+ raise
60
+ first_error = error
61
+
62
+
63
+
64
+ def _read_flash(esp, offset, length, options=None):
65
+ """Bound the upstream stub reader without replacing its ACK/MD5 parser."""
66
+ if type(offset) is not int or offset < 0 or type(length) is not int or length <= 0 or length > 0xffffffff or offset + length > 0x100000000:
67
+ raise ValueError('ROM read requires a positive 32-bit address range')
68
+ if options is None:
69
+ options = {}
70
+ if not isinstance(options, dict) or set(options) - {'blockSize', 'maxInFlight'}:
71
+ raise ValueError('Unsupported ROM read options')
72
+ block_size, max_in_flight = options.get('blockSize', 1024), options.get('maxInFlight', 1)
73
+ if type(block_size) is not int or block_size not in (1024, 4096) or type(max_in_flight) is not int or not 1 <= max_in_flight <= 64:
74
+ raise ValueError('ROM read requires 1024 or 4096 byte blocks and 1..64 outstanding packets')
75
+ if not getattr(esp, 'IS_STUB', False):
76
+ raise RuntimeError('Bounded Flash read requires an initialized flasher stub')
77
+ if getattr(esp, 'FLASH_SECTOR_SIZE', None) != 4096 or not callable(getattr(esp, 'check_command', None)):
78
+ raise RuntimeError('Unsupported esptool stub read layout; use esptool==5.6.0')
79
+ command = esp.ESP_CMDS['READ_FLASH']
80
+ original_check = esp.check_command
81
+ missing = object()
82
+ old_check = vars(esp).get('check_command', missing)
83
+ old_block = vars(esp).get('FLASH_SECTOR_SIZE', missing)
84
+ commands = 0
85
+ block_changed = check_changed = False
86
+
87
+ def bounded_check(op_description, op=None, data=b'', *args, **kwargs):
88
+ nonlocal commands
89
+ if op == command:
90
+ commands += 1
91
+ if commands != 1 or not isinstance(data, bytes) or len(data) != 16:
92
+ raise RuntimeError('Unsupported esptool READ_FLASH command layout')
93
+ wire_offset, wire_length, wire_block, wire_window = struct.unpack('<IIII', data)
94
+ if (wire_offset, wire_length, wire_block, wire_window) != (offset, length, block_size, 64):
95
+ raise RuntimeError('Unexpected esptool READ_FLASH range or packet layout')
96
+ data = struct.pack('<IIII', offset, length, block_size, max_in_flight)
97
+ return original_check(op_description, op, data, *args, **kwargs)
98
+
99
+ try:
100
+ # Upstream 5.6.0 uses this instance field for both its request and its
101
+ # short-packet validation. Changing only the wire request is invalid.
102
+ esp.FLASH_SECTOR_SIZE = block_size
103
+ block_changed = True
104
+ esp.check_command = bounded_check
105
+ check_changed = True
106
+ result = esp.read_flash(offset, length)
107
+ if commands != 1 or not isinstance(result, bytes) or len(result) != length:
108
+ raise RuntimeError('Upstream Flash read did not complete the bounded request')
109
+ return result
110
+ finally:
111
+ try:
112
+ if check_changed:
113
+ if old_check is missing:
114
+ del esp.check_command
115
+ else:
116
+ esp.check_command = old_check
117
+ finally:
118
+ if block_changed:
119
+ if old_block is missing:
120
+ del esp.FLASH_SECTOR_SIZE
121
+ else:
122
+ esp.FLASH_SECTOR_SIZE = old_block
123
+
124
+
10
125
  def main():
11
126
  with contextlib.redirect_stdout(sys.stderr):
12
127
  import esptool
13
- major, minor = map(int, esptool.__version__.split('.')[:2])
14
- if major != 5 or minor < 3:
15
- raise RuntimeError('Install esptool>=5.3,<6')
128
+ if esptool.__version__ != '5.6.0':
129
+ raise RuntimeError('Install esptool==5.6.0 for the verified bounded Flash reader')
16
130
  esp = None
17
131
  families = {'ESP32-P4': 0x3280, 'ESP32-S3': 0x3253, 'ESP32-S31': 0x3231}
18
132
  try:
@@ -22,9 +136,23 @@ def main():
22
136
  with contextlib.redirect_stdout(sys.stderr):
23
137
  method, args = request['method'], request.get('args', [])
24
138
  result = None
139
+ if method != 'initialize' and esp is None:
140
+ raise RuntimeError('Initialize the ROM adapter before issuing operations')
25
141
  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()}
142
+ if esp is not None:
143
+ raise RuntimeError('ROM adapter is already initialized')
144
+ candidate, mode = _open_rom(esptool, sys.argv[1])
145
+ try:
146
+ mac = bytes(candidate.read_mac())
147
+ if len(mac) != 6:
148
+ raise RuntimeError('ROM did not return a six-byte factory MAC')
149
+ result = {'chipFamily': families.get(candidate.CHIP_NAME), 'chipRevision': candidate.get_chip_revision(),
150
+ 'connectionMode': mode, 'stubRunning': bool(candidate.IS_STUB), 'chipMac': mac.hex()}
151
+ except Exception:
152
+ if candidate._port.is_open:
153
+ candidate._port.close()
154
+ raise
155
+ esp = candidate
28
156
  elif method == 'security':
29
157
  security = esp.get_security_info()
30
158
  result = {'flags': security['flags'], 'flashCryptCnt': security['flash_crypt_cnt']}
@@ -35,7 +163,7 @@ def main():
35
163
  elif method == 'flashId':
36
164
  result = esp.flash_id()
37
165
  elif method == 'read':
38
- result = base64.b64encode(esp.read_flash(*args)).decode('ascii')
166
+ result = base64.b64encode(_read_flash(esp, *args)).decode('ascii')
39
167
  elif method == 'write':
40
168
  offset, encoded = args
41
169
  data = base64.b64decode(encoded, validate=True)
@@ -0,0 +1,219 @@
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 sleep = dependencies.sleep ?? ((ms, abortSignal) => delay(ms, undefined, { signal: abortSignal }))
183
+ const deadline = now() + timeout
184
+ const sameDevice = item => mac(item.serialNumber) === expectedMac && physicalLocation(item.location) === location
185
+ let runtime
186
+ // A closed native stub may still enumerate as ROM, then disappear before
187
+ // the same device's runtime reappears. Only observe during this window;
188
+ // never re-SYNC the stale ROM or issue a second handoff while it settles.
189
+ while (now() < deadline) {
190
+ checkAbort(signal)
191
+ let ports
192
+ try { ports = await enumerate({ python, signal, timeout: Math.max(1, Math.ceil(deadline - now())) }) }
193
+ catch (error) {
194
+ checkAbort(signal)
195
+ throw fail('ROM_PORT_ENUMERATION_FAILED', 'Cannot verify the selected device for read-only initialization recovery', error)
196
+ }
197
+ checkAbort(signal)
198
+ if (now() >= deadline) throw fail('ROM_PORT_REBIND_TIMEOUT', 'Read-only initialization recovery enumeration exceeded its deadline')
199
+ if (!Array.isArray(ports)) throw fail('ROM_PORT_ENUMERATION_FAILED', 'Serial enumeration returned invalid descriptors')
200
+ const candidates = ports.filter(item => nativeRom(item) || nativeRuntime(item))
201
+ const conflicting = candidates.filter(item => (mac(item.serialNumber) === expectedMac || physicalLocation(item.location) === location) && !sameDevice(item))
202
+ if (conflicting.length) throw fail('ROM_RUNTIME_IDENTITY_MISMATCH', 'Runtime descriptors do not match the previously selected ROM device')
203
+ const matches = candidates.filter(sameDevice)
204
+ if (matches.length > 1) throw fail('ROM_PORT_AMBIGUOUS', 'Multiple native USB ports match the selected device')
205
+ if (matches.length && nativeRuntime(matches[0])) { runtime = matches[0]; break }
206
+ await sleep(Math.min(50, Math.max(1, deadline - now())), signal)
207
+ }
208
+ // Returning null preserves the caller's original initialize failure.
209
+ if (!runtime) return null
210
+ // Revalidate at prepare's fresh enumeration, before opening EV-MUX. An OS port
211
+ // may have been reused for a different board since the recovery snapshot.
212
+ const result = await prepareNodeRomPort({ port: runtime.path, python, signal, timeout,
213
+ expectedIdentity: { mac: expectedMac, location } }, dependencies)
214
+ if (result.expectedMac !== expectedMac || result.transition?.observed !== 'rom' || physicalLocation(result.transition.location) !== location) {
215
+ throw fail('ROM_RUNTIME_IDENTITY_MISMATCH', 'Recovery did not preserve the selected physical device identity')
216
+ }
217
+ result.transition.recovery = { previousPort: previous.port, runtimePid: runtime.productId }
218
+ return result
219
+ }