@esp-vision/device-core 0.4.2 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -2
- package/README.md +23 -13
- package/package.json +1 -5
- package/schemas/device-actions-v1.json +1 -1
- package/src/device.js +17 -6
- package/src/device_files.js +5 -4
- package/src/device_python.js +4 -3
- package/src/device_session.js +127 -43
- package/src/evrepl.js +196 -22
- package/src/node.js +10 -0
- package/src/node_inventory.js +7 -4
- package/src/node_serial.py +9 -1
- package/src/node_transport.js +36 -7
- package/tests/check-package.mjs +6 -9
- package/tests/device-checks.mjs +2 -2
- package/tests/fixtures/device-checks.py +205 -1
- package/tests/test-device-api.mjs +96 -6
- package/tests/test-device-execution.mjs +235 -16
- package/tests/test-device-files-api.mjs +85 -1
- package/tests/test-device-functional-checks.mjs +9 -0
- package/tests/test-device-hardware-cli.mjs +8 -0
- package/tests/test-device-python-api.mjs +29 -2
- package/tests/test-firmware-install.mjs +0 -17
- package/tests/test-node-adapter-assets.mjs +16 -0
- package/tests/test-node-inventory.mjs +136 -0
- package/tests/test-node-serial.mjs +104 -0
- package/src/cli.js +0 -185
- package/tests/test-cli-output.mjs +0 -31
- package/tests/test-cli-ports.mjs +0 -50
- package/tests/test-cli.mjs +0 -98
package/CHANGELOG.md
CHANGED
|
@@ -1,12 +1,35 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.
|
|
3
|
+
## 0.5.1
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- Make packaged execution-deadline regressions deterministic with a controlled clock, including expiry before VM entry, and keep installation guidance independent of temporary publication state.
|
|
8
|
+
|
|
9
|
+
## 0.5.0
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- 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`.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- 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.
|
|
18
|
+
|
|
19
|
+
- Wait for a private VM-entry sentinel before resolving detached Python startup, with bounded startup waits and session-isolated output.
|
|
20
|
+
|
|
21
|
+
- Add validated `connect({ probeRepl: false })` for non-interrupting native RPC access and use it for metadata inventory probes; retain the existing active connection default.
|
|
22
|
+
|
|
23
|
+
- Make aggregate deadlines optional by default for completed Python runs and file reads while retaining bounded REPL exchanges and per-chunk file-read limits.
|
|
24
|
+
- 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.
|
|
25
|
+
|
|
26
|
+
## 0.4.2
|
|
4
27
|
|
|
5
28
|
### Fixed
|
|
6
29
|
|
|
7
30
|
- 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.
|
|
8
31
|
|
|
9
|
-
## 0.4.1
|
|
32
|
+
## 0.4.1
|
|
10
33
|
|
|
11
34
|
### Fixed
|
|
12
35
|
|
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
|
-
|
|
22
|
+
The **0.5.x** package line is SDK-only: the management CLI lives in `@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. For unpublished source changes, 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.
|
|
25
|
+
npm install --save-exact @esp-vision/device-core@0.5.1 --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
|
-
|
|
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-
|
|
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
|
-
|
|
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.
|
|
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
|
|
135
|
+
### Browser ROM flashing
|
|
134
136
|
|
|
135
|
-
`@esp-vision/device-core/browser` exports `openBrowserRom({ port, logger?, signal? })`.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
3
|
+
"version": "0.5.1",
|
|
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": {
|
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
|
|
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
|
-
|
|
116
|
-
|
|
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 }),
|
package/src/device_files.js
CHANGED
|
@@ -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
|
},
|
package/src/device_python.js
CHANGED
|
@@ -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 &&
|
|
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 =
|
|
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' })
|
package/src/device_session.js
CHANGED
|
@@ -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 =
|
|
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
|
|
1207
|
-
|
|
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
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
if (
|
|
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.
|
|
1256
|
+
const result = await this.runScript(path, source, {
|
|
1257
|
+
timeout: null, signal, inlineCompletion: true,
|
|
1258
|
+
})
|
|
1259
|
+
output = result.output
|
|
1234
1260
|
} catch (error) {
|
|
1235
|
-
|
|
1236
|
-
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
1431
|
-
const command = this.repl.
|
|
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
|
-
|
|
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
|