@esp-vision/device-core 0.5.1 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/NOTICE +8 -0
  3. package/README.md +25 -8
  4. package/package.json +4 -3
  5. package/schemas/device-actions-v1.json +1 -1
  6. package/scripts/generate-action-schema.mjs +2 -1
  7. package/scripts/npm-command.mjs +17 -0
  8. package/scripts/release-npm.mjs +18 -1
  9. package/scripts/version-policy.mjs +71 -0
  10. package/src/browser.js +6 -2
  11. package/src/browser_rom.js +208 -4
  12. package/src/browser_rom_compat.js +86 -59
  13. package/src/browser_serial.js +130 -0
  14. package/src/device.js +5 -4
  15. package/src/device_bindings.js +5 -4
  16. package/src/device_capture.js +11 -0
  17. package/src/device_operations.js +3 -1
  18. package/src/device_session.js +263 -10
  19. package/src/evmux.js +43 -4
  20. package/src/firmware_flash.js +1 -1
  21. package/src/jpeg_validate.js +132 -0
  22. package/src/node_inventory.js +1 -1
  23. package/src/node_rom.js +92 -29
  24. package/src/node_rom.py +134 -6
  25. package/src/node_rom_port.js +219 -0
  26. package/src/node_serial.py +122 -13
  27. package/src/node_transport.js +136 -21
  28. package/src/rom_md5.js +30 -0
  29. package/tests/check-browser.mjs +76 -123
  30. package/tests/check-package.mjs +8 -7
  31. package/tests/device-checks.mjs +6 -2
  32. package/tests/fixtures/device-checks.py +11 -1
  33. package/tests/jpeg-fixtures.mjs +39 -0
  34. package/tests/test-browser-rom.mjs +204 -67
  35. package/tests/test-browser-serial.mjs +143 -0
  36. package/tests/test-device-api.mjs +40 -7
  37. package/tests/test-device-functional-checks.mjs +12 -2
  38. package/tests/test-device-operations-api.mjs +4 -3
  39. package/tests/test-device-reset.mjs +292 -0
  40. package/tests/test-firmware-flash.mjs +1 -1
  41. package/tests/test-host-checks.mjs +47 -0
  42. package/tests/test-jpeg-validation.mjs +233 -0
  43. package/tests/test-node-rom-port.mjs +385 -0
  44. package/tests/test-node-rom.mjs +231 -0
  45. package/tests/test-node-serial.mjs +436 -1
  46. package/tests/test-npm-release-policy.mjs +56 -0
  47. package/tests/test-observed-capture.mjs +131 -0
  48. package/tests/test-uart-baud.mjs +180 -0
  49. package/tests/test-version-policy.mjs +37 -0
@@ -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?.binaryResponse && frameMetadata && this._matchesPendingResponse(frameMetadata, { ok: true })) {
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('Capture returned no binary image'), { code: 'INVALID_RESPONSE' })
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), 30000)
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.rpc('device.control', { action: 'reset' }, { timeout, signal })
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
- await this.writeFrame('repl.signal', 'signal', null, new Uint8Array([0x03]), { signal })
1301
- await this.writeFrame('repl.stdin', 'data', null, command, { signal })
1302
- await awaitWithSignal(this.sleep(100), signal)
1303
- return { ok: true, operation, acknowledged: false }
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.frames += 1
323
- this.lastChannel = metadata.channel || 'unknown'
324
- this.dispatch(metadata, payload)
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' && metadata.contentType === 'image/jpeg') {
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,
@@ -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, false)
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
+ }
@@ -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