@esp-vision/device-core 0.5.0 → 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.
- package/CHANGELOG.md +32 -1
- package/NOTICE +8 -0
- package/README.md +24 -7
- package/package.json +4 -3
- package/schemas/device-actions-v1.json +1 -1
- package/scripts/release-npm.mjs +16 -0
- 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 +76 -27
- package/src/node_rom.py +68 -2
- package/src/node_rom_port.js +211 -0
- package/src/node_serial.py +47 -1
- package/src/node_transport.js +76 -10
- package/src/rom_md5.js +30 -0
- package/tests/check-browser.mjs +76 -123
- package/tests/check-package.mjs +1 -1
- 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-execution.mjs +30 -3
- 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-jpeg-validation.mjs +233 -0
- package/tests/test-node-rom-port.mjs +305 -0
- package/tests/test-node-rom.mjs +160 -0
- package/tests/test-node-serial.mjs +206 -0
- package/tests/test-npm-release-policy.mjs +62 -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
|
+
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
|
|
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'),
|
|
@@ -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;
|
|
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
|
-
})
|
|
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
|
|
27
|
-
|
|
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
|
+
}
|
package/src/node_serial.py
CHANGED
|
@@ -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
|
|
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:])
|
package/src/node_transport.js
CHANGED
|
@@ -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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
|
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
|
+
}
|