@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.
- package/CHANGELOG.md +37 -0
- package/NOTICE +8 -0
- package/README.md +25 -8
- package/package.json +4 -3
- package/schemas/device-actions-v1.json +1 -1
- package/scripts/generate-action-schema.mjs +2 -1
- package/scripts/npm-command.mjs +17 -0
- package/scripts/release-npm.mjs +18 -1
- package/scripts/version-policy.mjs +71 -0
- package/src/browser.js +6 -2
- package/src/browser_rom.js +208 -4
- package/src/browser_rom_compat.js +86 -59
- package/src/browser_serial.js +130 -0
- package/src/device.js +5 -4
- package/src/device_bindings.js +5 -4
- package/src/device_capture.js +11 -0
- package/src/device_operations.js +3 -1
- package/src/device_session.js +263 -10
- package/src/evmux.js +43 -4
- package/src/firmware_flash.js +1 -1
- package/src/jpeg_validate.js +132 -0
- package/src/node_inventory.js +1 -1
- package/src/node_rom.js +92 -29
- package/src/node_rom.py +134 -6
- package/src/node_rom_port.js +219 -0
- package/src/node_serial.py +122 -13
- package/src/node_transport.js +136 -21
- package/src/rom_md5.js +30 -0
- package/tests/check-browser.mjs +76 -123
- package/tests/check-package.mjs +8 -7
- package/tests/device-checks.mjs +6 -2
- package/tests/fixtures/device-checks.py +11 -1
- package/tests/jpeg-fixtures.mjs +39 -0
- package/tests/test-browser-rom.mjs +204 -67
- package/tests/test-browser-serial.mjs +143 -0
- package/tests/test-device-api.mjs +40 -7
- package/tests/test-device-functional-checks.mjs +12 -2
- package/tests/test-device-operations-api.mjs +4 -3
- package/tests/test-device-reset.mjs +292 -0
- package/tests/test-firmware-flash.mjs +1 -1
- package/tests/test-host-checks.mjs +47 -0
- package/tests/test-jpeg-validation.mjs +233 -0
- package/tests/test-node-rom-port.mjs +385 -0
- package/tests/test-node-rom.mjs +231 -0
- package/tests/test-node-serial.mjs +436 -1
- package/tests/test-npm-release-policy.mjs +56 -0
- package/tests/test-observed-capture.mjs +131 -0
- package/tests/test-uart-baud.mjs +180 -0
- 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
|
-
|
|
11
|
-
|
|
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
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
41
|
-
try {
|
|
42
|
-
|
|
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
|
|
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;
|
|
55
|
-
|
|
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
|
-
|
|
14
|
-
|
|
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
|
|
27
|
-
|
|
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
|
|
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
|
+
}
|