@esp-vision/device-core 0.1.0 → 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,9 +1,33 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
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
+
13
+ ## 0.1.1
14
+
15
+ ### Fixed
16
+
17
+ - Expose `tcpTransport` and `serialTransport` through `@esp-vision/device-core/node`.
18
+ - Include unit tests, hardware checks, fixtures and npm script targets in the tarball. Fail when no unit tests are found; validate imports, discovery and runnable checks from an isolated installed package.
19
+ - Clarify `device.discover()` versus the provider's `list()` contract and document the shipped Node adapters and installed test commands.
20
+
21
+ ## 0.1.0 — Initial public release
4
22
 
5
23
  ### Added
6
24
 
25
+ - Added browser-safe and optional Node LAN discovery interfaces, TTL-aware DNS-SD collection, and selection-time device identity validation before EV-MUX acquisition.
26
+
27
+ - Standalone, UI-independent EV-MUX v4 sessions, strict ownership/epoch filtering, framed REPL, files, execution, Wi-Fi controls and device-action contracts.
28
+ - Verified firmware-flash adapter with injected ROM loader, image/security/partition checks, complete readback and masked P4 download-latch reset.
29
+ - Public npm metadata, protected default-branch publishing, isolated tarball checks and verified/idempotent release retries. The package has no Agent/IDE runtime dependency or bundle; the official firmware index uses a small TOML parser, and Node discovery optionally uses multicast-dns.
30
+
7
31
  - Default official firmware discovery reads dl.espressif.com config and per-version metadata; legacy unqualified releases remain visible without enabling unsafe writes. CLI uses --port and a built-in esptool process adapter, removing the user-supplied JavaScript loader requirement.
8
32
 
9
33
  - Ship `esp-vision-device` management CLI: unified `inspect` passive/probed wired/LAN queries matching the SDK method name, platform-aware USB serial filtering with `--all-ports` for native UART/Bluetooth, numbered/grouped endpoints, strict configuration/control and catalog/version firmware commands. Reuse Core facade and one cross-platform Python serial/TCP adapter in both CLI and hardware checks.
@@ -23,26 +47,14 @@
23
47
 
24
48
  ### Fixed
25
49
 
50
+ - Preserve split mDNS address announcements during cache flush, reclaim expired cache entries, bound packet retention, and close discovery sockets when cancellation occurs during setup.
51
+ - Soft reset requires a newer ready epoch and a real REPL barrier within one deadline; caller cancellation reaches all stages and device-action adapters.
52
+ - Bounded file reads reject incomplete/changing content; serial startup drain, TCP frame deadlines and keepalive recovery follow the v4 contract.
53
+
26
54
  - Align discovery output by Unicode display width instead of tabs; fall back to aligned, wrapped device blocks on narrow terminals without truncating identifiers.
27
55
 
28
56
  - Generate script completion inside the executing statement so `input()` receives only user input; retain output arriving in the same frame as completion.
29
57
  - Return binary image results correlated to the requesting sequence instead of a shared last-image slot. LCD capture uses the dedicated `debug.capture_screen` capability and never falls back to the camera; this refactor does not implement the planned high-throughput binary firmware protocol.
30
58
  - Clean up subscriptions, pending work and transports on connection failure or session loss, and isolate event-listener exceptions from protocol delivery.
31
59
 
32
- ## 0.1.0 — Initial public release
33
-
34
- ### Added
35
-
36
- - Added browser-safe and optional Node LAN discovery interfaces, TTL-aware DNS-SD collection, and selection-time device identity validation before EV-MUX acquisition.
37
-
38
- - Standalone, UI-independent EV-MUX v4 sessions, strict ownership/epoch filtering, framed REPL, files, execution, Wi-Fi controls and device-action contracts.
39
- - Verified firmware-flash adapter with injected ROM loader, image/security/partition checks, complete readback and masked P4 download-latch reset.
40
- - Public npm metadata, protected default-branch publishing, isolated tarball checks and verified/idempotent release retries. The package has no Agent/IDE runtime dependency or bundle; the official firmware index uses a small TOML parser, and Node discovery optionally uses multicast-dns.
41
-
42
- ### Fixed
43
-
44
- - Preserve split mDNS address announcements during cache flush, reclaim expired cache entries, bound packet retention, and close discovery sockets when cancellation occurs during setup.
45
- - Soft reset requires a newer ready epoch and a real REPL barrier within one deadline; caller cancellation reaches all stages and device-action adapters.
46
- - Bounded file reads reject incomplete/changing content; serial startup drain, TCP frame deadlines and keepalive recovery follow the v4 contract.
47
-
48
60
  The earlier v3 extraction existed only inside repository development history; it was not an npm release of this package. Version 0.1.0 is the first public v4-only package.
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 npm page and installation become available only after the first publication 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.0 --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.0
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
@@ -49,7 +49,7 @@ esp-vision-device inspect --port COM3 --fields identity,capabilities
49
49
  esp-vision-device inspect --tcp 192.168.1.100:3232 --fields network,storage --json
