@esp-vision/device-core 0.4.1 → 0.5.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,6 +1,29 @@
1
1
  # Changelog
2
2
 
3
- ## 0.4.1 (unreleased)
3
+ ## [Unreleased]
4
+
5
+ ### Changed
6
+
7
+ - Move the host management CLI to the standalone `@esp-vision/device-cli` package and keep passive Node inventory helpers available from `@esp-vision/device-core/node`.
8
+
9
+ ### Fixed
10
+
11
+ - Detach started Python runs from connection-lifetime cancellation after VM entry, so normal disconnect releases ownership without sending a remote stop; retain caller AbortSignal cancellation.
12
+
13
+ - Wait for a private VM-entry sentinel before resolving detached Python startup, with bounded startup waits and session-isolated output.
14
+
15
+ - Add validated `connect({ probeRepl: false })` for non-interrupting native RPC access and use it for metadata inventory probes; retain the existing active connection default.
16
+
17
+ - Make aggregate deadlines optional by default for completed Python runs and file reads while retaining bounded REPL exchanges and per-chunk file-read limits.
18
+ - Keep wait-mode small-source execution on the REPL until its completion sentinel arrives, without imposing a fixed program-completion timeout or requiring device storage.
19
+
20
+ ## 0.4.2
21
+
22
+ ### Fixed
23
+
24
+ - Route browser ROM reset calls through the active flash stub after upload, preserving its two-byte status decoder during P4 watchdog register writes; report reset failure diagnostics without invalidating successful firmware verification.
25
+
26
+ ## 0.4.1
4
27
 
5
28
  ### Fixed
6
29
 
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
- This source prepares **0.4.0 (not published by this change)** with browser ROM flashing, immutable release-manifest readers and application updates from a single complete BIN. npm 0.3.0 does not contain these APIs. The `/node` byte transports and bundled checks require 0.1.1; complete installation and `/node` `openNodeRom` are introduced in 0.3.0. Check the registry before using the 0.4.0 install examples; until publication, use a tarball packed from an exact reviewed source commit. 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 latest public npm release is **0.4.2**. This source prepares **0.5.0 (not yet published)**: Core becomes an SDK-only package, the management CLI moves to `@esp-vision/device-cli`, and Python execution/file reads have no aggregate deadline unless configured. Pin the exact package version and verify it exists in the public registry before installing. Until publication, use a tarball packed from an exact reviewed source commit. 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.4.0 --registry=https://registry.npmjs.org/
25
+ npm install --save-exact @esp-vision/device-core@0.5.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.
@@ -38,10 +38,10 @@ Documentation source lives in this repository under `docs/en/device-core/` and `
38
38
 
39
39
  ## Management CLI
40
40
 
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)).
41
+ Starting with 0.5.0, Device Core is a library and does not install a management command. Existing 0.4.x installations retain their command until explicitly upgraded or removed. Install the independent `@esp-vision/device-cli` package when you need the device toolkit. It uses the public Core APIs and is installed explicitly; Device Core does not fetch or wrap the CLI. 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.4.0
44
+ npm install -g @esp-vision/device-cli
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
@@ -110,7 +110,9 @@ try {
110
110
  }
111
111
  ```
112
112
 
113
- ### Immutable release snapshots and a single firmware BIN (0.4.0, unpublished)
113
+ `connect()` sends Ctrl-C and a short REPL liveness probe by default for compatibility. Pass `probeRepl: false` when a detached application may be running: Core still acquires the EV-MUX session and reads identity/capabilities, but leaves the REPL untouched. In this mode a reported `vmReady: false` stays false; Python operations that require a ready VM fail with a readiness error.
114
+
115
+ ### Immutable release snapshots and a single firmware BIN
114
116
 
115
117
  The `/firmware` entry exports `loadOfficialFirmware({ board?, version?, fetch?, signal? })` and `loadReleaseManifest({ url, sha256, version?, sourceCommit?, boards?, fetch?, signal? })`. The official reader prioritizes `[publication]` in `config.toml`, selects exactly one version reference and verifies its raw manifest SHA-256 before returning `{ schemaVersion: 1, version, sourceCommit, pipelineId, layout, releases, website, mcp?, publication }`. History references include exact board sets; unsupported board/version combinations and malformed explicit publication data reject without falling back to guessed URLs or old metadata. Configs without `[publication]` keep the legacy feed; an explicit version limits old metadata requests too.
116
118
 
@@ -128,11 +130,11 @@ const release = snapshot.releases.find(entry => entry.board === selectedBoard);
128
130
 
129
131
  In `layout: 'single-bin'`, `image.source = { url, bytes, sha256, offset }` describes the complete BIN; `image.url` equals its URL, while `image.bytes/sha256` still describe only the application. The source URL/length/hash must match `install`, and its offset must equal the application's flash offset. Core downloads the full container once (no Range request), validates its whole hash and embedded bootloader/partition/application hashes, then extracts and writes only the application in default update mode. Existing partition checks and full application readback remain mandatory; `mode: 'install'` writes the qualified complete BIN. With `image.source`, supplied `data` must also be the complete BIN; old application-only metadata keeps its existing raw-app `data` semantics. Manifests are bounded at 4 MiB, firmware downloads at 256 MiB and their approved exact size. Official manifest/artifact sources are confined to the ESP-VISION CDN root; model URLs use their full source commit and MCP has a version/commit/hash descriptor. No npm, firmware tag or CDN publication is performed by preparing this source version.
