@esp-vision/device-core 0.1.1 → 0.2.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 CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0
4
+
5
+ ### Changed
6
+
7
+ - File and large-script uploads use one reliable binary `file.write` engine with 16 KiB blocks, integrity validation and cancellation cleanup; no Base64/JSON or REPL upload fallback. Public `files.write` and Python API signatures stay unchanged. Uploads require matching firmware capability `file.write.binary`; older v4 devices can still be inspected but must update firmware before upload.
8
+
9
+ ### Fixed
10
+
11
+ - Verify newly published tarballs using bounded uncached registry reads instead of a single immediate npm view; retry transient metadata propagation/network failures without repeating publication, while failing immediately on access denial or integrity mismatch.
12
+
3
13
  ## 0.1.1
4
14
 
5
15
  ### Fixed
package/README.md CHANGED
@@ -19,10 +19,10 @@
19
19
  | Visibility | Public; installation needs no npm login |
20
20
  | Source location | `host/device-core` in the ESP-VISION firmware repository |
21
21
 
22
- The `/node` export and bundled checks below require 0.1.1, the next patch release prepared by this source tree; 0.1.0 remains the published baseline until the release pipeline succeeds. CI authenticates with the protected `NPM_PUBLISH_TOKEN` and verifies that `npm whoami` returns `esp-vision` before publishing. Tokens are not part of this package or its documentation.
22
+ The `/node` export and bundled checks were introduced in 0.1.1. This source prepares 0.2.0 with reliable binary uploads; confirm registry publication before installing it. CI authenticates with the protected `NPM_PUBLISH_TOKEN` and verifies that `npm whoami` returns `esp-vision` before publishing. Tokens are not part of this package or its documentation.
23
23
 
24
24
  ```sh
25
- npm install --save-exact @esp-vision/device-core@0.1.1 --registry=https://registry.npmjs.org/
25
+ npm install --save-exact @esp-vision/device-core@0.2.0 --registry=https://registry.npmjs.org/
26
26
  ```
27
27
 
28
28
  EV-MUX v4 firmware is required. A v3 hello is rejected with `VERSION_MISMATCH`; applications should ask the user to update firmware. Core has no runtime dependency on the IDE, Agent, DOM or Node serial libraries. The host supplies the physical byte transport and discovery adapter; Core owns protocol parsing, session negotiation, REPL coordination and device operations.
@@ -41,7 +41,7 @@ Documentation source lives in this repository under `docs/en/device-core/` and `
41
41
  The npm package installs `esp-vision-device`. `inspect` combines passive wired/LAN enumeration and targeted queries. By default it lists USB serial candidates; `--all-ports` also includes native UART/Bluetooth ports. Explicit `--port` bypasses discovery filtering; `configure`, `control`, and `firmware check/update` cover management. See the [CLI guide](https://docs.espressif.com/projects/esp-vision/en/latest/esp32p4/device-core/cli.html) ([中文](https://docs.espressif.com/projects/esp-vision/zh_CN/latest/esp32p4/device-core/cli.html)).
42
42
 
43
43
  ```sh
44
- npm install -g @esp-vision/device-core@0.1.1
44
+ npm install -g @esp-vision/device-core@0.2.0
45
45
  esp-vision-device inspect
46
46
  esp-vision-device inspect --all-ports
47
47
  esp-vision-device inspect --interface usj,usb --probe --json