50
50
  ```
51
51
 
52
- Wired access/enumeration requires Python with pyserial (`--python` overrides the interpreter); LAN discovery uses optional `multicast-dns`, normally installed by npm. Windows/Linux/macOS paths are accepted. Passive enumeration never opens a serial port; `--probe` and targeted queries acquire a session and may interrupt Python. Unidentified ports, ROM candidates and USB-to-UART adapters remain numbered and visible. Board names are unknown until firmware identification; USB descriptors are hints only. Firmware versions are fetched automatically from dl.espressif.com. Firmware flashing uses the built-in serial adapter with separately installed Python esptool>=5.3,<6; no user loader module is required. Historical releases without qualified update metadata remain listed but cannot be installed by this flow. Installation becomes available after first npm publication.
52
+ Wired access/enumeration requires Python with pyserial (`--python` overrides the interpreter); LAN discovery uses optional `multicast-dns`, normally installed by npm. Windows/Linux/macOS paths are accepted. Passive enumeration never opens a serial port; `--probe` and targeted queries acquire a session and may interrupt Python. Unidentified ports, ROM candidates and USB-to-UART adapters remain numbered and visible. Board names are unknown until firmware identification; USB descriptors are hints only. Firmware versions are fetched automatically from dl.espressif.com. Firmware flashing uses the built-in serial adapter with separately installed Python esptool>=5.3,<6; no user loader module is required. Historical releases without qualified update metadata remain listed but cannot be installed by this flow. Check the registry version before installing a newly prepared release.
53
53
 
54
54
  ## Public device API
55
55
 
@@ -110,14 +110,39 @@ try {
110
110
  }
111
111
  ```
112
112
 
113
+ ### Official Node adapters
114
+
115
+ 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.
116
+
117
+ ```js
118
+ import { createDevice } from '@esp-vision/device-core';
119
+ import { tcpTransport, serialTransport } from '@esp-vision/device-core/node';
120
+
121
+ const tcpDevice = createDevice({ transport: tcpTransport({ host: '192.168.1.100', port: 3232 }) });
122
+ const usbDevice = createDevice({ transport: serialTransport({ serial: '/dev/ttyACM0', python: 'python3' }) });
123
+ // Connect only the selected device, and always disconnect() in finally.
124
+ ```
125
+
113
126
  A discovery-only instance can receive a transport later:
114
127
 
115
128
  ```js
116
- const [selected] = await device.discover({ timeout: 3000 });
117
- await device.connect({ transport: tcpAdapter, role: 'tcp', expectedDevice: selected });
129
+ import { createDevice } from '@esp-vision/device-core';
130
+ import { tcpTransport } from '@esp-vision/device-core/node';
131
+ import { createNodeDiscovery } from '@esp-vision/device-core/discovery/node';
132
+
133
+ const provider = createNodeDiscovery();
134
+ const device = createDevice({ discovery: provider });
135
+ const selected = (await device.discover({ timeout: 3000 })).find(item => item.compatible && item.addresses.length);
136
+ if (!selected) throw new Error('No compatible device found');
137
+ try {
138
+ await device.connect({ transport: tcpTransport({ host: selected.addresses[0], port: selected.port }), role: 'tcp', expectedDevice: selected });
139
+ console.log(await device.inspect({ fields: ['identity'] }));
140
+ } finally { await device.disconnect(); }
118
141
  ```
119
142
 