130
132
 
131
- To validate before npm publication, check out an exact reviewed commit, run `npm install --ignore-scripts --no-audit --no-fund --package-lock=false` and `npm run release:check` in `host/device-core`, then `npm pack --ignore-scripts --pack-destination <project-local-output>`. Install that locally produced tarball or use a source archive pinned to that exact commit. Do not claim that an unpublished 0.4.0 is available in the registry.
133
+ To validate before npm publication, check out an exact reviewed commit, run `npm install --ignore-scripts --no-audit --no-fund --package-lock=false` and `npm run release:check` in `host/device-core`, then `npm pack --ignore-scripts --pack-destination <project-local-output>`. Install that locally produced tarball or use a source archive pinned to that exact commit.
132
134
 
133
- ### Browser ROM flashing (unreleased)
135
+ ### Browser ROM flashing
134
136
 
135
- `@esp-vision/device-core/browser` exports `openBrowserRom({ port, logger?, signal? })`. Build from this branch until a new SDK release is published; installing 0.3.0 does not enable this API. The constructor is passive, and `initialize()` lazily loads the pinned `tasmota-webserial-esptool` browser backend. Use a browser bundler such as webpack, Vite or esbuild; direct native ESM loading of the upstream backend is not supported. No Python installation or consumer-side tasmota patch is needed. The root and `/node` entry points do not load browser modules.
137
+ `@esp-vision/device-core/browser` exports `openBrowserRom({ port, logger?, signal? })`. The constructor is passive, and `initialize()` lazily loads the pinned `tasmota-webserial-esptool` browser backend. Use a browser bundler such as webpack, Vite or esbuild; direct native ESM loading of the upstream backend is not supported. No Python installation or consumer-side tasmota patch is needed. The root and `/node` entry points do not load browser modules.
136
138
 
137
139
  ```js
138
140
  import { createDevice } from '@esp-vision/device-core';
@@ -153,6 +155,8 @@ Core owns downloads, chip/revision/security and partition/capacity checks, write
153
155
 
154
156
  ### Official Node adapters
155
157
 
158
+ The public Node entry also exports `inventory`, `listSerial`, `filterSerialPorts`, `serialEntry`, `transportFor` and `defaultPython`. Enumeration is passive unless `inventory({ probe: true })` is explicitly requested; cancellation is supported. Bundlers obtain the exact published Python adapters from `nodeAdapterAssets()`, which returns `{ serial: URL, rom: URL }` without opening a port or running Python.
159
+
156
160
  The Node-only `@esp-vision/device-core/node` subpath exports `tcpTransport({ host, port })` and `serialTransport({ serial, python })`. Constructors are passive; Core opens/closes the link during `connect()`/`disconnect()`. Serial needs pyserial in the selected Python interpreter, and uses role `usj`, `usb` or `uart` for the actual interface. The root/browser entry points do not load Node networking/process modules.
157
161
 
158
162
  ```js
@@ -238,7 +242,11 @@ await device.python.execute({ path: '/main.py', completion: 'start' });
238
242
  unsubscribe();
239
243
  ```
240
244
 
241
- `source` and `path` are mutually exclusive. `completion: 'wait'` (the default) returns `{ ok, path, output, outputTruncated }` after the program finishes. `'start'` returns `{ ok, path, done }` after starting it; await `done` for the execution result. Source started this way uses an internal temporary script that Core removes after execution. Terminal input, scripts and filesystem helpers share one MicroPython interpreter. The application should not send terminal input while an automated operation owns the REPL. Conflicting automated operations reject with `DEVICE_BUSY`; Core does not silently queue them or isolate user Python in a second interpreter. Terminal input remains available to an explicitly started interactive script.
245
+ `source` and `path` are mutually exclusive. `completion: 'wait'` (the default) returns `{ ok, path, output, outputTruncated }` after the program finishes. `'start'` returns `{ ok, path, done }` after Core receives a private VM-entry marker from the generated runner; it does not wait for the program to finish, so a short script may already be done when the handle is returned. Core waits at most five seconds for this start acknowledgement. If it times out or is cancelled, the program may have entered and Core does not claim that it stopped. Await `done` for the execution result. Once the start handle is returned, `device.disconnect()` releases the session without requesting a stop; `done` rejects locally as the session closes, and the device program may continue. A caller-provided `signal` remains bound until `done` settles and requests a stop if aborted while the session is available. Source started this way uses an internal temporary script that Core removes after execution; disconnect can prevent this cleanup. Terminal input, scripts and filesystem helpers share one MicroPython interpreter. The application should not send terminal input while an automated operation owns the REPL. Conflicting automated operations reject with `DEVICE_BUSY`; Core does not silently queue them or isolate user Python in a second interpreter. Terminal input remains available to an explicitly started interactive script.
246
+
247
+ Small `source` requests with `completion: 'wait'` run through the existing REPL without creating a device file, so they work when storage is unmounted and do not require `script.run` or `file.write.binary`. Core holds the source in a temporary REPL variable, executes it in the normal global namespace so user variables remain available, and deletes only that temporary variable after completion. Source larger than the REPL input limit uses a temporary device script and requires `script.run`, `file.write.binary`, and writable storage.
248
+
249
+ For `completion: 'wait'`, omitting `timeout` disables the aggregate operation deadline; a positive `timeout` limits the full operation, including its REPL queue and startup exchanges. Core still bounds each REPL exchange and RPC, so an omitted total deadline does not create an unbounded protocol wait. If a timeout or cancellation occurs while code is running, its effects may be uncertain; Core does not claim that the program stopped or roll back device changes.
242
250
 
243
251
  ### Files and images
244
252
 
@@ -259,7 +267,7 @@ const image = await device.capture({ source: 'camera', format: 'jpeg', timeout:
259
267
  // image: { data: Uint8Array, mimeType: 'image/jpeg', metadata }
260
268
  ```