@@ -220,7 +220,7 @@ const image = await device.capture({ source: 'camera', format: 'jpeg', timeout:
220
220
 
221
221
  Paths are absolute device paths. `files.list({ path: '/', recursive: false })` returns entries `{ name, path, type, size }`. `files.read({ path })` returns a `Uint8Array`; use `encoding: 'utf-8'` to return text, and `maxBytes` to bound a read. Writes default to `overwrite: true`; set it to `false` to require a new destination. Uploading and executing are separate operations; create an empty file with `files.write({ path, data: new Uint8Array() })` rather than a second touch API. Remove an empty directory with `files.remove({ path: '/models', type: 'directory' })`; there is no implicit recursive deletion. `files.mkdir({ path, recursive: true })` creates missing parents.
222
222
 
223
- **Current firmware limits:** file upload still uses the existing `script.write` chunked RPC, including Base64/JSON overhead. This API cleanup does not implement the proposed high-throughput raw binary protocol or promise improved model-upload throughput. The filesystem must be mounted and allow writing, with room for the temporary upload and any existing destination file. File reads and directory operations currently use the REPL.
223
+ **Binary uploads (SDK 0.2.0):** text, model and image uploads share reliable raw binary `file.write` frames with 16 KiB blocks, continuous native VFS writes and SHA-256 commit. Matching firmware must advertise `file.write.binary`; missing support rejects with a firmware-upgrade message, without a slow upload fallback. File progress is not a durable commit. FAT replacement preserves an old-file backup until ordinary publication succeeds, with rollback on ordinary rename failure; it is not a power-loss-atomic journal. The filesystem must be mounted and allow writing, with room for the temporary upload and any existing destination file. File reads and directory operations currently use the REPL.
224
224
 
225
225
  `capture({ source: 'screen', format: 'jpeg' })` uses the dedicated `debug.capture_screen` RPC to encode the LCD's last submitted RGB565 framebuffer, including the display's scaling and letterboxing. It does not read the panel electronics or capture the camera. Camera capture uses `debug.capture_frame`. Both return binary JPEG responses correlated to the request; optional `quality` is an integer from 1 to 100. Missing firmware capabilities reject with `UNSUPPORTED_OPERATION`; an uninitialized display returns `DISPLAY_NOT_READY`, and a board without an LCD returns `NOT_SUPPORTED`. Preview is a separate real-time stream and may drop frames under congestion.
226
226
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@esp-vision/device-core",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Shared EV-MUX, framed REPL, device session, and action contracts for ESP Vision clients.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "title": "ESP Vision Device Actions",
4
- "deviceCoreVersion": "0.1.1",
4
+ "deviceCoreVersion": "0.2.0",
5
5
  "schemaVersion": 1,
6
6
  "actions": [
7
7
  {
@@ -169,7 +169,7 @@
169
169
  },
170
170
  {
171
171
  "name": "python.execute",
172
- "description": "Execute MicroPython through the shared interpreter using bounded REPL or chunked script transport.",
172
+ "description": "Execute MicroPython through the shared interpreter using bounded REPL or binary file staging.",
173
173
  "inputSchema": {
174
174
  "type": "object",
175
175
  "properties": {
@@ -1,6 +1,7 @@
1
1
  // SPDX-FileCopyrightText: 2026 ESP-VISION-IDE
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
  import assert from 'node:assert/strict'
4
+ import { verifyPublishedIntegrity } from './verify-registry.mjs'
4
5
  import { spawnSync } from 'node:child_process'
5
6
  import { mkdtemp, readFile, writeFile, rm } from 'node:fs/promises'
6
7
  import { tmpdir } from 'node:os'
@@ -48,7 +49,7 @@ try {
48
49
  console.log(`Npm publisher: ${publisher}`)
49
50
  const url = new URL(encodeURIComponent(manifest.name), registry)
50
51
  url.pathname += '/' + encodeURIComponent(manifest.version)
51
- const response = await fetch(url, { signal: AbortSignal.timeout(15000) })
52
+ const response = await fetch(url, { cache: 'no-store', headers: { 'Cache-Control': 'no-cache' }, signal: AbortSignal.timeout(15000) })
52
53
  if (response.ok) {
53
54
  const existing = await response.json()
54
55
  assert.equal(existing.dist?.integrity, packed.integrity,
@@ -57,8 +58,8 @@ try {
57
58
  } else {
58
59
  assert.equal(response.status, 404, 'Registry lookup failed; do not attempt publication')
59
60
  npm(['publish', join(temporary, packed.filename), '--ignore-scripts', '--access', 'public', '--registry', registry])
60
- const verified = JSON.parse(npm(['view', `${manifest.name}@${manifest.version}`, 'dist.integrity', '--json', '--registry', registry]))
61
- assert.equal(verified, packed.integrity, 'Published tarball integrity mismatch')
61
+ console.log('npm publish accepted; verifying registry metadata')
62
+ await verifyPublishedIntegrity(url, packed.integrity)
62
63
  console.log(`Published and verified ${packed.name}@${packed.version}`)
63
64
  }
64
65
  }
@@ -0,0 +1,36 @@
1
+ // SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import assert from 'node:assert/strict'
4
+
5
+ // Publication is one write. Only reads are retried while registry metadata propagates.
6
+ export async function verifyPublishedIntegrity(url, integrity, {
7
+ fetcher = globalThis.fetch,
8
+ wait = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds)),
9
+ report = message => console.log(message),
10
+ } = {}) {
11
+ const attempts = 6
12
+ for (let attempt = 0; attempt < attempts; attempt++) {
13
+ let response, transient
14
+ try {
15
+ response = await fetcher(url, { cache: 'no-store', headers: { 'Cache-Control': 'no-cache' }, signal: AbortSignal.timeout(15000) })
16
+ } catch (error) {
17
+ if (!(error instanceof TypeError) && !['TimeoutError', 'AbortError'].includes(error.name)) throw error
18
+ transient = 'network timeout or unavailable'
19
+ }
20
+ if (response?.ok) {
21
+ const metadata = await response.json()
22
+ assert.equal(metadata.dist?.integrity, integrity, 'Published tarball integrity mismatch')
23
+ return
24
+ }
25
+ if (response) {
26
+ const retryable = response.status === 404 || response.status === 429 || response.status >= 500 && response.status <= 599
27
+ await response.body?.cancel()
28
+ assert.ok(retryable, `Registry verification failed: HTTP ${response.status}`)
29
+ transient = `HTTP ${response.status}`
30
+ }
31
+ assert.ok(attempt + 1 < attempts, `Published metadata verification unavailable after ${attempts} reads (${transient}); retry the CI job, not publication manually`)
32
+ const delay = 1000 * 2 ** attempt
33
+ report(`Registry verification ${transient}; retrying read ${attempt + 2}/${attempts} in ${delay}ms`)
34
+ await wait(delay)
35
+ }
36
+ }
@@ -74,7 +74,7 @@ export const DEVICE_ACTION_DESCRIPTORS = Object.freeze([
74
74
  includeFiles: { type: 'boolean', default: true },
75
75
  logLimit: { type: 'integer', minimum: 0, maximum: 500, default: 100 },
76
76
  }), { readOnly: true, concurrency: 'shared-read' }),
77
- action('python.execute', 'Execute MicroPython through the shared interpreter using bounded REPL or chunked script transport.', objectSchema({
77
+ action('python.execute', 'Execute MicroPython through the shared interpreter using bounded REPL or binary file staging.', objectSchema({
78
78
  code: { type: 'string' },
79
79
  timeoutMs: { type: 'integer', minimum: 100, maximum: 120000, default: 8000 },
80
80
  }, ['code']), { destructive: true, openWorld: true }),
@@ -3,7 +3,6 @@
3
3
  * SPDX-License-Identifier: Apache-2.0
4
4
  */
5
5
 
6
- import { EV_REPL_FILE_CHUNK_BYTES } from './evrepl.js'
7
6
 
8
7
  export const EPHEMERAL_PYTHON_PATH = '/.espvision_agent_run.py'
9
8
 
@@ -306,17 +305,9 @@ export function bindSessionDeviceActions(registry, {
306
305
  registry.bind('deviceFile.write', async (input, { session, signal }) => {
307
306
  const path = validateDevicePath(input.path)
308
307
  const bytes = input.encoding === 'base64' ? base64ToBytes(input.content) : null
309
- if (bytes) {
310
- if (session.hasFeature('script.write')) {
311
- await session.writeFile(path, bytes, { encoding: 'binary', signal })
312
- } else {
313
- await session.repl.writeFile(path, bytes, EV_REPL_FILE_CHUNK_BYTES, false, { signal })
314
- }
315
- } else if (session.hasFeature('script.write')) {
316
- await session.writeScript(path, input.content, { runAfterWrite: input.runAfterWrite, signal })
317
- } else {
318
- await session.repl.writeFile(path, input.content, EV_REPL_FILE_CHUNK_BYTES, false, { signal })
319
- }
308
+ await session.writeFile(path, bytes ?? input.content, {
309
+ encoding: bytes ? 'binary' : 'utf-8', runAfterWrite: input.runAfterWrite, signal,
310
+ })
320
311
  return { ok: true, path, bytes: bytes?.length ?? new TextEncoder().encode(input.content).length }
321
312
  })
322
313
  registry.bind('deviceFile.remove', async (input, { session, signal }) => {
@@ -47,6 +47,7 @@ const EV_MUX_USER_RPC_METHODS = new Set([
47
47
  'capabilities',
48
48
  'session.keepalive',
49
49
  'session.release',
50
+ 'file.write',
50
51
  'script.write',
51
52
  'script.run',
52
53
  'device.control',
@@ -146,7 +147,7 @@ function isNetworkAction(method, payload) {
146
147
  }
147
148
 
148
149
  function isVmRpc(method, payload) {
149
- if (method === 'script.write' || method === 'script.run' || method === 'debug.capture_frame' || method === 'debug.capture_screen') return true
150
+ if (method === 'file.write' || method === 'script.write' || method === 'script.run' || method === 'debug.capture_frame' || method === 'debug.capture_screen') return true
150
151
  if (method === 'debug.info') return payload?.scope !== 'transport.stats'
151
152
  return false
152
153
  }
@@ -155,30 +156,6 @@ function payloadBytes(payload) {
155
156
  return payload instanceof Uint8Array ? payload : utf8Bytes(payload)
156
157
  }
157
158
 
158
- function maxScriptChunkBytes({ path, encoding, mode, runAfterWrite, rxPayloadMax, totalBytes }) {
159
- let low = 1
160
- let high = Math.max(1, Math.min(EV_MUX_SCRIPT_CHUNK_BYTES, rxPayloadMax))
161
- while (low < high) {
162
- const candidate = Math.ceil((low + high) / 2)
163
- const payload = {
164
- path, encoding, mode, offset: Math.max(0, totalBytes - 1), totalBytes,
165
- contentBase64: 'A'.repeat(Math.ceil(candidate / 3) * 4), runAfterWrite,
166
- }
167
- if (utf8Bytes(JSON.stringify(payload)).length <= rxPayloadMax) low = candidate
168
- else high = candidate - 1
169
- }
170
- return low
171
- }
172
-
173
- function bytesToBase64(bytes) {
174
- let binary = ''
175
- const chunkSize = 0x8000
176
- for (let i = 0; i < bytes.length; i += chunkSize) {
177
- binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize))
178
- }
179
- return btoa(binary)
180
- }
181
-
182
159
  function defaultSleep(ms) {
183
160
  return new Promise((resolve) => setTimeout(resolve, ms))
184
161
  }
@@ -262,6 +239,11 @@ export function validateEvmuxCapabilities(payload) {
262
239
  }
263
240
 
264
241
  for (const key of Object.keys(EV_MUX_DEFAULT_LIMITS)) validateLimit(payload.limits, key)
242
+ if (payload.features.includes('file.write.binary') && (!Number.isSafeInteger(payload.limits.fileWriteChunkMax)
243
+ || payload.limits.fileWriteChunkMax < 4096 || payload.limits.fileWriteChunkMax > 16384
244
+ || payload.limits.fileWriteChunkMax % 4096 !== 0)) {
245
+ throw new Error('EV-MUX v4 capabilities are missing a valid limits.fileWriteChunkMax')
246
+ }
265
247
  if (payload.session?.policy !== 'firstCome'
266
248
  || !Number.isSafeInteger(payload.session.leaseMs)
267
249
  || !Number.isSafeInteger(payload.session.keepaliveMs)
@@ -622,6 +604,7 @@ export class DeviceSession {
622
604
  redactSsid = true,
623
605
  forceNetwork = false,
624
606
  } = {}) {
607
+ if (payload instanceof Uint8Array) return { binaryBytes: payload.byteLength }
625
608
  if (!forceNetwork && !isNetworkAction(method, payload) && !isNetworkAction(method, requestPayload)) return payload
626
609
  const secrets = [requestPayload?.ssid, requestPayload?.password]
627
610
  .filter((value) => typeof value === 'string' && value.length > 0)
@@ -687,6 +670,7 @@ export class DeviceSession {
687
670
  seq: requestedSeq = null,
688
671
  signal = null,
689
672
  writeTimeout = null,
673
+ encoding = undefined,
690
674
  } = {}) {
691
675
  if (this.state === 'disconnected') {
692
676
  throw new Error('EV-MUX is not connected')
@@ -715,7 +699,9 @@ export class DeviceSession {
715
699
  && (payloadData.length !== 1 || payloadData[0] !== 0x03)) {
716
700
  throw new Error('EV-MUX repl.signal only accepts the single-byte Ctrl-C signal 0x03')
717
701
  }
718
- if (this.capabilities && payloadData.length > this.limits.rxPayloadMax) {
702
+ const payloadLimit = method === 'file.write' && encoding === 'binary' && this.hasFeature('file.write.binary')
703
+ ? this.limits.fileWriteChunkMax + 20 : this.limits.rxPayloadMax
704
+ if (this.capabilities && payloadData.length > payloadLimit) {
719
705
  throw new Error(`EV-MUX payload is ${payloadData.length} bytes; device limit is ${this.limits.rxPayloadMax}`)
720
706
  }
721
707
  if ((channel === 'repl.stdin' || channel === 'repl.signal') && payloadData.length > this.limits.replInputMax) {
@@ -727,7 +713,7 @@ export class DeviceSession {
727
713
  seq,
728
714
  channel,
729
715
  type: frameType,
730
- encoding: channel.endsWith('.rpc') ? 'json' : 'text',
716
+ encoding: encoding ?? (channel.endsWith('.rpc') ? 'json' : 'text'),
731
717
  ...(method ? { method } : {}),
732
718
  ...(needsAuthorization ? { sessionId: this.sessionId } : {}),
733
719
  ...(usesVm && needsAuthorization ? { vmEpoch: this.vmEpoch } : {}),
@@ -738,6 +724,7 @@ export class DeviceSession {
738
724
  const frame = this.makeEvMuxFrame({
739
725
  channel,
740
726
  type: frameType,
727
+ encoding,
741
728
  method,
742
729
  payload,
743
730
  seq,
@@ -764,6 +751,7 @@ export class DeviceSession {
764
751
  throw new Error('EV-MUX is not connected')
765
752
  }
766
753
  if (signal?.aborted) throw abortError(signal)
754
+ if (payload instanceof Uint8Array && method !== 'file.write') throw new TypeError('Raw binary requests are only supported for file.write')
767
755
  if (method === 'script.write' || method === 'script.run') validateRpcPath(payload?.path)
768
756
  if (method === 'debug.info' && (payload?.scope === 'fs.list' || payload?.scope === 'fs.read')) {
769
757
  validateRpcPath(payload.path ?? (payload.scope === 'fs.list' ? '/' : undefined))
@@ -816,7 +804,8 @@ export class DeviceSession {
816
804
  this.handlers.rpcRequest?.({ channel: rpcChannel, method, seq, timeout, payload: safePayload })
817
805
  this._diagnostic('rpc.request', { channel: rpcChannel, method, seq })
818
806
  try {
819
- await this.writeFrame(rpcChannel, 'req', method, JSON.stringify(payload), {
807
+ await this.writeFrame(rpcChannel, 'req', method, payload instanceof Uint8Array ? payload : JSON.stringify(payload), {
808
+ encoding: payload instanceof Uint8Array ? 'binary' : undefined,
820
809
  vmRequest,
821
810
  seq,
822
811
  signal,
@@ -1089,41 +1078,45 @@ export class DeviceSession {
1089
1078
  : content instanceof Uint8Array
1090
1079
  ? content
1091
1080
  : new Uint8Array(content)
1092
- const chunkBytes = maxScriptChunkBytes({
1093
- path, encoding, mode, runAfterWrite, rxPayloadMax: this.limits.rxPayloadMax, totalBytes: bytes.length,
1094
- })
1095
- if (this.hasFeature('script.write.chunked') && bytes.length > chunkBytes) {
1096
- let lastResponse = null
1097
- for (let offset = 0; offset < bytes.length; offset += chunkBytes) {
1081
+ if (!this.hasFeature('file.write.binary')) {
1082
+ throw Object.assign(new Error('File upload requires firmware with file.write.binary; update the device firmware'), { code: 'UNSUPPORTED_OPERATION' })
1083
+ }
1084
+ const limit = this.limits.fileWriteChunkMax
1085
+ if (!Number.isSafeInteger(limit) || limit < 4096 || limit > 16384 || limit % 4096 !== 0 || bytes.length > 0xffffffff) {
1086
+ throw new Error('Invalid negotiated binary file transfer limit or file size')
1087
+ }
1088
+ const digest = new Uint8Array(await globalThis.crypto.subtle.digest('SHA-256', bytes))
1089
+ if (signal?.aborted) throw abortError(signal)
1090
+ const sha256 = Array.from(digest, byte => byte.toString(16).padStart(2, '0')).join('')
1091
+ const uploadId = randomHex()
1092
+ let committed = false
1093
+ try {
1094
+ const begin = await this.rpc('file.write', { action: 'begin', path, mode, totalBytes: bytes.length, sha256, uploadId }, { timeout: 8000, signal })
1095
+ if (begin.uploadId !== uploadId || begin.chunkBytes !== limit) throw new Error('Invalid binary file transfer acknowledgement')
1096
+ const token = Uint8Array.from(begin.uploadId.match(/../g), byte => parseInt(byte, 16))
1097
+ for (let offset = 0; offset < bytes.length; offset += limit) {
1098
1098
  if (signal?.aborted) throw abortError(signal)
1099
- const chunk = bytes.subarray(offset, Math.min(offset + chunkBytes, bytes.length))
1100
- lastResponse = await this.rpc('script.write', {
1101
- path,
1102
- encoding,
1103
- mode,
1104
- offset,
1105
- totalBytes: bytes.length,
1106
- contentBase64: bytesToBase64(chunk),
1107
- runAfterWrite: runAfterWrite && offset + chunk.length >= bytes.length,
1108
- }, { timeout: 8000, signal })
1099
+ const chunk = bytes.subarray(offset, Math.min(offset + limit, bytes.length))
1100
+ const payload = new Uint8Array(20 + chunk.length)
1101
+ payload.set(token)
1102
+ new DataView(payload.buffer).setUint32(16, offset, true)
1103
+ payload.set(chunk, 20)
1104
+ const response = await this.rpc('file.write', payload, { timeout: 8000, signal })
1105
+ if (response.writtenBytes !== offset + chunk.length) throw new Error('Incomplete binary file transfer acknowledgement')
1109
1106
  onProgress?.({ writtenBytes: offset + chunk.length, totalBytes: bytes.length })
1110
1107
  }
1111
- return lastResponse
1112
- }
1113
-
1114
- const payload = {
1115
- path,
1116
- encoding,
1117
- mode,
1118
- contentBase64: bytesToBase64(bytes),
1119
- runAfterWrite,
1120
- }
1121
- if (utf8Bytes(JSON.stringify(payload)).length > this.limits.rxPayloadMax) {
1122
- throw new Error(`script.write payload exceeds the device RX limit ${this.limits.rxPayloadMax}; firmware must support script.write.chunked.`)
1108
+ const result = await this.rpc('file.write', { action: 'commit', uploadId: begin.uploadId }, { timeout: 8000, signal })
1109
+ if (result.complete !== true || result.bytes !== bytes.length) throw new Error('Incomplete binary file commit acknowledgement')
1110
+ committed = true
1111
+ if (bytes.length === 0) onProgress?.({ writtenBytes: 0, totalBytes: 0 })
1112
+ if (runAfterWrite) await this.rpc('script.run', { path }, { timeout: 8000, signal })
1113
+ return { ...result, path, runAfterWrite }
1114
+ } finally {
1115
+ if (!committed) {
1116
+ // Cancellation does not cancel cleanup, and cleanup cannot mask the primary error.
1117
+ await this.rpc('file.write', { action: 'abort', uploadId }, { timeout: 1500 }).catch(() => {})
1118
+ }
1123
1119
  }
1124
- const response = await this.rpc('script.write', payload, { timeout: 8000, signal })
1125
- onProgress?.({ writtenBytes: bytes.length, totalBytes: bytes.length })
1126
- return response
1127
1120
  }
1128
1121
 
1129
1122
  async writeScript(path, content, { runAfterWrite = false, signal = null } = {}) {
@@ -1217,10 +1210,10 @@ export class DeviceSession {
1217
1210
  if (commandBytes <= inputLimit) {
1218
1211
  return this.repl.execPy(source, { timeout, signal })
1219
1212
  }
1220
- if (!this.hasFeature('script.write') || !this.hasFeature('script.run')) {
1213
+ if (!this.hasFeature('file.write.binary') || !this.hasFeature('script.run')) {
1221
1214
  throw new Error(
1222
1215
  `Python source requires ${commandBytes} REPL bytes, above the firmware limit ${inputLimit}; ` +
1223
- 'upgrade firmware with script.write and script.run support.',
1216
+ 'upgrade firmware with file.write.binary and script.run support.',
1224
1217
  )
1225
1218
  }
1226
1219
 
@@ -1365,8 +1358,8 @@ export class DeviceSession {
1365
1358
  if (timeout !== null && (!Number.isFinite(timeout) || timeout <= 0)) {
1366
1359
  throw new RangeError('Script timeout must be a positive number or null for an interactive run')
1367
1360
  }
1368
- if ((content !== undefined && !this.hasFeature('script.write')) || !this.hasFeature('script.run')) {
1369
- throw new Error('Running scripts requires script.run and uploading source also requires script.write')
1361
+ if ((content !== undefined && !this.hasFeature('file.write.binary')) || !this.hasFeature('script.run')) {
1362
+ throw new Error('Running scripts requires script.run and uploading source also requires file.write.binary')
1370
1363
  }
1371
1364
  if (this.runCapture || this.runStarting) {
1372
1365
  throw new Error('A script is already running')
package/src/evmux.js CHANGED
@@ -389,14 +389,14 @@ export class EvMuxParser {
389
389
  }
390
390
  }
391
391
 
392
- export function makeEvMuxFrame({ channel, type = 'data', method = null, payload = '', seq = 0, sid = 'web-ide', sessionId = undefined, vmEpoch = undefined, contentType = undefined, width = undefined, height = undefined, route = undefined }) {
392
+ export function makeEvMuxFrame({ channel, type = 'data', method = null, payload = '', seq = 0, sid = 'web-ide', sessionId = undefined, vmEpoch = undefined, contentType = undefined, width = undefined, height = undefined, route = undefined, encoding = undefined }) {
393
393
  const payloadBytes = payload instanceof Uint8Array ? payload : new TextEncoder().encode(payload)
394
394
  const metadata = {
395
395
  sid,
396
396
  seq,
397
397
  channel,
398
398
  type,
399
- encoding: channel.endsWith('.rpc') ? 'json' : 'text',
399
+ encoding: encoding ?? (channel.endsWith('.rpc') ? 'json' : 'text'),
400
400
  ...(sessionId ? { sessionId } : {}),
401
401
  ...(vmEpoch !== undefined ? { vmEpoch } : {}),
402
402
  ...(contentType ? { contentType } : {}),
@@ -408,7 +408,8 @@ export function makeEvMuxFrame({ channel, type = 'data', method = null, payload
408
408
  metadata.method = method
409
409
  }
410
410
  const metadataBytes = new TextEncoder().encode(JSON.stringify(metadata))
411
- const header = new TextEncoder().encode(`\x1eEVMUX/1 h=${metadataBytes.length} p=${payloadBytes.length} c=00000000\r\n`)
411
+ const checksum = encoding === 'binary' ? crc32(payloadBytes).toString(16).padStart(8, '0') : '00000000'
412
+ const header = new TextEncoder().encode(`\x1eEVMUX/1 h=${metadataBytes.length} p=${payloadBytes.length} c=${checksum}\r\n`)
412
413
  const out = new Uint8Array(header.length + metadataBytes.length + payloadBytes.length + 1)
413
414
  out.set(header, 0)
414
415
  out.set(metadataBytes, header.length)
@@ -13,6 +13,7 @@ function executionFixture({ short = false, failWrite = false, failStop = false,
13
13
  let session
14
14
  let resetRequested = false
15
15
  let resetPolls = 0
16
+ let uploaded = 0
16
17
  const stdout = (payload) => session.feedBytes(makeEvMuxFrame({
17
18
  channel: 'repl.stdout', type: 'data', sessionId: session.sessionId, vmEpoch: session.vmEpoch, payload,
18
19
  }))
@@ -26,7 +27,7 @@ function executionFixture({ short = false, failWrite = false, failStop = false,
26
27
  const parser = new EvMuxParser({
27
28
  filterFrame: (metadata, bytes) => {
28
29
  const text = new TextDecoder().decode(bytes)
29
- const payload = metadata.channel.endsWith('.rpc') ? JSON.parse(text) : text
30
+ const payload = metadata.encoding === 'binary' ? bytes : metadata.channel.endsWith('.rpc') ? JSON.parse(text) : text
30
31
  requests.push({ metadata, payload })
31
32
  if (metadata.channel === 'repl.signal') return
32
33
  if (metadata.channel === 'repl.stdin') {
@@ -51,7 +52,15 @@ function executionFixture({ short = false, failWrite = false, failStop = false,
51
52
  response = { ok: true, sessionId: session.sessionId, vmEpoch: responseEpoch,
52
53
  vmReady: stalledReset || resetPolls === 1 || resetPolls >= 3 }
53
54
  }
54
- if (metadata.method === 'script.write' && failWrite) {
55
+ if (metadata.method === 'file.write') {
56
+ if (payload instanceof Uint8Array) {
57
+ uploaded = new DataView(payload.buffer, payload.byteOffset, payload.byteLength).getUint32(16, true) + payload.length - 20
58
+ response = { ok: true, writtenBytes: uploaded }
59
+ } else if (payload.action === 'begin') {
60
+ uploaded = 0; response = { ok: true, uploadId: payload.uploadId, chunkBytes: 16384 }
61
+ } else response = { ok: true, bytes: uploaded, complete: payload.action === 'commit' }
62
+ }
63
+ if (metadata.method === 'file.write' && failWrite) {
55
64
  response = { ok: false, error: { code: 'WRITE_FAILED', message: 'simulated write failure' } }
56
65
  } else if (metadata.method === 'script.run') {
57
66
  running = true
@@ -75,7 +84,8 @@ function executionFixture({ short = false, failWrite = false, failStop = false,
75
84
  session.sessionId = 'a'.repeat(32)
76
85
  session.vmEpoch = 1
77
86
  session.vmReady = true
78
- session.capabilities = { features: ['script.write', 'script.run', 'device.control'] }
87
+ session.limits.fileWriteChunkMax = 16384
88
+ session.capabilities = { features: ['file.write.binary', 'script.run', 'device.control'] }
79
89
  return { session, requests, finish, get terminal() { return terminal }, get running() { return running } }
80
90
  }
81
91
 
@@ -0,0 +1,112 @@
1
+ // SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import assert from 'node:assert/strict'
4
+ import { test } from 'node:test'
5
+ import { DeviceSession } from '../src/device_session.js'
6
+ import { EvMuxParser, makeEvMuxFrame } from '../src/evmux.js'
7
+ import { EvRepl } from '../src/evrepl.js'
8
+
9
+ function fixture(options = {}) {
10
+ const requests = [], chunks = []; let session, began, finished
11
+ let token
12
+ const peer = new EvMuxParser({ filterFrame(metadata, bytes) {
13
+ const payload = metadata.encoding === 'binary' ? bytes.slice() : JSON.parse(new TextDecoder().decode(bytes))
14
+ requests.push({ metadata, payload })
15
+ let reply = { ok: true }
16
+ if (payload instanceof Uint8Array) {
17
+ assert.equal(metadata.method, 'file.write'); assert.equal(metadata.encoding, 'binary')
18
+ assert.equal(metadata.vmEpoch, 7); assert.equal(metadata.sessionId, 'a'.repeat(32))
19
+ assert.equal(Buffer.from(payload.slice(0, 16)).toString('hex'), token)
20
+ const offset = new DataView(payload.buffer).getUint32(16, true)
21
+ assert.equal(offset, chunks.reduce((sum, chunk) => sum + chunk.length, 0))
22
+ chunks.push(payload.slice(20)); reply.writtenBytes = offset + payload.length - 20
23
+ if (options.badAck) reply.writtenBytes--
24
+ if (options.abort) options.abort.abort()
25
+ } else if (payload.action === 'begin') {
26
+ began = payload; token = payload.uploadId; reply.uploadId = token; reply.chunkBytes = 16384
27
+ if (options.abortBegin) options.abortBegin.abort()
28
+ if (options.failBegin) { options.failBegin = false; throw new Error('begin response lost') }
29
+ } else if (payload.action === 'commit') {
30
+ finished = true; reply.bytes = chunks.reduce((sum, chunk) => sum + chunk.length, 0); reply.complete = true
31
+ if (options.badCommit) reply.bytes++
32
+ if (options.failCommit) reply = { ok: false, error: { code: 'HASH_MISMATCH' } }
33
+ }
34
+ session.feedBytes(makeEvMuxFrame({ channel: 'user.rpc', type: 'rsp', method: metadata.method, seq: metadata.seq,
35
+ sessionId: 'a'.repeat(32), vmEpoch: 7, payload: JSON.stringify(reply) }))
36
+ return false
37
+ } })
38
+ session = new DeviceSession({ transport: { async writeBytes(bytes) { peer.feedBytes(bytes) } }, EvMuxParser, makeEvMuxFrame, EvRepl })
39
+ Object.assign(session, { state: 'ready', sessionId: 'a'.repeat(32), vmEpoch: 7, vmReady: true,
40
+ capabilities: { features: ['file.write.binary', 'script.run'] } })
41
+ session.limits.fileWriteChunkMax = 16384
42
+ return { session, requests, chunks, get began() { return began }, get finished() { return finished } }
43
+ }
44
+ for (const size of [0, 1, 4096, 16384, 16385, 2 * 1024 * 1024]) {
45
+ test(`binary upload ${size} bytes is bounded, exact, hashed and committed`, async () => {
46
+ const f = fixture(), data = Uint8Array.from({ length: size }, (_, i) => i & 255), progress = []
47
+ try {
48
+ const result = await f.session.writeFile('/model.bin', data, { encoding: 'binary', onProgress: value => progress.push(value.writtenBytes) })
49
+ assert.equal(result.complete, true); assert.equal(result.bytes, size)
50
+ assert.deepEqual(Buffer.concat(f.chunks), Buffer.from(data)); assert.equal(f.finished, true)
51
+ assert.equal(f.began.sha256, Buffer.from(await crypto.subtle.digest('SHA-256', data)).toString('hex'))
52
+ assert.ok(f.chunks.every(chunk => chunk.length <= 16384)); assert.equal(f.chunks.length, Math.ceil(size / 16384))
53
+ assert.equal(progress.at(-1), size)
54
+ assert.ok(!f.requests.some(request => request.metadata.method === 'script.write'))
55
+ } finally { f.session.close() }
56
+ })
57
+ }
58
+ test('Python upload uses the same byte engine and requests execution only after commit', async () => {
59
+ const f = fixture()
60
+ try {
61
+ await f.session.writeScript('/script.py', "print('中文')", { runAfterWrite: true })
62
+ assert.deepEqual(Buffer.concat(f.chunks), Buffer.from("print('中文')"))
63
+ assert.equal(f.requests.at(-1).metadata.method, 'script.run')
64
+ assert.equal(f.requests.at(-2).payload.action, 'commit')
65
+ } finally { f.session.close() }
66
+ })
67
+ for (const setting of ['badAck', 'badCommit', 'failCommit']) {
68
+ test(`${setting} triggers abort cleanup without publishing success`, async () => {
69
+ const f = fixture({ [setting]: true })
70
+ try {
71
+ await assert.rejects(f.session.writeFile('/x.bin', new Uint8Array(100)))
72
+ assert.equal(f.requests.at(-1).payload.action, 'abort')
73
+ } finally { f.session.close() }
74
+ })
75
+ }
76
+ test('cancellation after begin still sends an independent abort', async () => {
77
+ const controller = new AbortController(), f = fixture({ abort: controller })
78
+ try {
79
+ await assert.rejects(f.session.writeFile('/x.bin', new Uint8Array(32768), { signal: controller.signal }), { name: 'AbortError' })
80
+ assert.equal(f.requests.at(-1).payload.action, 'abort')
81
+ } finally { f.session.close() }
82
+ })
83
+ test('missing binary capability rejects without falling back to script.write', async () => {
84
+ const f = fixture(); f.session.capabilities.features = ['script.write']
85
+ try { await assert.rejects(f.session.writeFile('/x.bin', new Uint8Array(10)), { code: 'UNSUPPORTED_OPERATION' }); assert.equal(f.requests.length, 0) }
86
+ finally { f.session.close() }
87
+ })
88
+ test('corrupted binary frames fail CRC before dispatch', () => {
89
+ let calls = 0
90
+ const parser = new EvMuxParser({ filterFrame() { calls++; return false } })
91
+ const bytes = makeEvMuxFrame({ channel: 'user.rpc', type: 'req', method: 'file.write', encoding: 'binary', payload: new Uint8Array([0, 1, 0x1e, 0x1f, 255]) })
92
+ parser.feedBytes(bytes); assert.equal(calls, 1)
93
+ bytes[bytes.length - 2] ^= 1; parser.feedBytes(bytes); assert.equal(calls, 1)
94
+ })
95
+
96
+ test('cancellation during begin still aborts the client-known upload ID', async () => {
97
+ const controller = new AbortController(), f = fixture({ abortBegin: controller })
98
+ try {
99
+ await assert.rejects(f.session.writeFile('/x.bin', new Uint8Array(100), { signal: controller.signal }), { name: 'AbortError' })
100
+ assert.equal(f.requests.at(-1).payload.action, 'abort')
101
+ assert.equal(f.requests.at(-1).payload.uploadId, f.began.uploadId)
102
+ assert.equal(f.chunks.length, 0)
103
+ } finally { f.session.close() }
104
+ })
105
+ test('a lost begin response still allows abort without knowing a server-generated ID', async () => {
106
+ const f = fixture({ failBegin: true })
107
+ try {
108
+ await assert.rejects(f.session.writeFile('/x.bin', new Uint8Array(100)), /begin response lost/)
109
+ assert.equal(f.requests.at(-1).payload.action, 'abort')
110
+ assert.equal(f.requests.at(-1).payload.uploadId, f.began.uploadId)
111
+ } finally { f.session.close() }
112
+ })
@@ -0,0 +1,58 @@
1
+ // SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import assert from 'node:assert/strict'
4
+ import { test } from 'node:test'
5
+ import { verifyPublishedIntegrity } from '../scripts/verify-registry.mjs'
6
+
7
+ const integrity = 'sha512-test-content'
8
+ const url = 'https://registry.npmjs.org/%40esp-vision%2Fdevice-core/0.2.0'
9
+ const published = value => new Response(JSON.stringify({ dist: { integrity: value } }))
10
+ function fixture(replies) {
11
+ const calls = [], waits = [], reports = []
12
+ return { calls, waits, reports, options: {
13
+ async fetcher(address, options) {
14
+ calls.push({ address, options })
15
+ const reply = replies.shift()
16
+ if (reply instanceof Error) throw reply
17
+ assert(reply, 'Unexpected verification request')
18
+ return reply
19
+ },
20
+ wait: async milliseconds => { waits.push(milliseconds) },
21
+ report: message => reports.push(message),
22
+ } }
23
+ }
24
+ test('new npm version becomes visible after stale 404 metadata; only uncached reads repeat', async () => {
25
+ const f = fixture([new Response('', { status: 404 }), published(integrity)])
26
+ await verifyPublishedIntegrity(url, integrity, f.options)
27
+ assert.equal(f.calls.length, 2); assert.deepEqual(f.waits, [1000])
28
+ for (const call of f.calls) {
29
+ assert.equal(call.address, url); assert.equal(call.options.cache, 'no-store')
30
+ assert.equal(call.options.headers['Cache-Control'], 'no-cache')
31
+ assert.equal(call.options.method ?? 'GET', 'GET')
32
+ }
33
+ })
34
+ test('verification retries rate limits, temporary registry errors and network failures', async () => {
35
+ const f = fixture([new Response('', { status: 429 }), new Response('', { status: 503 }), new TypeError('network unavailable'), published(integrity)])
36
+ await verifyPublishedIntegrity(url, integrity, f.options)
37
+ assert.deepEqual(f.waits, [1000, 2000, 4000])
38
+ })
39
+ for (const status of [400, 401, 403]) test(`registry HTTP ${status} fails without retries`, async () => {
40
+ const f = fixture([new Response('', { status })])
41
+ await assert.rejects(verifyPublishedIntegrity(url, integrity, f.options), new RegExp(`HTTP ${status}`))
42
+ assert.equal(f.calls.length, 1); assert.deepEqual(f.waits, [])
43
+ })
44
+ test('published integrity mismatch fails immediately', async () => {
45
+ const f = fixture([published('sha512-other-content')])
46
+ await assert.rejects(verifyPublishedIntegrity(url, integrity, f.options), /integrity mismatch/)
47
+ assert.equal(f.calls.length, 1); assert.deepEqual(f.waits, [])
48
+ })
49
+ test('missing published integrity is not accepted', async () => {
50
+ const f = fixture([new Response('{}')])
51
+ await assert.rejects(verifyPublishedIntegrity(url, integrity, f.options), /integrity mismatch/)
52
+ assert.deepEqual(f.waits, [])
53
+ })
54
+ test('persistent propagation failures stop after six reads with bounded backoff', async () => {
55
+ const f = fixture(Array.from({ length: 6 }, () => new Response('', { status: 404 })))
56
+ await assert.rejects(verifyPublishedIntegrity(url, integrity, f.options), /after 6 reads/)
57
+ assert.equal(f.calls.length, 6); assert.deepEqual(f.waits, [1000, 2000, 4000, 8000, 16000])
58
+ })