120
- Opening `tcpAdapter` from the selected address/bridge URL remains the host's responsibility. Supported query fields are `identity`, `capabilities`, `runtime`, `network` and `storage`. The default is identity/capabilities/runtime; `inspect` reads only the requested fields and does not change configuration. Network/storage queries require the corresponding firmware capabilities. `connect()` returns that default inspection result after negotiation.
143
+ Application discovery is `device.discover()`. `createNodeDiscovery()` and `createBridgeDiscovery()` return providers whose adapter contract is `list({ timeout, signal })`; they do not expose `discover()`. Inject a provider into `createDevice({ discovery: provider })` rather than calling `provider.discover()`. `list()` is the lower-level integration method, not another device API.
144
+
145
+ Supported query fields are `identity`, `capabilities`, `runtime`, `network` and `storage`. The default is identity/capabilities/runtime; `inspect` reads only the requested fields and does not change configuration. Network/storage queries require the corresponding firmware capabilities. `connect()` returns that default inspection result after negotiation.
121
146
 
122
147
  ### Events
123
148
 
@@ -195,7 +220,7 @@ const image = await device.capture({ source: 'camera', format: 'jpeg', timeout:
195
220
 
196
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.
197
222
 
198
- **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.
199
224
 
200
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.
201
226
 
@@ -284,14 +309,24 @@ The package includes library source, schemas, license and documentation, with no
284
309
 
285
310
  ## Hardware acceptance
286
311
 
287
- Run the checked-out 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.
312
+ The package ships its unit suite and hardware runner. From your application directory, target the installed dependency explicitly:
313
+
314
+ ```sh
315
+ npm --prefix node_modules/@esp-vision/device-core test
316
+ npm --prefix node_modules/@esp-vision/device-core run test:hardware -- --help
317
+ npm --prefix node_modules/@esp-vision/device-core run test:hardware -- --tcp 192.168.1.100:3232
318
+ ```
319
+
320
+ 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.
321
+
322
+ 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.
288
323
 
289
324
  ```sh
290
325
  npm run test:hardware -- --tcp 192.168.1.100:3232
291
- npm run test:hardware -- --port /dev/serial/by-id/YOUR_DEVICE --role usj
292
- npm run test:hardware -- --port /dev/serial/by-id/YOUR_USB_OTG_DEVICE --role usb --write
326
+ npm run test:hardware -- --serial /dev/serial/by-id/YOUR_DEVICE --role usj
327
+ npm run test:hardware -- --serial /dev/serial/by-id/YOUR_USB_OTG_DEVICE --role usb --write
293
328
  npm run test:hardware -- --tcp 192.168.1.100 --write --capture --timeout 90000
294
- npm run test:hardware -- --port /dev/serial/by-id/YOUR_DEVICE --role usj --checks all --frames 30 --output camera.jpg
329
+ npm run test:hardware -- --serial /dev/serial/by-id/YOUR_DEVICE --role usj --checks all --frames 30 --output camera.jpg
295
330
  npm run test:hardware -- --tcp 192.168.1.100 --checks display --display-hold 2 --screen-output screen.jpg
296
331
  ```
297
332
 
@@ -303,7 +338,7 @@ These functional checks intentionally exercise sensor settings, image processing
303
338
 
304
339
  `--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.
305
340
 
306
- 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. Hardware-check scripts and their tests stay in the source tree and are excluded from the published package.
341
+ 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.
307
342
 
308
343
  ## License
309
344
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@esp-vision/device-core",
3
- "version": "0.1.0",
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": {
@@ -12,7 +12,8 @@
12
12
  "./bindings": "./src/device_bindings.js",
13
13
  "./firmware": "./src/firmware_flash.js",
14
14
  "./discovery": "./src/network_discovery.js",
15
- "./discovery/node": "./src/network_discovery_node.js"
15
+ "./discovery/node": "./src/network_discovery_node.js",
16
+ "./node": "./src/node_transport.js"
16
17
  },
17
18
  "files": [
18
19
  "src/*.js",
@@ -21,11 +22,14 @@
21
22
  "CHANGELOG.md",
22
23
  "README.md",
23
24
  "LICENSE",
24
- "NOTICE"
25
+ "NOTICE",
26
+ "tests/*.mjs",
27
+ "tests/fixtures/*.py",
28
+ "scripts/*.mjs"
25
29
  ],
26
30
  "license": "Apache-2.0",
27
31
  "scripts": {
28
- "test": "node --test tests/test-device-*.mjs tests/test-cli*.mjs tests/test-firmware-flash.mjs tests/test-network-discovery.mjs",
32
+ "test": "node scripts/run-tests.mjs",
29
33
  "check:schema": "node scripts/generate-action-schema.mjs --check",
30
34
  "schema:actions": "node scripts/generate-action-schema.mjs",
31
35
  "check:package": "node tests/check-package.mjs",
@@ -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.0",
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": {
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env node
2
+ /*
3
+ * SPDX-FileCopyrightText: 2026 ESP-VISION-IDE
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import assert from 'node:assert/strict'
8
+ import { mkdir, readFile, writeFile } from 'node:fs/promises'
9
+ import { dirname, resolve } from 'node:path'
10
+ import { fileURLToPath } from 'node:url'
11
+ import process from 'node:process'
12
+ import {
13
+ DEVICE_ACTION_DESCRIPTORS,
14
+ DEVICE_ACTION_SCHEMA_VERSION,
15
+ } from '../src/device_actions.js'
16
+
17
+ const coreRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
18
+ const corePackage = JSON.parse(await readFile(resolve(coreRoot, 'package.json'), 'utf8'))
19
+ const outputPath = resolve(coreRoot, `schemas/device-actions-v${DEVICE_ACTION_SCHEMA_VERSION}.json`)
20
+ const document = {
21
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
22
+ title: 'ESP Vision Device Actions',
23
+ deviceCoreVersion: corePackage.version,
24
+ schemaVersion: DEVICE_ACTION_SCHEMA_VERSION,
25
+ actions: DEVICE_ACTION_DESCRIPTORS,
26
+ }
27
+ const serialized = `${JSON.stringify(document, null, 2)}\n`
28
+
29
+ if (process.argv.includes('--check')) {
30
+ assert.equal(await readFile(outputPath, 'utf8'), serialized, 'Device Action schema artifact is stale; regenerate it')
31
+ console.log(`Device Action Schema ${DEVICE_ACTION_SCHEMA_VERSION} is current`)
32
+ } else {
33
+ await mkdir(dirname(outputPath), { recursive: true })
34
+ await writeFile(outputPath, serialized)
35
+ console.log(`Generated ${outputPath}`)
36
+ }
@@ -0,0 +1,68 @@
1
+ // SPDX-FileCopyrightText: 2026 ESP-VISION-IDE
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import assert from 'node:assert/strict'
4
+ import { verifyPublishedIntegrity } from './verify-registry.mjs'
5
+ import { spawnSync } from 'node:child_process'
6
+ import { mkdtemp, readFile, writeFile, rm } from 'node:fs/promises'
7
+ import { tmpdir } from 'node:os'
8
+ import { dirname, join, resolve } from 'node:path'
9
+ import { fileURLToPath } from 'node:url'
10
+
11
+ const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
12
+ const registry = 'https://registry.npmjs.org/'
13
+ const dryRun = process.argv.includes('--dry-run')
14
+ assert.ok(process.argv.slice(2).every(arg => arg === '--dry-run'), 'Unknown release argument')
15
+ const manifest = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'))
16
+ assert.equal(manifest.name, '@esp-vision/device-core')
17
+ assert.match(manifest.version, /^\d+\.\d+\.\d+$/)
18
+ assert.equal(manifest.publishConfig?.registry, registry)
19
+ assert.equal(manifest.publishConfig?.access, 'public')
20
+ assert.equal(manifest.license, 'Apache-2.0')
21
+ if (!dryRun) {
22
+ assert.equal(process.env.CI_PIPELINE_SOURCE, 'push', 'Publish only from the merged default-branch pipeline')
23
+ assert.ok(process.env.CI_DEFAULT_BRANCH && process.env.CI_COMMIT_BRANCH === process.env.CI_DEFAULT_BRANCH,
24
+ 'Publish only after manual merge to the default branch')
25
+ assert.equal(process.env.CI_COMMIT_REF_PROTECTED, 'true', 'Publishing requires a protected branch')
26
+ assert.ok(process.env.NPM_PUBLISH_TOKEN, 'Protected NPM_PUBLISH_TOKEN is missing')
27
+ }
28
+ const temporary = await mkdtemp(join(tmpdir(), 'esp-vision-core-release-'))
29
+ try {
30
+ // Store an environment reference, never the token value, outside the package.
31
+ const config = join(temporary, 'npmrc')
32
+ await writeFile(config, `registry=${registry}\n//registry.npmjs.org/:_authToken=\${NPM_PUBLISH_TOKEN}\n`, { mode: 0o600 })
33
+ const env = dryRun ? { ...process.env } : { ...process.env, NPM_CONFIG_USERCONFIG: config }
34
+ function npm(args) {
35
+ const result = spawnSync('npm', args, { cwd: root, env, encoding: 'utf8' })
36
+ if (result.status !== 0) throw new Error(`npm ${args[0]} failed: ${result.stderr || result.error?.message}`)
37
+ return result.stdout
38
+ }
39
+ const [packed] = JSON.parse(npm(['pack', '--json', '--ignore-scripts', '--pack-destination', temporary]))
40
+ for (const required of ['LICENSE', 'NOTICE']) assert.ok(packed.files.some(({ path }) => path === required), `Missing ${required}`)
41
+ assert.ok(packed.files.every(({ path }) => /^(src\/|schemas\/|tests\/|scripts\/|package\.json$|README\.md$|LICENSE$|NOTICE$|CHANGELOG\.md$)/.test(path)),
42
+ 'Only the standalone library may be published')
43
+ if (dryRun) {
44
+ npm(['publish', join(temporary, packed.filename), '--dry-run', '--ignore-scripts', '--access', 'public', '--registry', registry])
45
+ console.log(`Dry run: ${packed.name}@${packed.version}; ${packed.files.length} files; no registry write`)
46
+ } else {
47
+ const publisher = npm(['whoami', '--registry', registry]).trim()
48
+ assert.equal(publisher, 'esp-vision', 'Npm publisher account must be esp-vision')
49
+ console.log(`Npm publisher: ${publisher}`)
50
+ const url = new URL(encodeURIComponent(manifest.name), registry)
51
+ url.pathname += '/' + encodeURIComponent(manifest.version)
52
+ const response = await fetch(url, { cache: 'no-store', headers: { 'Cache-Control': 'no-cache' }, signal: AbortSignal.timeout(15000) })
53
+ if (response.ok) {
54
+ const existing = await response.json()
55
+ assert.equal(existing.dist?.integrity, packed.integrity,
56
+ 'This version already exists with different content; bump the package version')
57
+ console.log(`${packed.name}@${packed.version} already published with identical content; skipped`)
58
+ } else {
59
+ assert.equal(response.status, 404, 'Registry lookup failed; do not attempt publication')
60
+ npm(['publish', join(temporary, packed.filename), '--ignore-scripts', '--access', 'public', '--registry', registry])
61
+ console.log('npm publish accepted; verifying registry metadata')
62
+ await verifyPublishedIntegrity(url, packed.integrity)
63
+ console.log(`Published and verified ${packed.name}@${packed.version}`)
64
+ }
65
+ }
66
+ } finally {
67
+ await rm(temporary, { recursive: true, force: true })
68
+ }
@@ -0,0 +1,16 @@
1
+ // SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import { readdir } from 'node:fs/promises'
4
+ import { spawnSync } from 'node:child_process'
5
+ import { fileURLToPath } from 'node:url'
6
+ import process from 'node:process'
7
+
8
+ const root = fileURLToPath(new URL('../', import.meta.url))
9
+ const tests = (await readdir(new URL('../tests/', import.meta.url)))
10
+ .filter(name => /^test-.*\.mjs$/.test(name)).sort()
11
+ if (!tests.length) throw new Error('No Device Core unit tests found; the package is incomplete')
12
+ const result = spawnSync(process.execPath, ['--test', ...process.argv.slice(2), ...tests.map(name => `tests/${name}`)], {
13
+ cwd: root, stdio: 'inherit',
14
+ })
15
+ if (result.error) throw result.error
16
+ process.exitCode = result.status ?? 1
@@ -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 }) => {