@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/device_session.js
CHANGED
|
@@ -8,9 +8,12 @@
|
|
|
8
8
|
* drift between the two entry points.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import { requireObservedCameraCapture } from './device_capture.js'
|
|
12
|
+
|
|
11
13
|
export const EV_MUX_PAYLOAD_LIMIT = 1800
|
|
12
14
|
export const EV_MUX_SCRIPT_CHUNK_BYTES = 900
|
|
13
15
|
export const EV_MUX_VERSION = 4
|
|
16
|
+
export const UART_BAUD_CANDIDATES = Object.freeze([2000000, 1500000, 921600, 460800, 115200])
|
|
14
17
|
const INLINE_PYTHON_REPL_TIMEOUT_MS = 5000
|
|
15
18
|
const SCRIPT_START_ACK_TIMEOUT_MS = 5000
|
|
16
19
|
// MicroPython has a 128-byte VFS path buffer; script.write also appends .tmp.
|
|
@@ -49,6 +52,9 @@ const EV_MUX_USER_RPC_METHODS = new Set([
|
|
|
49
52
|
'capabilities',
|
|
50
53
|
'session.keepalive',
|
|
51
54
|
'session.release',
|
|
55
|
+
'transport.uart.prepare',
|
|
56
|
+
'transport.uart.probe',
|
|
57
|
+
'transport.uart.commit',
|
|
52
58
|
'file.write',
|
|
53
59
|
'script.write',
|
|
54
60
|
'script.run',
|
|
@@ -333,6 +339,7 @@ export class DeviceSession {
|
|
|
333
339
|
this.negotiatePromise = null
|
|
334
340
|
this.negotiateController = null
|
|
335
341
|
this.releasePromise = null
|
|
342
|
+
this.resetPromise = null
|
|
336
343
|
this.vmReadinessProbeActive = false
|
|
337
344
|
this.pendingRpc = new Map()
|
|
338
345
|
this.draining = false
|
|
@@ -360,10 +367,19 @@ export class DeviceSession {
|
|
|
360
367
|
},
|
|
361
368
|
rawText: (text) => this.handlers.rawText?.(text),
|
|
362
369
|
preview: (base64, metadata, bytes) => this.handlers.preview?.(base64, metadata, bytes),
|
|
370
|
+
invalidImage: (metadata, error, payload) => {
|
|
371
|
+
this._diagnostic('image.invalid', { channel: metadata.channel, method: metadata.method ?? null, seq: metadata.seq,
|
|
372
|
+
code: 'INVALID_IMAGE', reason: error.reason ?? 'invalid JPEG structure' })
|
|
373
|
+
const seq = Number(metadata.seq), pending = this.pendingRpc.get(seq)
|
|
374
|
+
if (metadata.type === 'rsp' && pending && this._matchesPendingResponse(metadata, payload)) {
|
|
375
|
+
this.pendingRpc.delete(seq)
|
|
376
|
+
pending.reject(error)
|
|
377
|
+
}
|
|
378
|
+
},
|
|
363
379
|
debugImage: (base64, metadata, bytes, frameMetadata) => {
|
|
364
380
|
this.lastDebugImage = { base64, metadata }
|
|
365
381
|
const pending = this.pendingRpc.get(Number(metadata.seq))
|
|
366
|
-
if (pending
|
|
382
|
+
if (pending && frameMetadata && this._matchesPendingResponse(frameMetadata, { ok: true })) {
|
|
367
383
|
pending.image = { data: bytes.slice(), mimeType: 'image/jpeg', metadata: { ...metadata } }
|
|
368
384
|
}
|
|
369
385
|
this.handlers.debugImage?.(base64, metadata)
|
|
@@ -691,10 +707,12 @@ export class DeviceSession {
|
|
|
691
707
|
signal = null,
|
|
692
708
|
writeTimeout = null,
|
|
693
709
|
encoding = undefined,
|
|
710
|
+
resetToken = null,
|
|
694
711
|
} = {}) {
|
|
695
712
|
if (this.state === 'disconnected') {
|
|
696
713
|
throw new Error('EV-MUX is not connected')
|
|
697
714
|
}
|
|
715
|
+
if (type === 'req' && method === 'debug.capture_frame') requireObservedCameraCapture(this)
|
|
698
716
|
const seq = requestedSeq === null ? this._allocateSequence() : requestedSeq >>> 0
|
|
699
717
|
const requestPayload = typeof payload === 'string' && method
|
|
700
718
|
? (() => { try { return JSON.parse(payload) } catch (_err) { return {} } })()
|
|
@@ -702,6 +720,10 @@ export class DeviceSession {
|
|
|
702
720
|
const needsAuthorization = authorize ?? !(method === 'hello' || method === 'capabilities')
|
|
703
721
|
const usesVm = vmRequest ?? (channel === 'repl.stdin' || channel === 'repl.signal'
|
|
704
722
|
|| (Boolean(method) && isVmRpc(method, requestPayload)))
|
|
723
|
+
if (this.state === 'resetting' && !(resetToken && resetToken === this.resetWriteToken)
|
|
724
|
+
&& !(method === 'device.control' && requestPayload?.action === 'reset')) {
|
|
725
|
+
throw Object.assign(new Error('Device reset is in progress'), { code: 'DEVICE_RESETTING' })
|
|
726
|
+
}
|
|
705
727
|
if (needsAuthorization && !this.sessionId) {
|
|
706
728
|
throw new Error(`EV-MUX ${method || channel} requires an acquired v4 session`)
|
|
707
729
|
}
|
|
@@ -771,6 +793,7 @@ export class DeviceSession {
|
|
|
771
793
|
throw new Error('EV-MUX is not connected')
|
|
772
794
|
}
|
|
773
795
|
if (signal?.aborted) throw abortError(signal)
|
|
796
|
+
if (method === 'debug.capture_frame') requireObservedCameraCapture(this)
|
|
774
797
|
if (payload instanceof Uint8Array && method !== 'file.write') throw new TypeError('Raw binary requests are only supported for file.write')
|
|
775
798
|
if (method === 'script.write' || method === 'script.run') validateRpcPath(payload?.path)
|
|
776
799
|
if (method === 'debug.info' && (payload?.scope === 'fs.list' || payload?.scope === 'fs.read')) {
|
|
@@ -856,7 +879,7 @@ export class DeviceSession {
|
|
|
856
879
|
throw error
|
|
857
880
|
}
|
|
858
881
|
if (binaryResponse) {
|
|
859
|
-
if (!image) throw Object.assign(new Error('
|
|
882
|
+
if (!image) throw Object.assign(new Error('Invalid JPEG image: capture response contains no binary JPEG'), { code: 'INVALID_IMAGE' })
|
|
860
883
|
return image
|
|
861
884
|
}
|
|
862
885
|
return this._redactRpcPayload(method, responsePayload, { requestPayload: payload, redactSsid: false })
|
|
@@ -866,9 +889,126 @@ export class DeviceSession {
|
|
|
866
889
|
return this.rpc(method, payload, { ...options, channel: 'debug.rpc', binaryResponse: true })
|
|
867
890
|
}
|
|
868
891
|
|
|
892
|
+
async _setUartBaudRate(baudRate, signal) {
|
|
893
|
+
const controller = new AbortController()
|
|
894
|
+
const abort = () => controller.abort(abortError(signal))
|
|
895
|
+
signal?.addEventListener('abort', abort, { once: true })
|
|
896
|
+
if (signal?.aborted) abort()
|
|
897
|
+
try {
|
|
898
|
+
return await withTimeout(() => this.transport.setBaudRate(baudRate, { signal: controller.signal }), 2000, 'UART speed change timed out', controller.signal)
|
|
899
|
+
} catch (error) {
|
|
900
|
+
// Web Serial cannot cancel open(). Mark its in-flight attempt stale so
|
|
901
|
+
// a late open closes itself rather than changing a recovered link.
|
|
902
|
+
controller.abort(error)
|
|
903
|
+
throw error
|
|
904
|
+
} finally { signal?.removeEventListener('abort', abort) }
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
async _negotiateUartBaud({ hello, discovery, signal, expectedDevice }) {
|
|
908
|
+
const options = this.capabilities.uartBaud
|
|
909
|
+
if (options.baseline !== 115200 || !Array.isArray(options.candidates)
|
|
910
|
+
|| !Number.isSafeInteger(options.timeoutMs) || options.timeoutMs < 1000 || options.timeoutMs > 10000
|
|
911
|
+
|| !Number.isSafeInteger(options.probeBytes) || options.probeBytes < 32 || options.probeBytes > 512) {
|
|
912
|
+
this._diagnostic('uart.baud.skipped', { reason: 'invalid_capabilities' })
|
|
913
|
+
return { hello, discovery }
|
|
914
|
+
}
|
|
915
|
+
const candidates = UART_BAUD_CANDIDATES.filter(baud => options.candidates.includes(baud))
|
|
916
|
+
if (!candidates.includes(115200)) return { hello, discovery }
|
|
917
|
+
let current = { hello, discovery }
|
|
918
|
+
for (const baudRate of candidates) {
|
|
919
|
+
if (signal?.aborted) throw abortError(signal)
|
|
920
|
+
clearTimeout(this.keepaliveTimer)
|
|
921
|
+
this.keepaliveGeneration += 1
|
|
922
|
+
this.state = 'negotiating'
|
|
923
|
+
const token = randomHex()
|
|
924
|
+
let commitSent = false
|
|
925
|
+
this._diagnostic('uart.baud.testing', { baudRate })
|
|
926
|
+
try {
|
|
927
|
+
const prepared = await this.rpc('transport.uart.prepare', { baudRate, token }, { timeout: 1500, signal })
|
|
928
|
+
if (prepared.baudRate !== baudRate || prepared.token !== token || prepared.timeoutMs !== options.timeoutMs) {
|
|
929
|
+
throw Object.assign(new Error('Invalid UART switch acknowledgement'), { code: 'UART_BAUD_INVALID_RESPONSE' })
|
|
930
|
+
}
|
|
931
|
+
const delay = prepared.switchDelayMs ?? options.switchDelayMs ?? 100
|
|
932
|
+
if (!Number.isSafeInteger(delay) || delay < 0 || delay > 1000) {
|
|
933
|
+
throw Object.assign(new Error('Invalid UART switch delay'), { code: 'UART_BAUD_INVALID_RESPONSE' })
|
|
934
|
+
}
|
|
935
|
+
await this._setUartBaudRate(baudRate, signal)
|
|
936
|
+
// Firmware drains the old-rate ACK before changing hardware. The host
|
|
937
|
+
// must not send a new-rate probe into that drain/guard interval.
|
|
938
|
+
await awaitWithSignal(this.sleep(delay + 20), signal)
|
|
939
|
+
this.parser.reset()
|
|
940
|
+
this.parser.active = true
|
|
941
|
+
for (const length of [...new Set([32, Math.min(128, options.probeBytes), options.probeBytes])]) {
|
|
942
|
+
for (let round = 0; round < 2; round += 1) {
|
|
943
|
+
// Framing validates CRC in both directions; exact echo also rejects
|
|
944
|
+
// stale responses and byte corruption at different packet lengths.
|
|
945
|
+
const data = randomHex(Math.ceil(length / 2)).slice(0, length)
|
|
946
|
+
const probe = await this.rpc('transport.uart.probe', { token, data }, { timeout: 800, signal })
|
|
947
|
+
if (probe.token !== token || probe.data !== data) {
|
|
948
|
+
throw Object.assign(new Error('UART bidirectional verification failed'), { code: 'UART_BAUD_BAD_ECHO' })
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
commitSent = true
|
|
953
|
+
let committed
|
|
954
|
+
try {
|
|
955
|
+
committed = await this.rpc('transport.uart.commit', { token }, { timeout: 800, signal })
|
|
956
|
+
} catch (error) {
|
|
957
|
+
if (signal?.aborted || error.code !== 'TIMEOUT') throw error
|
|
958
|
+
// Commit is token-idempotent; retry a lost ACK, never business data.
|
|
959
|
+
committed = await this.rpc('transport.uart.commit', { token }, { timeout: 800, signal })
|
|
960
|
+
}
|
|
961
|
+
if (committed.token !== token || committed.baudRate !== baudRate) {
|
|
962
|
+
throw Object.assign(new Error('Invalid UART commit acknowledgement'), { code: 'UART_BAUD_INVALID_RESPONSE' })
|
|
963
|
+
}
|
|
964
|
+
this.capabilities = { ...this.capabilities, uartBaud: { ...options, current: baudRate } }
|
|
965
|
+
this._diagnostic('uart.baud.selected', { baudRate })
|
|
966
|
+
this._startKeepalive()
|
|
967
|
+
return current
|
|
968
|
+
} catch (error) {
|
|
969
|
+
if (signal?.aborted) throw abortError(signal)
|
|
970
|
+
this._diagnostic('uart.baud.failed', { baudRate, code: error.code ?? 'SERIAL_ERROR' })
|
|
971
|
+
// A reopen can reset a board, or a damaged ACK can hide a successful
|
|
972
|
+
// device-side switch. Release if possible, then wait for the firmware's
|
|
973
|
+
// independent rollback before reacquiring at the baseline.
|
|
974
|
+
let released = false
|
|
975
|
+
try {
|
|
976
|
+
await this.rpc('session.release', {}, { timeout: 500, signal })
|
|
977
|
+
released = true
|
|
978
|
+
} catch { /* The firmware timeout is the independent recovery path. */ }
|
|
979
|
+
const rollbackMs = released ? 150 : commitSent ? this.leaseMs + 250 : options.timeoutMs + 250
|
|
980
|
+
// Each authenticated request may renew the lease, even if its ACK is
|
|
981
|
+
// lost. Count conservatively from the last release attempt, rather
|
|
982
|
+
// than from prepare submission or an earlier successful probe.
|
|
983
|
+
await awaitWithSignal(this.sleep(rollbackMs), signal)
|
|
984
|
+
await this._setUartBaudRate(115200, signal)
|
|
985
|
+
clearTimeout(this.keepaliveTimer)
|
|
986
|
+
this.keepaliveGeneration += 1
|
|
987
|
+
this.sessionGeneration += 1
|
|
988
|
+
this.sessionId = null
|
|
989
|
+
this.vmEpoch = null
|
|
990
|
+
this.vmReady = false
|
|
991
|
+
this.vmReadyKnown = false
|
|
992
|
+
this.clientNonce = randomHex()
|
|
993
|
+
this.parser.reset()
|
|
994
|
+
this.state = 'negotiating'
|
|
995
|
+
current = await this._negotiate({ role: this.role, drainMs: 0, probeRepl: false, autoBaud: false, signal, expectedDevice })
|
|
996
|
+
this.state = 'negotiating'
|
|
997
|
+
if (current.hello.session.owner !== 'uart') {
|
|
998
|
+
throw Object.assign(new Error('UART owner changed during speed recovery'), { code: 'UART_BAUD_RECOVERY_FAILED' })
|
|
999
|
+
}
|
|
1000
|
+
if (baudRate === 115200) throw Object.assign(new Error('UART baseline verification failed'), { code: 'UART_BAUD_VERIFICATION_FAILED', cause: error })
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
return current
|
|
1004
|
+
}
|
|
1005
|
+
|
|
869
1006
|
negotiate(options = {}) {
|
|
870
1007
|
if (this.negotiatePromise) return this.negotiatePromise
|
|
871
1008
|
if (this.releasePromise) return Promise.reject(new Error('EV-MUX session is being released'))
|
|
1009
|
+
if (this.resetPromise) {
|
|
1010
|
+
return Promise.reject(Object.assign(new Error('Device reset is in progress'), { code: 'DEVICE_RESETTING' }))
|
|
1011
|
+
}
|
|
872
1012
|
|
|
873
1013
|
const controller = new AbortController()
|
|
874
1014
|
const callerSignal = options.signal || null
|
|
@@ -878,7 +1018,7 @@ export class DeviceSession {
|
|
|
878
1018
|
this.negotiateController = controller
|
|
879
1019
|
const deadlineError = new Error('EV-MUX negotiation deadline exceeded')
|
|
880
1020
|
deadlineError.code = 'TIMEOUT'
|
|
881
|
-
const deadlineTimer = setTimeout(() => controller.abort(deadlineError),
|
|
1021
|
+
const deadlineTimer = setTimeout(() => controller.abort(deadlineError), 60000)
|
|
882
1022
|
const operation = this._negotiate({ ...options, signal: controller.signal })
|
|
883
1023
|
const tracked = operation.finally(() => {
|
|
884
1024
|
clearTimeout(deadlineTimer)
|
|
@@ -891,7 +1031,7 @@ export class DeviceSession {
|
|
|
891
1031
|
return tracked
|
|
892
1032
|
}
|
|
893
1033
|
|
|
894
|
-
async _negotiate({ role = 'unknown', drainMs = 300, signal = null, acquire = true, probeRepl = true, expectedDevice = null } = {}) {
|
|
1034
|
+
async _negotiate({ role = 'unknown', drainMs = 300, signal = null, acquire = true, probeRepl = true, expectedDevice = null, autoBaud = true } = {}) {
|
|
895
1035
|
this.role = role
|
|
896
1036
|
this.state = 'negotiating'
|
|
897
1037
|
this._diagnostic('negotiate.begin', { role })
|
|
@@ -986,7 +1126,6 @@ export class DeviceSession {
|
|
|
986
1126
|
this.keepaliveMs = hello.session.keepaliveMs || this.keepaliveMs
|
|
987
1127
|
this.leaseMs = hello.session.leaseMs || this.leaseMs
|
|
988
1128
|
this.parser.active = true
|
|
989
|
-
this.state = 'ready'
|
|
990
1129
|
this._startKeepalive()
|
|
991
1130
|
}
|
|
992
1131
|
|
|
@@ -1032,6 +1171,13 @@ export class DeviceSession {
|
|
|
1032
1171
|
// Start with the lease timing from the accepted hello immediately, then
|
|
1033
1172
|
// refresh its cadence once the full capability response is available.
|
|
1034
1173
|
this._startKeepalive()
|
|
1174
|
+
if (autoBaud && typeof this.transport.setBaudRate === 'function'
|
|
1175
|
+
&& hello.session.owner === 'uart' && this.capabilities.uartBaud) {
|
|
1176
|
+
const negotiated = await this._negotiateUartBaud({ hello, discovery, signal, expectedDevice })
|
|
1177
|
+
hello = negotiated.hello
|
|
1178
|
+
discovery = negotiated.discovery
|
|
1179
|
+
capabilities = this.capabilities
|
|
1180
|
+
}
|
|
1035
1181
|
} else {
|
|
1036
1182
|
this.parser.active = true
|
|
1037
1183
|
this.state = 'discovered'
|
|
@@ -1199,7 +1345,13 @@ export class DeviceSession {
|
|
|
1199
1345
|
release({ timeout = 1200 } = {}) {
|
|
1200
1346
|
if (this.releasePromise) return this.releasePromise
|
|
1201
1347
|
const negotiation = this.negotiatePromise
|
|
1348
|
+
const resetting = this.resetPromise
|
|
1202
1349
|
const operation = (async () => {
|
|
1350
|
+
if (resetting) {
|
|
1351
|
+
// The reset caller owns its result. After an ACK or uncertain reset
|
|
1352
|
+
// there is no old lease to release; a rejected reset retains it.
|
|
1353
|
+
try { await resetting } catch (_resetError) { /* Keep reset failure with its caller. */ }
|
|
1354
|
+
}
|
|
1203
1355
|
if (negotiation) {
|
|
1204
1356
|
try {
|
|
1205
1357
|
await negotiation
|
|
@@ -1288,7 +1440,7 @@ export class DeviceSession {
|
|
|
1288
1440
|
if (operation === 'stop') return this.interrupt()
|
|
1289
1441
|
if (signal?.aborted) throw abortError(signal)
|
|
1290
1442
|
if (operation === 'hard-reset' && this.hasFeature('device.control')) {
|
|
1291
|
-
return this.
|
|
1443
|
+
return this._hardReset({ timeout, signal })
|
|
1292
1444
|
}
|
|
1293
1445
|
const command = {
|
|
1294
1446
|
'soft-reset': 'import machine; machine.soft_reset()\r\n',
|
|
@@ -1297,10 +1449,111 @@ export class DeviceSession {
|
|
|
1297
1449
|
}[operation]
|
|
1298
1450
|
if (!command) throw new Error(`Unsupported device control operation: ${operation}`)
|
|
1299
1451
|
if (operation === 'soft-reset') return this._softReset(command, { timeout, signal })
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1452
|
+
return this._resetViaRepl(operation, command, { timeout, signal })
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
_resetViaRepl(operation, command, { timeout, signal }) {
|
|
1456
|
+
if (!Number.isFinite(timeout) || timeout <= 0) throw new RangeError('Reset timeout must be positive')
|
|
1457
|
+
if (this.releasePromise) {
|
|
1458
|
+
throw Object.assign(new Error('Device session is being released'), { code: 'DEVICE_RELEASING' })
|
|
1459
|
+
}
|
|
1460
|
+
if (this.resetPromise || this.state !== 'ready') {
|
|
1461
|
+
throw Object.assign(new Error('Device is not ready for reset'), { code: 'DEVICE_RESETTING' })
|
|
1462
|
+
}
|
|
1463
|
+
const deadline = createOperationDeadline(signal, timeout,
|
|
1464
|
+
Object.assign(new Error('Device reset submission timed out'), { code: 'TIMEOUT' }))
|
|
1465
|
+
const owner = this.sessionId
|
|
1466
|
+
const generation = this.sessionGeneration
|
|
1467
|
+
const resetToken = Symbol('REPL reset')
|
|
1468
|
+
this.resetWriteToken = resetToken
|
|
1469
|
+
this.state = 'resetting'
|
|
1470
|
+
clearTimeout(this.keepaliveTimer)
|
|
1471
|
+
this.keepaliveTimer = null
|
|
1472
|
+
this.keepaliveGeneration += 1
|
|
1473
|
+
let commandAttempted = false
|
|
1474
|
+
const resetting = (async () => {
|
|
1475
|
+
try {
|
|
1476
|
+
const options = { signal: deadline.signal, resetToken }
|
|
1477
|
+
await this.writeFrame('repl.signal', 'signal', null, new Uint8Array([0x03]), options)
|
|
1478
|
+
if (deadline.signal.aborted) throw abortError(deadline.signal)
|
|
1479
|
+
commandAttempted = true
|
|
1480
|
+
await this.writeFrame('repl.stdin', 'data', null, command, options)
|
|
1481
|
+
// The Node bridge's pipe write is not a serial write acknowledgement.
|
|
1482
|
+
// Drain its output before cleanup can terminate that bridge process.
|
|
1483
|
+
await awaitWithSignal(this.transport.drain
|
|
1484
|
+
? this.transport.drain({ signal: deadline.signal }) : this.sleep(100), deadline.signal)
|
|
1485
|
+
// Framed REPL has no reset ACK. A completed write only confirms
|
|
1486
|
+
// submission; ROM identification belongs to the next connection.
|
|
1487
|
+
this.close(Object.assign(new Error('Device reset submitted'), { code: 'DEVICE_RESET' }))
|
|
1488
|
+
return { ok: true, operation, acknowledged: false, outcome: 'unknown' }
|
|
1489
|
+
} catch (error) {
|
|
1490
|
+
// A failed write may have reached the VM. Do not replay it or send
|
|
1491
|
+
// session.release to a possibly rebooted device using the old lease.
|
|
1492
|
+
if (commandAttempted) {
|
|
1493
|
+
error.reset = { acknowledged: false, outcome: 'unknown' }
|
|
1494
|
+
this.close(error)
|
|
1495
|
+
} else if (this.sessionId === owner && this.sessionGeneration === generation) {
|
|
1496
|
+
// Only the interrupt was attempted; no reset command was submitted.
|
|
1497
|
+
this.state = 'ready'
|
|
1498
|
+
this._startKeepalive()
|
|
1499
|
+
}
|
|
1500
|
+
throw error
|
|
1501
|
+
} finally {
|
|
1502
|
+
deadline.dispose()
|
|
1503
|
+
if (this.resetWriteToken === resetToken) this.resetWriteToken = null
|
|
1504
|
+
}
|
|
1505
|
+
})()
|
|
1506
|
+
const tracked = resetting.finally(() => {
|
|
1507
|
+
if (this.resetPromise === tracked) this.resetPromise = null
|
|
1508
|
+
})
|
|
1509
|
+
this.resetPromise = tracked
|
|
1510
|
+
return tracked
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
_hardReset({ timeout, signal }) {
|
|
1514
|
+
if (!Number.isFinite(timeout) || timeout <= 0) throw new RangeError('Reset timeout must be positive')
|
|
1515
|
+
if (this.releasePromise) {
|
|
1516
|
+
throw Object.assign(new Error('Device session is being released'), { code: 'DEVICE_RELEASING' })
|
|
1517
|
+
}
|
|
1518
|
+
if (this.resetPromise || this.state !== 'ready') {
|
|
1519
|
+
throw Object.assign(new Error('Device is not ready for reset'), { code: 'DEVICE_RESETTING' })
|
|
1520
|
+
}
|
|
1521
|
+
const owner = this.sessionId
|
|
1522
|
+
const generation = this.sessionGeneration
|
|
1523
|
+
this.state = 'resetting'
|
|
1524
|
+
clearTimeout(this.keepaliveTimer)
|
|
1525
|
+
this.keepaliveTimer = null
|
|
1526
|
+
this.keepaliveGeneration += 1
|
|
1527
|
+
const operation = (async () => {
|
|
1528
|
+
try {
|
|
1529
|
+
const response = await this.rpc('device.control', { action: 'reset' }, { timeout, signal })
|
|
1530
|
+
// The ACK confirms acceptance, not a completed reboot. USB Serial/JTAG
|
|
1531
|
+
// can stay open across restart; never send release using its old lease.
|
|
1532
|
+
this.close(Object.assign(new Error('Device reset accepted'), { code: 'DEVICE_RESET' }))
|
|
1533
|
+
return response
|
|
1534
|
+
} catch (error) {
|
|
1535
|
+
const rejected = error.response?.ok === false
|
|
1536
|
+
const leaseLost = ['SESSION_REQUIRED', 'SESSION_EXPIRED', 'NOT_OWNER'].includes(error.code)
|
|
1537
|
+
if (rejected && !leaseLost && this.sessionId === owner && this.sessionGeneration === generation) {
|
|
1538
|
+
// An authenticated rejection proves no reset was accepted. Keep the
|
|
1539
|
+
// live lease so normal cleanup still detects a genuine release error.
|
|
1540
|
+
this.state = 'ready'
|
|
1541
|
+
this._startKeepalive()
|
|
1542
|
+
} else {
|
|
1543
|
+
// Timeout, cancellation or link loss after submission is uncertain.
|
|
1544
|
+
// An ownership rejection also requires dropping the obsolete lease.
|
|
1545
|
+
// Preserve the original failure and never replay a destructive reset.
|
|
1546
|
+
if (!rejected) error.reset = { acknowledged: false, outcome: 'unknown' }
|
|
1547
|
+
this.close(error)
|
|
1548
|
+
}
|
|
1549
|
+
throw error
|
|
1550
|
+
}
|
|
1551
|
+
})()
|
|
1552
|
+
const tracked = operation.finally(() => {
|
|
1553
|
+
if (this.resetPromise === tracked) this.resetPromise = null
|
|
1554
|
+
})
|
|
1555
|
+
this.resetPromise = tracked
|
|
1556
|
+
return tracked
|
|
1304
1557
|
}
|
|
1305
1558
|
|
|
1306
1559
|
async _softReset(command, { timeout, signal }) {
|
package/src/evmux.js
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
* SPDX-License-Identifier: Apache-2.0
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import { validateJpegImage } from './jpeg_validate.js'
|
|
7
|
+
|
|
6
8
|
const SOF = 0x1e
|
|
7
9
|
const EOF_GUARD = 0x1f
|
|
8
10
|
const HEADER_RE = /^EVMUX\/1 h=(\d+) p=(\d+) c=([0-9A-Fa-f]{8})\r\n$/
|
|
@@ -98,6 +100,7 @@ export class EvMuxParser {
|
|
|
98
100
|
this.rxChunks = 0
|
|
99
101
|
this.frames = 0
|
|
100
102
|
this.droppedFrames = 0
|
|
103
|
+
this.invalidImageFrames = 0
|
|
101
104
|
this.lastChannel = null
|
|
102
105
|
this.maxMetadataLength = 65535
|
|
103
106
|
this.maxPayloadLength = MAX_BUFFER
|
|
@@ -175,6 +178,7 @@ export class EvMuxParser {
|
|
|
175
178
|
this.rxChunks = 0
|
|
176
179
|
this.frames = 0
|
|
177
180
|
this.droppedFrames = 0
|
|
181
|
+
this.invalidImageFrames = 0
|
|
178
182
|
this.lastChannel = null
|
|
179
183
|
}
|
|
180
184
|
|
|
@@ -186,6 +190,7 @@ export class EvMuxParser {
|
|
|
186
190
|
bufferedBytes: this.buffer.length,
|
|
187
191
|
frames: this.frames,
|
|
188
192
|
droppedFrames: this.droppedFrames,
|
|
193
|
+
invalidImageFrames: this.invalidImageFrames,
|
|
189
194
|
lastChannel: this.lastChannel,
|
|
190
195
|
}
|
|
191
196
|
}
|
|
@@ -319,9 +324,29 @@ export class EvMuxParser {
|
|
|
319
324
|
this.handlers.frameRejected?.(metadata)
|
|
320
325
|
continue
|
|
321
326
|
}
|
|
322
|
-
this.
|
|
323
|
-
|
|
324
|
-
|
|
327
|
+
if (this.dispatch(metadata, payload) !== false) {
|
|
328
|
+
this.frames += 1
|
|
329
|
+
this.lastChannel = metadata.channel || 'unknown'
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
_dropInvalidImage(metadata, error, payload) {
|
|
335
|
+
this.droppedFrames += 1
|
|
336
|
+
this.invalidImageFrames += 1
|
|
337
|
+
this.handlers.invalidImage?.(metadata, error, payload)
|
|
338
|
+
return false
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
_validateImage(metadata, payload) {
|
|
342
|
+
try {
|
|
343
|
+
if (metadata.encoding !== 'binary' || metadata.contentType !== 'image/jpeg') {
|
|
344
|
+
throw Object.assign(new Error('Invalid JPEG image: response must use binary image/jpeg'), { code: 'INVALID_IMAGE', reason: 'response must use binary image/jpeg' })
|
|
345
|
+
}
|
|
346
|
+
validateJpegImage(payload, metadata)
|
|
347
|
+
return true
|
|
348
|
+
} catch (error) {
|
|
349
|
+
return this._dropInvalidImage(metadata, error, payload)
|
|
325
350
|
}
|
|
326
351
|
}
|
|
327
352
|
|
|
@@ -338,9 +363,19 @@ export class EvMuxParser {
|
|
|
338
363
|
return
|
|
339
364
|
}
|
|
340
365
|
if (channel === 'user.rpc' || channel === 'debug.rpc') {
|
|
366
|
+
const capture = channel === 'debug.rpc' && metadata.type === 'rsp'
|
|
367
|
+
&& (metadata.method === 'debug.capture_frame' || metadata.method === 'debug.capture_screen')
|
|
368
|
+
if (capture && metadata.contentType !== 'image/jpeg') {
|
|
369
|
+
let response
|
|
370
|
+
try { response = metadata.encoding === 'json' ? JSON.parse(decodePayloadText(payload)) : null } catch { /* The correlated capture is rejected below. */ }
|
|
371
|
+
if (response?.ok !== false || typeof response.error?.code !== 'string' || !response.error.code) {
|
|
372
|
+
return this._dropInvalidImage(metadata, Object.assign(new Error('Invalid JPEG image: capture response contains no binary JPEG'), { code: 'INVALID_IMAGE', reason: 'capture response contains no binary JPEG' }), payload)
|
|
373
|
+
}
|
|
374
|
+
}
|
|
341
375
|
// EV-MUX v4: debug.capture_frame answers with the rsp frame itself
|
|
342
376
|
// carrying the binary JPEG payload (no separate data channel).
|
|
343
377
|
if (channel === 'debug.rpc' && metadata.type === 'rsp' && metadata.contentType === 'image/jpeg') {
|
|
378
|
+
if (!this._validateImage(metadata, payload)) return false
|
|
344
379
|
this.handlers.debugImage?.(bytesToBase64(payload), {
|
|
345
380
|
width: Number(metadata.width) || 0,
|
|
346
381
|
height: Number(metadata.height) || 0,
|
|
@@ -373,7 +408,11 @@ export class EvMuxParser {
|
|
|
373
408
|
this.handlers.command?.(appendLine(`${channel} ${line}`))
|
|
374
409
|
return
|
|
375
410
|
}
|
|
376
|
-
if (channel === 'preview.frame'
|
|
411
|
+
if (channel === 'preview.frame') {
|
|
412
|
+
if (metadata.type !== 'data') {
|
|
413
|
+
return this._dropInvalidImage(metadata, Object.assign(new Error('Invalid JPEG image: preview must be a data frame'), { code: 'INVALID_IMAGE', reason: 'preview must be a data frame' }), payload)
|
|
414
|
+
}
|
|
415
|
+
if (!this._validateImage(metadata, payload)) return false
|
|
377
416
|
this.handlers.preview?.(bytesToBase64(payload), {
|
|
378
417
|
width: Number(metadata.width) || 0,
|
|
379
418
|
height: Number(metadata.height) || 0,
|
package/src/firmware_flash.js
CHANGED
|
@@ -85,7 +85,7 @@ export class FirmwareFlashSession {
|
|
|
85
85
|
await validateFirmwareImage(this.image, bytes, { mode: this.mode })
|
|
86
86
|
onPhase(this.mode === 'install' ? 'Installing complete firmware; existing data in the written range will be replaced' : 'Writing application; bootloader, partition table, NVS and user files are preserved')
|
|
87
87
|
await this.stub.flashData(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength),
|
|
88
|
-
(written, total) => onProgress(total ? Math.min(80, Math.round(written / total * 80)) : 0), this.image.offset,
|
|
88
|
+
(written, total) => onProgress(total ? Math.min(80, Math.round(written / total * 80)) : 0), this.image.offset, true)
|
|
89
89
|
onPhase(this.mode === 'install' ? 'Verifying the complete installation image' : 'Verifying the complete application image')
|
|
90
90
|
// maxInFlight is a packet count: one 1 KiB packet bounds USB-JTAG backlog.
|
|
91
91
|
const readback = new Uint8Array(bytes.byteLength)
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
const invalid = reason => Object.assign(new Error(`Invalid JPEG image: ${reason}`), { code: 'INVALID_IMAGE', reason })
|
|
5
|
+
|
|
6
|
+
/** Bounded JPEG marker/table checks, not an entropy decoder (ITU-T T.81 B/C). */
|
|
7
|
+
export function validateJpegImage(bytes, metadata = {}) {
|
|
8
|
+
if (!(bytes instanceof Uint8Array)) throw invalid('payload must be binary bytes')
|
|
9
|
+
if (!Number.isInteger(metadata.width) || !Number.isInteger(metadata.height)
|
|
10
|
+
|| metadata.width <= 0 || metadata.height <= 0 || metadata.width > 65535 || metadata.height > 65535) throw invalid('invalid image dimensions')
|
|
11
|
+
if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8) throw invalid('missing SOI')
|
|
12
|
+
const quant = [-1, -1, -1, -1], huffman = new Uint8Array(8), components = []
|
|
13
|
+
let offset = 2, frame = null, scans = 0, covered = 0, restartInterval = 0
|
|
14
|
+
const word = index => (bytes[index] << 8) | bytes[index + 1]
|
|
15
|
+
while (offset < bytes.length) {
|
|
16
|
+
if (bytes[offset++] !== 0xff) throw invalid('expected a marker')
|
|
17
|
+
while (offset < bytes.length && bytes[offset] === 0xff) offset++
|
|
18
|
+
if (offset >= bytes.length) throw invalid('truncated marker')
|
|
19
|
+
const marker = bytes[offset++]
|
|
20
|
+
if (marker === 0xd9) {
|
|
21
|
+
if (!frame || !scans || covered !== (1 << components.length) - 1) throw invalid('incomplete frame or scans')
|
|
22
|
+
if (offset !== bytes.length) throw invalid('bytes follow EOI')
|
|
23
|
+
return { width: frame.width, height: frame.height }
|
|
24
|
+
}
|
|
25
|
+
if (marker === 0 || marker === 0xd8 || marker >= 0xd0 && marker <= 0xd7) throw invalid('unexpected standalone marker')
|
|
26
|
+
if (offset + 2 > bytes.length) throw invalid('truncated segment length')
|
|
27
|
+
const length = word(offset), begin = offset + 2, end = offset + length
|
|
28
|
+
if (length < 2 || end > bytes.length) throw invalid('segment exceeds image bounds')
|
|
29
|
+
offset = end
|
|
30
|
+
|
|
31
|
+
if (marker >= 0xe0 && marker <= 0xef || marker === 0xfe) continue
|
|
32
|
+
if (marker === 0xdb) {
|
|
33
|
+
if (begin === end) throw invalid('empty quantization segment')
|
|
34
|
+
let cursor = begin
|
|
35
|
+
while (cursor < end) {
|
|
36
|
+
const header = bytes[cursor++], precision = header >> 4, table = header & 15
|
|
37
|
+
if (precision > 1 || table > 3 || cursor + 64 * (precision + 1) > end) throw invalid('invalid quantization table')
|
|
38
|
+
for (let item = 0; item < 64; item++) {
|
|
39
|
+
const value = precision ? word(cursor) : bytes[cursor]
|
|
40
|
+
if (!value) throw invalid('zero quantization value')
|
|
41
|
+
cursor += precision + 1
|
|
42
|
+
}
|
|
43
|
+
quant[table] = precision
|
|
44
|
+
}
|
|
45
|
+
continue
|
|
46
|
+
}
|
|
47
|
+
if (marker === 0xc4) {
|
|
48
|
+
if (begin === end) throw invalid('empty Huffman segment')
|
|
49
|
+
let cursor = begin
|
|
50
|
+
while (cursor < end) {
|
|
51
|
+
if (cursor + 17 > end) throw invalid('truncated Huffman table')
|
|
52
|
+
const header = bytes[cursor++], kind = header >> 4, table = header & 15
|
|
53
|
+
if (kind > 1 || table > 3) throw invalid('invalid Huffman table selector')
|
|
54
|
+
let symbols = 0, slots = 1
|
|
55
|
+
for (let size = 0; size < 16; size++) {
|
|
56
|
+
const count = bytes[cursor++]
|
|
57
|
+
symbols += count
|
|
58
|
+
slots = slots * 2 - count
|
|
59
|
+
if (slots < 0) throw invalid('oversubscribed Huffman table')
|
|
60
|
+
}
|
|
61
|
+
if (!symbols || symbols > 256 || slots === 0 || cursor + symbols > end) throw invalid('invalid Huffman symbol bounds')
|
|
62
|
+
cursor += symbols
|
|
63
|
+
huffman[kind * 4 + table] = 1
|
|
64
|
+
}
|
|
65
|
+
continue
|
|
66
|
+
}
|
|
67
|
+
if (marker === 0xdd) {
|
|
68
|
+
if (length !== 4) throw invalid('invalid restart interval')
|
|
69
|
+
restartInterval = word(begin)
|
|
70
|
+
continue
|
|
71
|
+
}
|
|
72
|
+
if (marker >= 0xc0 && marker <= 0xc2) {
|
|
73
|
+
if (frame || scans || length < 11) throw invalid('invalid or repeated SOF')
|
|
74
|
+
const count = bytes[begin + 5], width = word(begin + 3), height = word(begin + 1)
|
|
75
|
+
if (bytes[begin] !== 8 || !count || count > 4 || length !== 8 + 3 * count) throw invalid('unsupported frame layout')
|
|
76
|
+
if (width !== metadata.width || height !== metadata.height) throw invalid('SOF dimensions do not match metadata')
|
|
77
|
+
frame = { marker, width, height }
|
|
78
|
+
for (let index = 0; index < count; index++) {
|
|
79
|
+
const cursor = begin + 6 + index * 3, id = bytes[cursor], sampling = bytes[cursor + 1], table = bytes[cursor + 2]
|
|
80
|
+
if (components.some(component => component.id === id) || !(sampling >> 4) || sampling >> 4 > 4
|
|
81
|
+
|| !(sampling & 15) || (sampling & 15) > 4 || table > 3) throw invalid('invalid frame component')
|
|
82
|
+
components.push({ id, table })
|
|
83
|
+
}
|
|
84
|
+
continue
|
|
85
|
+
}
|
|
86
|
+
if (marker !== 0xda) throw invalid('unsupported coding marker')
|
|
87
|
+
if (!frame || length < 8) throw invalid('SOS precedes a complete frame')
|
|
88
|
+
const count = bytes[begin]
|
|
89
|
+
if (!count || count > components.length || length !== 6 + 2 * count) throw invalid('invalid scan component bounds')
|
|
90
|
+
const spectralStart = bytes[end - 3], spectralEnd = bytes[end - 2], high = bytes[end - 1] >> 4, low = bytes[end - 1] & 15
|
|
91
|
+
const progressive = frame.marker === 0xc2
|
|
92
|
+
if (!progressive && (spectralStart !== 0 || spectralEnd !== 63 || high || low)) throw invalid('invalid sequential scan parameters')
|
|
93
|
+
if (progressive && (spectralStart > spectralEnd || spectralEnd > 63 || (spectralStart === 0 ? spectralEnd !== 0 : count !== 1)
|
|
94
|
+
|| high > 13 || low > 13 || high !== 0 && high !== low + 1)) throw invalid('invalid progressive scan parameters')
|
|
95
|
+
let selected = 0
|
|
96
|
+
for (let index = 0; index < count; index++) {
|
|
97
|
+
const cursor = begin + 1 + index * 2, component = components.findIndex(value => value.id === bytes[cursor])
|
|
98
|
+
const dc = bytes[cursor + 1] >> 4, ac = bytes[cursor + 1] & 15
|
|
99
|
+
if (component < 0 || selected & (1 << component) || dc > 3 || ac > 3) throw invalid('invalid scan component reference')
|
|
100
|
+
selected |= 1 << component
|
|
101
|
+
if (quant[components[component].table] !== 0) throw invalid('missing 8-bit quantization table')
|
|
102
|
+
if ((!progressive || spectralStart === 0 && high === 0) && !huffman[dc]) throw invalid('missing DC Huffman table')
|
|
103
|
+
if ((!progressive || spectralStart > 0) && !huffman[4 + ac]) throw invalid('missing AC Huffman table')
|
|
104
|
+
}
|
|
105
|
+
if (!progressive || spectralStart === 0 && high === 0) covered |= selected
|
|
106
|
+
scans++
|
|
107
|
+
let entropyBytes = 0, restart = 0
|
|
108
|
+
while (offset < bytes.length) {
|
|
109
|
+
if (bytes[offset] !== 0xff) { entropyBytes++; offset++; continue }
|
|
110
|
+
const markerStart = offset++
|
|
111
|
+
while (offset < bytes.length && bytes[offset] === 0xff) offset++
|
|
112
|
+
if (offset >= bytes.length) throw invalid('truncated entropy marker')
|
|
113
|
+
const code = bytes[offset++]
|
|
114
|
+
if (code === 0) {
|
|
115
|
+
if (offset !== markerStart + 2) throw invalid('fill bytes precede stuffed entropy')
|
|
116
|
+
entropyBytes++
|
|
117
|
+
continue
|
|
118
|
+
}
|
|
119
|
+
if (code >= 0xd0 && code <= 0xd7) {
|
|
120
|
+
if (!restartInterval || !entropyBytes || code !== 0xd0 + restart) throw invalid('invalid restart marker sequence')
|
|
121
|
+
restart = (restart + 1) & 7
|
|
122
|
+
entropyBytes = 0
|
|
123
|
+
continue
|
|
124
|
+
}
|
|
125
|
+
if (!entropyBytes) throw invalid('empty entropy scan')
|
|
126
|
+
offset = markerStart
|
|
127
|
+
break
|
|
128
|
+
}
|
|
129
|
+
if (!entropyBytes) throw invalid('empty entropy scan')
|
|
130
|
+
}
|
|
131
|
+
throw invalid('missing final EOI')
|
|
132
|
+
}
|
package/src/node_inventory.js
CHANGED
|
@@ -87,7 +87,7 @@ export async function inventory(options = {}, dependencies = {}) {
|
|
|
87
87
|
signal?.throwIfAborted()
|
|
88
88
|
const device = (dependencies.createDevice ?? createDevice)({ transport: (dependencies.transportFor ?? transportFor)(row, options) })
|
|
89
89
|
try {
|
|
90
|
-
const result = await device.connect({ role: row.interface === 'serial' ? 'unknown' : row.interface, timeout: options.probeTimeout ?? 5000, signal, probeRepl: false, ...(row.advertisement ? { expectedDevice: row.advertisement } : {}) })
|
|
90
|
+
const result = await device.connect({ role: row.interface === 'serial' ? 'unknown' : row.interface, timeout: options.probeTimeout ?? 5000, signal, probeRepl: false, autoBaud: false, ...(row.advertisement ? { expectedDevice: row.advertisement } : {}) })
|
|
91
91
|
row.identity = result.identity; row.inspection = await device.inspect({ fields: options.fields, signal }); row.status = 'identified'
|
|
92
92
|
} catch (error) {
|
|
93
93
|
if (signal?.aborted) throw error
|