261
269
 
262
- 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.
270
+ 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. Omitting `timeout` disables the aggregate read deadline; a positive `timeout` limits the full read. Each chunk request remains bounded by `chunkTimeout`, which defaults to 30 seconds. 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.
263
271
 
264
272
  **Binary uploads (SDK 0.3.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.
265
273
 
@@ -294,6 +302,8 @@ const devices = await device.discover({ timeout: 3000 });
294
302
 
295
303
  Results contain `deviceId`, `hostname` (without `.local`), `board`, `firmwareVersion`, `evMuxVersion`, `compatible`, `addresses`, `port`, and adapter-specific `expiresAt`/`connectionUrl`. Supply the selected result as `expectedDevice` when connecting: Core checks the stable ID and hostname before acquisition, protecting against stale DHCP addresses. This is identity matching, not authentication. Refresh expired bridge handles. Multicast filtering, VLANs and client isolation may require a manual address. Browsers have no native DNS-SD enumeration API; local-network permission does not remove the bridge requirement.
296
304
 
305
+ Node-only adapters and passive serial inventory are exported from `@esp-vision/device-core/node`: `defaultPython`, `listSerial`, `filterSerialPorts`, `serialEntry`, `transportFor`, and `inventory`. `listSerial({ python, signal, timeout })` runs the bundled Python enumerator with a process timeout and cancellation signal; it lists ports without opening them. `inventory()` lists without probing by default. Pass `probe: true` to acquire each selected EV-MUX session and query metadata; this does not send Ctrl-C or run a REPL probe, and reported `vmReady` state is preserved. Creating a transport with `transportFor()` is passive and does not open the port.
306
+
297
307
  The root/browser entry points do not import Node networking modules. The optional Node discovery subpath dynamically loads `multicast-dns`. No Agent package is required.
298
308
 
299
309
  ## Migration from the session API
@@ -323,7 +333,7 @@ IDE and Agent adapters that construct sessions or call `session.repl` must migra
323
333
 
324
334
  ROM flashing remains a separate lifecycle under `@esp-vision/device-core/firmware`. Hosts inject the ROM loader, approved image manifest and reset policy. The adapter validates image integrity, chip/revision, security and partitions, verifies complete readback, and clears the P4 download latch before reset. It does not distribute firmware, open a browser picker or provide a flashing UI. EV-MUX `control({ action: 'bootloader' })` only requests entry into the downloader; it does not flash an image.
325
335
 
326
- Agent workflows, MCP tool mappings, model providers, CLI bundles and plugin packaging belong to the independent Agent project and are not included here.
336
+ Agent workflows, MCP tool mappings, model providers and plugin packaging belong to their own integration packages and are not included here.
327
337
 
328
338
  ## Validation and release
329
339
 
@@ -358,7 +368,7 @@ npm --prefix node_modules/@esp-vision/device-core run test:hardware -- --help
358
368
  npm --prefix node_modules/@esp-vision/device-core run test:hardware -- --tcp 192.168.1.100:3232
359
369
  ```
360
370
 
361
- Inside the source or installed package directory, use `npm test` and `npm run test:hardware -- ...`. Unit tests require no device. The runner explicitly enumerates tests and fails when the suite is absent or empty, rather than passing zero cases. The management CLI uses `--port`; the dedicated hardware runner uses `--serial`, as shown in its help.
371
+ Inside the source or installed package directory, use `npm test` and `npm run test:hardware -- ...`. Unit tests require no device. The runner explicitly enumerates tests and fails when the suite is absent or empty, rather than passing zero cases. The dedicated hardware runner uses `--serial`, as shown in its help.
362
372
 
363
373
  Run the source or installed SDK against one idle device using Node.js 22. These checks use the public `createDevice()` API and release the session and transport afterwards. The default check reads identity/capabilities/runtime and executes a short `print()` without creating a device file. Connecting negotiates the REPL, so use a device whose running Python application may safely be interrupted. The check does not configure Wi-Fi, reset the board or erase storage.
364
374
 
@@ -379,7 +389,7 @@ These functional checks intentionally exercise sensor settings, image processing
379
389
 
380
390
  `--screen-output PATH` explicitly requests an LCD screenshot: it adds the `display` check when needed, draws red/green/blue/white bars with `ESP-VISION CI` text, validates backlight changes, captures that same display with `capture({ source: 'screen' })`, verifies JPEG completeness and panel dimensions, and writes the JPEG on the host before releasing the display. Missing LCD or screen capture support makes this explicit request fail instead of skip. Camera capture stays a separate source. Output images and logs should be placed under your project-local artifact directory when running this suite.
381
391
 
382
- TCP needs only Node.js. Serial additionally needs a Python interpreter with the optional [pyserial](https://pyserial.readthedocs.io/) dependency installed in your environment (`python3 -m pip install pyserial`, BSD-3-Clause; not vendored or included in the npm package). Select another interpreter with `--python /path/to/python`. The Apache-2.0-licensed serial adapter in `src/node_serial.py` is shared by the CLI and hardware runner; it enumerates ports without opening them, forwards bytes and configures RTS/DTR before opening; serial control-line behavior still depends on the board and OS driver. The same hardware runner, unit tests and Python fixture ship in the source tree and npm tarball; no firmware/build outputs or device credentials are included.
392
+ TCP needs only Node.js. Serial additionally needs a Python interpreter with the optional [pyserial](https://pyserial.readthedocs.io/) dependency installed in your environment (`python3 -m pip install pyserial`, BSD-3-Clause; not vendored or included in the npm package). Select another interpreter with the `python` option when listing ports or configuring a serial adapter. The Apache-2.0-licensed serial adapter in `src/node_serial.py` is shared by the public Node transport and hardware runner; it enumerates ports without opening them, forwards bytes and configures RTS/DTR before opening; serial control-line behavior still depends on the board and OS driver. The same hardware runner, unit tests and Python fixture ship in the source tree and npm tarball; no firmware/build outputs or device credentials are included.
383
393
 
384
394
  ## License
385
395
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@esp-vision/device-core",
3
- "version": "0.4.1",
3
+ "version": "0.5.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": {
@@ -68,15 +68,11 @@
68
68
  "optional": true
69
69
  }
70
70
  },
71
- "bin": {
72
- "esp-vision-device": "./src/cli.js"
73
- },
74
71
  "optionalDependencies": {
75
72
  "multicast-dns": "^7.2.5"
76
73
  },
77
74
  "dependencies": {
78
75
  "smol-toml": "^1.7.0",
79
- "string-width": "^7.2.0",
80
76
  "tasmota-webserial-esptool": "9.4.2"
81
77
  },
82
78
  "devDependencies": {
@@ -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.4.1",
4
+ "deviceCoreVersion": "0.5.0",
5
5
  "schemaVersion": 1,
6
6
  "actions": [
7
7
  {
@@ -15,6 +15,13 @@ export function browserRomCompatibility(loader) {
15
15
  }
16
16
  }
17
17
  }
18
+ // runStub returns a separate loader. Its command replies use two status bytes;
19
+ // the ROM loader still expects four and cannot issue reset register writes.
20
+ let activeStub
21
+ const hardReset = loader.hardReset
22
+ loader.hardReset = function (...args) {
23
+ return activeStub ? activeStub.hardReset(...args) : hardReset.apply(this, args)
24
+ }
18
25
  const runStub = loader.runStub
19
26
  loader.runStub = async function (skipFlashDetection = false) {
20
27
  const memFinish = this.memFinish, packet = this.readPacket
@@ -34,6 +41,7 @@ export function browserRomCompatibility(loader) {
34
41
  const stub = await runStub.call(this, skipFlashDetection)
35
42
  if (!stub.IS_STUB) throw new Error('This device has no supported browser flash stub')
36
43
  browserStubCompatibility(stub)
44
+ activeStub = stub
37
45
  return stub
38
46
  } finally { this.memFinish = memFinish; this.readPacket = packet }
39
47
  }
package/src/device.js CHANGED
@@ -106,14 +106,24 @@ class Device {
106
106
  source?.addEventListener('abort', abortSource, { once: true })
107
107
  if (lifecycle.aborted) abortLifecycle()
108
108
  if (source?.aborted) abortSource()
109
- const cleanup = () => {
109
+ const cleanupLifecycle = () => {
110
110
  lifecycle.removeEventListener('abort', abortLifecycle)
111
+ }
112
+ const cleanup = () => {
113
+ cleanupLifecycle()
111
114
  source?.removeEventListener('abort', abortSource)
112
115
  }
113
116
  try {
114
117
  const result = await api[domain][method]({ ...request, signal: controller.signal })
115
- // A started Python run retains cancellation until its completion.
116
- if (result?.done instanceof Promise) { result.done.then(cleanup, cleanup); return result }
118
+ if (result?.done instanceof Promise) {
119
+ // Once Python reports that a run started, disconnect releases the
120
+ // session but does not reinterpret that acknowledgement as a request
121
+ // to interrupt the device program. The caller's own signal remains
122
+ // attached until completion.
123
+ if (domain === 'python' && method === 'execute') cleanupLifecycle()
124
+ result.done.then(cleanup, cleanup)
125
+ return result
126
+ }
117
127
  cleanup()
118
128
  return result
119
129
  } catch (error) { cleanup(); throw error }
@@ -125,10 +135,11 @@ class Device {
125
135
  }
126
136
 
127
137
  async connect(options = {}) {
128
- object(options, ['transport', 'role', 'expectedDevice', 'timeout', 'signal'], 'connect')
129
- const { role = 'unknown', expectedDevice = null, timeout = 30000, signal } = options
138
+ object(options, ['transport', 'role', 'expectedDevice', 'timeout', 'signal', 'probeRepl'], 'connect')
139
+ const { role = 'unknown', expectedDevice = null, timeout = 30000, signal, probeRepl = true } = options
130
140
  if (!['unknown', 'usj', 'usb', 'uart', 'tcp'].includes(role)) throw new TypeError('role must be usj, usb, uart or tcp')
131
141
  if (!Number.isFinite(timeout) || timeout <= 0) throw new RangeError('timeout must be positive')
142
+ if (typeof probeRepl !== 'boolean') throw new TypeError('probeRepl must be a boolean')
132
143
  if (signal !== undefined && (!signal || typeof signal.addEventListener !== 'function' || typeof signal.aborted !== 'boolean')) throw new TypeError('signal must be an AbortSignal')
133
144
  if (signal?.aborted) throw cancellation(signal)
134
145
  if (this.#updating || this.#connecting || this.#disconnecting || this.#session) throw failure('DEVICE_BUSY', 'Disconnect before opening another connection')
@@ -173,7 +184,7 @@ class Device {
173
184
  this.#unsubscribe = transport.subscribe({ data: bytes => { if (this.#session === session) session.feedBytes(bytes) }, error: lost, close: () => lost() })
174
185
  if (typeof this.#unsubscribe !== 'function') throw new TypeError('Transport subscribe must synchronously return an unsubscribe function')
175
186
  if (transport.connect) await abortable(transport.connect({ signal: lifecycle.signal }), lifecycle.signal)
176
- await abortable(session.negotiate({ role, expectedDevice, signal: lifecycle.signal }), lifecycle.signal)
187
+ await abortable(session.negotiate({ role, expectedDevice, probeRepl, signal: lifecycle.signal }), lifecycle.signal)
177
188
  if (lifecycle.signal.aborted || this.#session !== session) throw cancellation(lifecycle.signal)
178
189
  this.#api = {
179
190
  python: createPythonApi({ session, state: this.#state }),
@@ -27,13 +27,14 @@ export function createFilesApi({ session, state = {} }) {
27
27
  }))
28
28
  },
29
29
  async read(request) {
30
- request = snapshotRequest(request, ['path', 'encoding', 'timeout', 'maxBytes', 'signal'], 'files.read')
31
- const { path, encoding = 'binary', timeout = 30000, maxBytes = null, signal = null } = request
30
+ request = snapshotRequest(request, ['path', 'encoding', 'timeout', 'chunkTimeout', 'maxBytes', 'signal'], 'files.read')
31
+ const { path, encoding = 'binary', timeout = null, chunkTimeout = 30000, maxBytes = null, signal = null } = request
32
32
  if (!['binary', 'utf-8'].includes(encoding)) throw new TypeError('File encoding must be binary or utf-8')
33
- if (!Number.isFinite(timeout) || timeout <= 0) throw new RangeError('File read timeout must be positive')
33
+ if (timeout !== null && (!Number.isFinite(timeout) || timeout <= 0)) throw new RangeError('File read timeout must be positive or null')
34
+ if (!Number.isFinite(chunkTimeout) || chunkTimeout <= 0) throw new RangeError('File read chunk timeout must be positive')
34
35
  if (maxBytes !== null && (!Number.isSafeInteger(maxBytes) || maxBytes < 0 || maxBytes > 16 * 1024 * 1024)) throw new RangeError('maxBytes must be between zero and 16 MiB')
35
36
  return operate('read', request, async () => {
36
- const bytes = await session.repl.readFile(path, { timeout, maxBytes, signal })
37
+ const bytes = await session.repl.readFile(path, { timeout, chunkTimeout, maxBytes, signal })
37
38
  return encoding === 'binary' ? bytes : new TextDecoder().decode(bytes)
38
39
  })
39
40
  },
@@ -53,7 +53,8 @@ function validateRequest(request) {
53
53
  if (path) validateDevicePath(request.path)
54
54
  const completion = request.completion ?? 'wait'
55
55
  if (!['wait', 'start'].includes(completion)) throw new TypeError('Python completion must be wait or start')
56
- if (request.timeout !== undefined && (!Number.isFinite(request.timeout) || request.timeout <= 0)) throw new RangeError('Python timeout must be positive')
56
+ if (request.timeout !== undefined && request.timeout !== null
57
+ && (!Number.isFinite(request.timeout) || request.timeout <= 0)) throw new RangeError('Python timeout must be positive or null')
57
58
  if (request.maxOutputChars !== undefined && (!Number.isSafeInteger(request.maxOutputChars) || request.maxOutputChars < 0)) throw new RangeError('maxOutputChars must be a nonnegative integer')
58
59
  if (completion === 'start' && request.timeout !== undefined) throw new TypeError('timeout only applies to completion wait')
59
60
  return { source, completion }
@@ -79,7 +80,7 @@ export function createPythonApi({ session, state = {} }) {
79
80
  async execute(request) {
80
81
  request = snapshotRequest(request, ['source', 'path', 'completion', 'timeout', 'signal', 'maxOutputChars'], 'Python execution')
81
82
  const { source, completion } = validateRequest(request)
82
- const { signal = null, timeout = 10000, maxOutputChars } = request
83
+ const { signal = null, timeout = null, maxOutputChars } = request
83
84
  checkAbort(signal)
84
85
  if (state.operation || state.run || session.runCapture || session.runStarting) throw deviceBusy()
85
86
  const token = { kind: 'python.execute', interactive: false }
@@ -93,7 +94,7 @@ export function createPythonApi({ session, state = {} }) {
93
94
  if (released) return
94
95
  released = true
95
96
  try {
96
- if (temporary) {
97
+ if (temporary && session.state === 'ready') {
97
98
  if (failed && session.stopForRepl) {
98
99
  const ready = await session.stopForRepl({ timeout: 3000 })
99
100
  if (!ready) throw Object.assign(new Error('Cannot clean up Python source until REPL is ready'), { code: 'REPL_UNAVAILABLE' })
@@ -11,6 +11,8 @@
11
11
  export const EV_MUX_PAYLOAD_LIMIT = 1800
12
12
  export const EV_MUX_SCRIPT_CHUNK_BYTES = 900
13
13
  export const EV_MUX_VERSION = 4
14
+ const INLINE_PYTHON_REPL_TIMEOUT_MS = 5000
15
+ const SCRIPT_START_ACK_TIMEOUT_MS = 5000
14
16
  // MicroPython has a 128-byte VFS path buffer; script.write also appends .tmp.
15
17
  export const EV_MUX_PATH_MAX_BYTES = 123
16
18
  export const EV_MUX_DEFAULT_LIMITS = Object.freeze({
@@ -188,6 +190,24 @@ function awaitWithSignal(promise, signal) {
188
190
  })
189
191
  }
190
192
 
193
+ function createOperationDeadline(signal, timeout, timeoutError = Object.assign(new Error('Python execution timed out'), { code: 'TIMEOUT' })) {
194
+ if (timeout === null) return { signal, dispose() {} }
195
+ const controller = new AbortController()
196
+ const abortFromCaller = () => controller.abort(abortError(signal))
197
+ signal?.addEventListener('abort', abortFromCaller, { once: true })
198
+ if (signal?.aborted) abortFromCaller()
199
+ const timer = controller.signal.aborted
200
+ ? null
201
+ : setTimeout(() => controller.abort(timeoutError), timeout)
202
+ return {
203
+ signal: controller.signal,
204
+ dispose() {
205
+ clearTimeout(timer)
206
+ signal?.removeEventListener('abort', abortFromCaller)
207
+ },
208
+ }
209
+ }
210
+
191
211
  function withTimeout(operation, timeout, message, signal = null) {
192
212
  return new Promise((resolve, reject) => {
193
213
  if (signal?.aborted) {
@@ -321,7 +341,7 @@ export class DeviceSession {
321
341
  this.runStarting = false
322
342
 
323
343
  this.repl = new EvRepl({
324
- writeEvmux: (channel, type, method, payload) => this.writeFrame(channel, type, method, payload),
344
+ writeEvmux: (channel, type, method, payload, options) => this.writeFrame(channel, type, method, payload, options),
325
345
  })
326
346
  this.repl.onScriptEnd = () => this._handleScriptEnd()
327
347
 
@@ -521,7 +541,7 @@ export class DeviceSession {
521
541
  if (this.runCapture) {
522
542
  clearTimeout(this.runCapture.timer)
523
543
  this.runCapture = null
524
- this.repl.cancelScriptEndSentinel()
544
+ this.repl.cancelScriptEndSentinel(error)
525
545
  }
526
546
  this.parser.resetReplTextDecoders?.()
527
547
  this.repl.resetEpoch(error)
@@ -1201,44 +1221,59 @@ export class DeviceSession {
1201
1221
  return tracked
1202
1222
  }
1203
1223
 
1204
- async executePython(source, { timeout = 8000, signal = null } = {}) {
1224
+ async executePython(source, { timeout = null, signal = null } = {}) {
1225
+ if (timeout !== null && (!Number.isFinite(timeout) || timeout <= 0)) {
1226
+ throw new RangeError('Python execution timeout must be positive or null')
1227
+ }
1205
1228
  if (signal?.aborted) throw abortError(signal)
1206
- const commandBytes = this.repl.execInputBytes
1207
- ? this.repl.execInputBytes(source)
1208
- : new TextEncoder().encode(`exec(${JSON.stringify(source)})\r\n`).length
1209
- const inputLimit = this.repl.inputMaxBytes || 511
1210
- if (commandBytes <= inputLimit) {
1211
- return this.repl.execPy(source, { timeout, signal })
1212
- }
1213
- if (!this.hasFeature('file.write.binary') || !this.hasFeature('script.run')) {
1214
- throw new Error(
1215
- `Python source requires ${commandBytes} REPL bytes, above the firmware limit ${inputLimit}; ` +
1216
- 'upgrade firmware with file.write.binary and script.run support.',
1217
- )
1218
- }
1219
-
1220
- const path = `/.__espvision_exec_${Date.now().toString(36)}_${this.sequence}.py`
1221
- let primaryError = null
1222
- let cleanupError = null
1223
- let output = null
1224
- this._diagnostic('python.execute.chunked', { path, sourceBytes: new TextEncoder().encode(source).length, commandBytes })
1229
+ const operation = createOperationDeadline(signal, timeout)
1230
+ signal = operation.signal
1225
1231
  try {
1226
- const result = await this.runScript(path, source, { timeout, signal, inlineCompletion: true })
1227
- output = result.output
1228
- } catch (error) {
1229
- primaryError = error
1230
- } finally {
1231
- if (primaryError) await this.stopForRepl().catch(() => false)
1232
+ if (signal?.aborted) throw abortError(signal)
1233
+ const commandBytes = this.repl.execInputBytes
1234
+ ? this.repl.execInputBytes(source)
1235
+ : new TextEncoder().encode(`exec(${JSON.stringify(source)})\r\n`).length
1236
+ const inputLimit = this.repl.inputMaxBytes || 511
1237
+ if (commandBytes <= inputLimit) {
1238
+ const result = await this.runScript(null, source, {
1239
+ timeout: null, signal, inlineSource: true,
1240
+ })
1241
+ return result.output
1242
+ }
1243
+ if (!this.hasFeature('file.write.binary') || !this.hasFeature('script.run')) {
1244
+ throw new Error(
1245
+ `Python source requires ${commandBytes} REPL bytes, above the firmware limit ${inputLimit}; ` +
1246
+ 'upgrade firmware with file.write.binary and script.run support.',
1247
+ )
1248
+ }
1249
+
1250
+ const path = `/.__espvision_exec_${Date.now().toString(36)}_${this.sequence}.py`
1251
+ let primaryError = null
1252
+ let cleanupError = null
1253
+ let output = null
1254
+ this._diagnostic('python.execute.chunked', { path, sourceBytes: new TextEncoder().encode(source).length, commandBytes })
1232
1255
  try {
1233
- await this.repl.removeFile(path)
1256
+ const result = await this.runScript(path, source, {
1257
+ timeout: null, signal, inlineCompletion: true,
1258
+ })
1259
+ output = result.output
1234
1260
  } catch (error) {
1235
- cleanupError = error
1236
- this._diagnostic('python.execute.cleanup_failed', { path, error: error.message })
1261
+ primaryError = error
1262
+ } finally {
1263
+ if (primaryError) await this.stopForRepl().catch(() => false)
1264
+ try {
1265
+ await this.repl.removeFile(path)
1266
+ } catch (error) {
1267
+ cleanupError = error
1268
+ this._diagnostic('python.execute.cleanup_failed', { path, error: error.message })
1269
+ }
1237
1270
  }
1271
+ if (primaryError) throw primaryError
1272
+ if (cleanupError) throw cleanupError
1273
+ return output
1274
+ } finally {
1275
+ operation.dispose()
1238
1276
  }
1239
- if (primaryError) throw primaryError
1240
- if (cleanupError) throw cleanupError
1241
- return output
1242
1277
  }
1243
1278
 
1244
1279
  async interrupt() {
@@ -1344,21 +1379,46 @@ export class DeviceSession {
1344
1379
  return false
1345
1380
  }
1346
1381
 
1347
- async runScript(path, content, {
1382
+ async runScript(path, content, options = {}) {
1383
+ const timeout = options.timeout === undefined ? 10000 : options.timeout
1384
+ if (timeout !== null && (!Number.isFinite(timeout) || timeout <= 0)) {
1385
+ throw new RangeError('Script timeout must be a positive number or null for an interactive run')
1386
+ }
1387
+ const timeoutError = Object.assign(new Error(`script run timeout after ${timeout}ms`), { code: 'SCRIPT_TIMEOUT' })
1388
+ const operation = createOperationDeadline(options.signal ?? null, timeout, timeoutError)
1389
+ try {
1390
+ return await this._runScript(path, content, {
1391
+ ...options,
1392
+ timeout: null,
1393
+ signal: operation.signal,
1394
+ })
1395
+ } finally {
1396
+ operation.dispose()
1397
+ }
1398
+ }
1399
+
1400
+ async _runScript(path, content, {
1348
1401
  timeout = 10000,
1349
1402
  signal = null,
1350
1403
  onStarted = null,
1351
1404
  maxOutputChars = Number.POSITIVE_INFINITY,
1352
1405
  trial = null,
1353
1406
  inlineCompletion = false,
1407
+ inlineSource = false,
1354
1408
  } = {}) {
1355
1409
  if (signal?.aborted) throw abortError(signal)
1356
- validateRpcPath(path)
1410
+ if (inlineSource) {
1411
+ if (path !== null || typeof content !== 'string' || inlineCompletion || trial) {
1412
+ throw new TypeError('Inline Python source must run without a device path or typed trial')
1413
+ }
1414
+ } else {
1415
+ validateRpcPath(path)
1416
+ }
1357
1417
  if (inlineCompletion && trial) throw new Error('Typed trial execution cannot use an inline runner')
1358
1418
  if (timeout !== null && (!Number.isFinite(timeout) || timeout <= 0)) {
1359
1419
  throw new RangeError('Script timeout must be a positive number or null for an interactive run')
1360
1420
  }
1361
- if ((content !== undefined && !this.hasFeature('file.write.binary')) || !this.hasFeature('script.run')) {
1421
+ if (!inlineSource && ((content !== undefined && !this.hasFeature('file.write.binary')) || !this.hasFeature('script.run'))) {
1362
1422
  throw new Error('Running scripts requires script.run and uploading source also requires file.write.binary')
1363
1423
  }
1364
1424
  if (this.runCapture || this.runStarting) {
@@ -1376,11 +1436,27 @@ export class DeviceSession {
1376
1436
  throw error
1377
1437
  }
1378
1438
  this.runStarting = true
1439
+ let inlineSourceVariable = null
1440
+ let inlineSourcePrepared = false
1379
1441
  try {
1380
1442
  if (!await this.stopForRepl({ timeout: timeout === null ? 5000 : Math.min(timeout, 5000), signal })) {
1381
1443
  throw new Error('could not stop the running script (no REPL answer after Ctrl-C)')
1382
1444
  }
1383
- if (content !== undefined) await this.writeScript(path, content, { signal })
1445
+ if (inlineSource) {
1446
+ inlineSourceVariable = `__evcore_python_${Date.now().toString(36)}_${++this.sequence}`
1447
+ const assignments = this.repl.createInlineSourceAssignments(content, inlineSourceVariable)
1448
+ for (const assignment of assignments) {
1449
+ inlineSourcePrepared = true
1450
+ await this.repl.execPy(assignment, { timeout: INLINE_PYTHON_REPL_TIMEOUT_MS, signal })
1451
+ }
1452
+ } else if (content !== undefined) {
1453
+ await this.writeScript(path, content, { signal })
1454
+ }
1455
+ } catch (error) {
1456
+ if (inlineSourcePrepared) {
1457
+ await this.repl.execPy(`del ${inlineSourceVariable}`, { timeout: 3000 }).catch(() => {})
1458
+ }
1459
+ throw error
1384
1460
  } finally {
1385
1461
  this.runStarting = false
1386
1462
  }
@@ -1427,10 +1503,18 @@ export class DeviceSession {
1427
1503
  completion.catch(() => {})
1428
1504
 
1429
1505
  try {
1430
- if (inlineCompletion) {
1431
- const command = this.repl.createScriptRunCommand(path)
1506
+ if (inlineSource) {
1507
+ const command = this.repl.createInlineScriptRunCommand(inlineSourceVariable)
1508
+ await this.repl.writeStdin(command, { signal })
1509
+ } else if (inlineCompletion) {
1510
+ const command = this.repl.createScriptRunCommand(path, { acknowledgeStart: Boolean(onStarted) })
1432
1511
  if (this.repl.scriptRunPrelude) await this.repl.execPy(this.repl.scriptRunPrelude, { timeout: timeout === null ? 5000 : timeout, signal })
1433
- await this.repl.writeStdin(command)
1512
+ const startAcknowledgement = onStarted
1513
+ ? this.repl.waitForScriptStart({ timeout: SCRIPT_START_ACK_TIMEOUT_MS, signal })
1514
+ : null
1515
+ startAcknowledgement?.catch(() => {})
1516
+ await this.repl.writeStdin(command, { signal })
1517
+ if (startAcknowledgement) await startAcknowledgement
1434
1518
  } else {
1435
1519
  const sentinelLine = this.repl.createScriptEndSentinel()
1436
1520
  const request = { path }
@@ -1440,7 +1524,7 @@ export class DeviceSession {
1440
1524
  request.trial_source_hash = trial.source_hash
1441
1525
  }
1442
1526
  await this.rpc('script.run', request, { timeout: 4000, signal })
1443
- await this.repl.writeStdin(sentinelLine)
1527
+ await this.repl.writeStdin(sentinelLine, { signal })
1444
1528
  }
1445
1529
  await onStarted?.({ path, trial: trial ? structuredClone(trial) : null })
1446
1530
  return await completion
@@ -1448,7 +1532,7 @@ export class DeviceSession {
1448
1532
  const capture = this.runCapture
1449
1533
  this.runCapture = null
1450
1534
  clearTimeout(capture?.timer)
1451
- this.repl.cancelScriptEndSentinel()
1535
+ this.repl.cancelScriptEndSentinel(err)
1452
1536
  capture?.reject(err)
1453
1537
  try {
1454
1538
  await completion