@esp-vision/device-core 0.1.0 → 0.1.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 +19 -17
- package/README.md +47 -12
- package/package.json +8 -4
- package/schemas/device-actions-v1.json +1 -1
- package/scripts/generate-action-schema.mjs +36 -0
- package/scripts/release-npm.mjs +67 -0
- package/scripts/run-tests.mjs +16 -0
- package/tests/check-package.mjs +84 -0
- package/tests/device-checks.mjs +155 -0
- package/tests/fixtures/device-checks.py +317 -0
- package/tests/hardware-check.mjs +140 -0
- package/tests/test-cli-output.mjs +31 -0
- package/tests/test-cli-ports.mjs +50 -0
- package/tests/test-cli.mjs +98 -0
- package/tests/test-device-actions.mjs +381 -0
- package/tests/test-device-api.mjs +184 -0
- package/tests/test-device-execution.mjs +284 -0
- package/tests/test-device-files-api.mjs +202 -0
- package/tests/test-device-firmware.mjs +133 -0
- package/tests/test-device-functional-checks.mjs +98 -0
- package/tests/test-device-hardware-cli.mjs +103 -0
- package/tests/test-device-operations-api.mjs +210 -0
- package/tests/test-device-python-api.mjs +194 -0
- package/tests/test-device-v4-alignment.mjs +189 -0
- package/tests/test-firmware-flash.mjs +89 -0
- package/tests/test-network-discovery.mjs +153 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,9 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## 0.1.1
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- Expose `tcpTransport` and `serialTransport` through `@esp-vision/device-core/node`.
|
|
8
|
+
- 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.
|
|
9
|
+
- Clarify `device.discover()` versus the provider's `list()` contract and document the shipped Node adapters and installed test commands.
|
|
10
|
+
|
|
11
|
+
## 0.1.0 — Initial public release
|
|
4
12
|
|
|
5
13
|
### Added
|
|
6
14
|
|
|
15
|
+
- Added browser-safe and optional Node LAN discovery interfaces, TTL-aware DNS-SD collection, and selection-time device identity validation before EV-MUX acquisition.
|
|
16
|
+
|
|
17
|
+
- Standalone, UI-independent EV-MUX v4 sessions, strict ownership/epoch filtering, framed REPL, files, execution, Wi-Fi controls and device-action contracts.
|
|
18
|
+
- Verified firmware-flash adapter with injected ROM loader, image/security/partition checks, complete readback and masked P4 download-latch reset.
|
|
19
|
+
- 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.
|
|
20
|
+
|
|
7
21
|
- 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
22
|
|
|
9
23
|
- 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 +37,14 @@
|
|
|
23
37
|
|
|
24
38
|
### Fixed
|
|
25
39
|
|
|
40
|
+
- Preserve split mDNS address announcements during cache flush, reclaim expired cache entries, bound packet retention, and close discovery sockets when cancellation occurs during setup.
|
|
41
|
+
- Soft reset requires a newer ready epoch and a real REPL barrier within one deadline; caller cancellation reaches all stages and device-action adapters.
|
|
42
|
+
- Bounded file reads reject incomplete/changing content; serial startup drain, TCP frame deadlines and keepalive recovery follow the v4 contract.
|
|
43
|
+
|
|
26
44
|
- Align discovery output by Unicode display width instead of tabs; fall back to aligned, wrapped device blocks on narrow terminals without truncating identifiers.
|
|
27
45
|
|
|
28
46
|
- Generate script completion inside the executing statement so `input()` receives only user input; retain output arriving in the same frame as completion.
|
|
29
47
|
- 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
48
|
- Clean up subscriptions, pending work and transports on connection failure or session loss, and isolate event-listener exceptions from protocol delivery.
|
|
31
49
|
|
|
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
50
|
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
|
|
22
|
+
The `/node` export and bundled checks below require 0.1.1, the next patch release prepared by this source tree; 0.1.0 remains the published baseline until the release pipeline succeeds. CI authenticates with the protected `NPM_PUBLISH_TOKEN` and verifies that `npm whoami` returns `esp-vision` before publishing. Tokens are not part of this package or its documentation.
|
|
23
23
|
|
|
24
24
|
```sh
|
|
25
|
-
npm install --save-exact @esp-vision/device-core@0.1.
|
|
25
|
+
npm install --save-exact @esp-vision/device-core@0.1.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.
|
|
@@ -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.
|
|
44
|
+
npm install -g @esp-vision/device-core@0.1.1
|
|
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.
|
|
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
|
-
|
|
117
|
-
|
|
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
|
-
|
|
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
|
|
|
@@ -284,14 +309,24 @@ The package includes library source, schemas, license and documentation, with no
|
|
|
284
309
|
|
|
285
310
|
## Hardware acceptance
|
|
286
311
|
|
|
287
|
-
|
|
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 -- --
|
|
292
|
-
npm run test:hardware -- --
|
|
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 -- --
|
|
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.
|
|
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.
|
|
3
|
+
"version": "0.1.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": {
|
|
@@ -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
|
|
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",
|
|
@@ -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,67 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 ESP-VISION-IDE
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
import assert from 'node:assert/strict'
|
|
4
|
+
import { spawnSync } from 'node:child_process'
|
|
5
|
+
import { mkdtemp, readFile, writeFile, rm } from 'node:fs/promises'
|
|
6
|
+
import { tmpdir } from 'node:os'
|
|
7
|
+
import { dirname, join, resolve } from 'node:path'
|
|
8
|
+
import { fileURLToPath } from 'node:url'
|
|
9
|
+
|
|
10
|
+
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
|
11
|
+
const registry = 'https://registry.npmjs.org/'
|
|
12
|
+
const dryRun = process.argv.includes('--dry-run')
|
|
13
|
+
assert.ok(process.argv.slice(2).every(arg => arg === '--dry-run'), 'Unknown release argument')
|
|
14
|
+
const manifest = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'))
|
|
15
|
+
assert.equal(manifest.name, '@esp-vision/device-core')
|
|
16
|
+
assert.match(manifest.version, /^\d+\.\d+\.\d+$/)
|
|
17
|
+
assert.equal(manifest.publishConfig?.registry, registry)
|
|
18
|
+
assert.equal(manifest.publishConfig?.access, 'public')
|
|
19
|
+
assert.equal(manifest.license, 'Apache-2.0')
|
|
20
|
+
if (!dryRun) {
|
|
21
|
+
assert.equal(process.env.CI_PIPELINE_SOURCE, 'push', 'Publish only from the merged default-branch pipeline')
|
|
22
|
+
assert.ok(process.env.CI_DEFAULT_BRANCH && process.env.CI_COMMIT_BRANCH === process.env.CI_DEFAULT_BRANCH,
|
|
23
|
+
'Publish only after manual merge to the default branch')
|
|
24
|
+
assert.equal(process.env.CI_COMMIT_REF_PROTECTED, 'true', 'Publishing requires a protected branch')
|
|
25
|
+
assert.ok(process.env.NPM_PUBLISH_TOKEN, 'Protected NPM_PUBLISH_TOKEN is missing')
|
|
26
|
+
}
|
|
27
|
+
const temporary = await mkdtemp(join(tmpdir(), 'esp-vision-core-release-'))
|
|
28
|
+
try {
|
|
29
|
+
// Store an environment reference, never the token value, outside the package.
|
|
30
|
+
const config = join(temporary, 'npmrc')
|
|
31
|
+
await writeFile(config, `registry=${registry}\n//registry.npmjs.org/:_authToken=\${NPM_PUBLISH_TOKEN}\n`, { mode: 0o600 })
|
|
32
|
+
const env = dryRun ? { ...process.env } : { ...process.env, NPM_CONFIG_USERCONFIG: config }
|
|
33
|
+
function npm(args) {
|
|
34
|
+
const result = spawnSync('npm', args, { cwd: root, env, encoding: 'utf8' })
|
|
35
|
+
if (result.status !== 0) throw new Error(`npm ${args[0]} failed: ${result.stderr || result.error?.message}`)
|
|
36
|
+
return result.stdout
|
|
37
|
+
}
|
|
38
|
+
const [packed] = JSON.parse(npm(['pack', '--json', '--ignore-scripts', '--pack-destination', temporary]))
|
|
39
|
+
for (const required of ['LICENSE', 'NOTICE']) assert.ok(packed.files.some(({ path }) => path === required), `Missing ${required}`)
|
|
40
|
+
assert.ok(packed.files.every(({ path }) => /^(src\/|schemas\/|tests\/|scripts\/|package\.json$|README\.md$|LICENSE$|NOTICE$|CHANGELOG\.md$)/.test(path)),
|
|
41
|
+
'Only the standalone library may be published')
|
|
42
|
+
if (dryRun) {
|
|
43
|
+
npm(['publish', join(temporary, packed.filename), '--dry-run', '--ignore-scripts', '--access', 'public', '--registry', registry])
|
|
44
|
+
console.log(`Dry run: ${packed.name}@${packed.version}; ${packed.files.length} files; no registry write`)
|
|
45
|
+
} else {
|
|
46
|
+
const publisher = npm(['whoami', '--registry', registry]).trim()
|
|
47
|
+
assert.equal(publisher, 'esp-vision', 'Npm publisher account must be esp-vision')
|
|
48
|
+
console.log(`Npm publisher: ${publisher}`)
|
|
49
|
+
const url = new URL(encodeURIComponent(manifest.name), registry)
|
|
50
|
+
url.pathname += '/' + encodeURIComponent(manifest.version)
|
|
51
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(15000) })
|
|
52
|
+
if (response.ok) {
|
|
53
|
+
const existing = await response.json()
|
|
54
|
+
assert.equal(existing.dist?.integrity, packed.integrity,
|
|
55
|
+
'This version already exists with different content; bump the package version')
|
|
56
|
+
console.log(`${packed.name}@${packed.version} already published with identical content; skipped`)
|
|
57
|
+
} else {
|
|
58
|
+
assert.equal(response.status, 404, 'Registry lookup failed; do not attempt publication')
|
|
59
|
+
npm(['publish', join(temporary, packed.filename), '--ignore-scripts', '--access', 'public', '--registry', registry])
|
|
60
|
+
const verified = JSON.parse(npm(['view', `${manifest.name}@${manifest.version}`, 'dist.integrity', '--json', '--registry', registry]))
|
|
61
|
+
assert.equal(verified, packed.integrity, 'Published tarball integrity mismatch')
|
|
62
|
+
console.log(`Published and verified ${packed.name}@${packed.version}`)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
} finally {
|
|
66
|
+
await rm(temporary, { recursive: true, force: true })
|
|
67
|
+
}
|
|
@@ -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,84 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 ESP-VISION-IDE
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
import assert from 'node:assert/strict'
|
|
4
|
+
import { execFileSync, spawnSync } from 'node:child_process'
|
|
5
|
+
import { mkdtemp, readFile, rm, mkdir } from 'node:fs/promises'
|
|
6
|
+
import { tmpdir } from 'node:os'
|
|
7
|
+
import { dirname, join, resolve } from 'node:path'
|
|
8
|
+
import { fileURLToPath } from 'node:url'
|
|
9
|
+
import process from 'node:process'
|
|
10
|
+
|
|
11
|
+
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
|
12
|
+
const manifest = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'))
|
|
13
|
+
assert.equal(manifest.license, 'Apache-2.0')
|
|
14
|
+
assert.match(await readFile(join(root, 'LICENSE'), 'utf8'), /Apache License[\s\S]*Version 2\.0/)
|
|
15
|
+
const temporary = await mkdtemp(join(tmpdir(), 'esp-vision-device-core-package-'))
|
|
16
|
+
try {
|
|
17
|
+
const [packed] = JSON.parse(execFileSync('npm', ['pack', '--json', '--ignore-scripts', '--pack-destination', temporary], { cwd: root, encoding: 'utf8' }))
|
|
18
|
+
assert.ok(packed.files.some(({ path }) => path === 'LICENSE'))
|
|
19
|
+
assert.ok(packed.files.some(({ path }) => path === 'NOTICE'))
|
|
20
|
+
assert.ok(packed.files.some(({ path }) => path === 'schemas/device-actions-v1.json'))
|
|
21
|
+
assert.ok(packed.files.every(({ path }) => /^(src\/|schemas\/|tests\/|scripts\/|package\.json$|README\.md$|LICENSE$|NOTICE$|CHANGELOG\.md$)/.test(path)), 'tarball must contain only the standalone library')
|
|
22
|
+
assert.ok(!packed.files.some(({ path }) => path.startsWith('docs/')), 'Documentation is published by the ESP-VISION documentation center')
|
|
23
|
+
assert.ok(!packed.files.some(({ path }) => path.includes('__pycache__') || path.endsWith('.pyc')), 'Never ship host Python bytecode')
|
|
24
|
+
const consumer = join(temporary, 'consumer')
|
|
25
|
+
await mkdir(consumer)
|
|
26
|
+
execFileSync('npm', ['install', '--offline', '--ignore-scripts', '--no-audit', '--no-fund', '--package-lock=false', join(temporary, packed.filename)], { cwd: consumer, stdio: 'pipe' })
|
|
27
|
+
const installed = join(consumer, 'node_modules/@esp-vision/device-core')
|
|
28
|
+
assert.equal(await readFile(join(installed, 'LICENSE'), 'utf8'), await readFile(join(root, 'LICENSE'), 'utf8'))
|
|
29
|
+
const notice = await readFile(join(installed, 'NOTICE'), 'utf8')
|
|
30
|
+
assert.ok(notice.includes('Copyright (c) 2024 Volodymyr Shymanskyy'))
|
|
31
|
+
assert.ok(notice.includes('Permission is hereby granted, free of charge'))
|
|
32
|
+
const entryPoints = Object.keys(manifest.exports).map(key => manifest.name + (key === '.' ? '' : key.slice(1)))
|
|
33
|
+
const script = `for (const name of ${JSON.stringify(entryPoints)}) {
|
|
34
|
+
const module = await import(name);
|
|
35
|
+
if (!Object.keys(module).length) throw new Error('Empty entry point: ' + name);
|
|
36
|
+
}
|
|
37
|
+
const api = await import('@esp-vision/device-core');
|
|
38
|
+
if (Object.keys(api).sort().join(',') !== 'createDevice') throw new Error('Root must expose only the application API');
|
|
39
|
+
const { DeviceSession } = await import('@esp-vision/device-core/session');
|
|
40
|
+
const { FirmwareFlashSession } = await import('@esp-vision/device-core/firmware');
|
|
41
|
+
const { tcpTransport, serialTransport } = await import('@esp-vision/device-core/node');
|
|
42
|
+
if (typeof tcpTransport !== 'function' || typeof serialTransport !== 'function') throw new Error('Missing public Node transports');
|
|
43
|
+
const { createNodeDiscovery } = await import('@esp-vision/device-core/discovery/node');
|
|
44
|
+
const { EventEmitter } = await import('node:events');
|
|
45
|
+
let queries = 0;
|
|
46
|
+
const provider = createNodeDiscovery({ createMdns() {
|
|
47
|
+
const mdns = new EventEmitter(); mdns.query = () => { queries++ }; mdns.destroy = () => {}; return mdns;
|
|
48
|
+
} });
|
|
49
|
+
const device = api.createDevice({ discovery: provider });
|
|
50
|
+
if (typeof provider.list !== 'function') throw new Error('Discovery providers must expose list()');
|
|
51
|
+
if ((await device.discover({ timeout: 50 })).length !== 0 || !queries) throw new Error('Device discovery did not call its provider');
|
|
52
|
+
// Constructors must be passive: these endpoints need not exist to create adapters.
|
|
53
|
+
api.createDevice({ transport: tcpTransport({ host: '127.0.0.1', port: 1 }) });
|
|
54
|
+
api.createDevice({ transport: serialTransport({ python: 'python3', serial: 'not-opened' }) });
|
|
55
|
+
if (typeof DeviceSession !== 'function' || typeof FirmwareFlashSession !== 'function') throw new Error('Missing host adapter APIs');`
|
|
56
|
+
execFileSync(process.execPath, ['--input-type=module', '-e', script], { cwd: consumer, stdio: 'pipe' })
|
|
57
|
+
assert.equal(manifest.bin['esp-vision-device'], './src/cli.js')
|
|
58
|
+
const help = execFileSync(process.execPath, [join(consumer, 'node_modules/@esp-vision/device-core/src/cli.js'), '--help'], { encoding: 'utf8' })
|
|
59
|
+
assert.ok(help.includes('esp-vision-device inspect'))
|
|
60
|
+
if (process.platform !== 'win32') {
|
|
61
|
+
const installedHelp = execFileSync(join(consumer, 'node_modules/.bin/esp-vision-device'), ['--help'], { encoding: 'utf8' })
|
|
62
|
+
assert.ok(installedHelp.includes('esp-vision-device inspect'), 'The installed npm command must execute through its symlink')
|
|
63
|
+
}
|
|
64
|
+
for (const required of ['src/node_serial.py', 'scripts/run-tests.mjs', 'tests/hardware-check.mjs', 'tests/device-checks.mjs', 'tests/fixtures/device-checks.py']) {
|
|
65
|
+
assert.ok(packed.files.some(({ path }) => path === required), `Missing installed check asset: ${required}`)
|
|
66
|
+
}
|
|
67
|
+
const unitOutput = execFileSync('npm', ['test'], { cwd: installed, encoding: 'utf8', maxBuffer: 4 * 1024 * 1024 })
|
|
68
|
+
const unitCount = Number(/^# tests (\d+)$/m.exec(unitOutput)?.[1] ?? 0)
|
|
69
|
+
assert.ok(unitCount > 0, 'Installed npm test must execute a nonempty suite')
|
|
70
|
+
const hardwareHelp = execFileSync('npm', ['run', 'test:hardware', '--', '--help'], { cwd: installed, encoding: 'utf8' })
|
|
71
|
+
assert.ok(hardwareHelp.includes('--serial PATH'), 'Installed hardware runner must load and show its help')
|
|
72
|
+
// A damaged tarball must fail instead of reporting a successful zero-test run.
|
|
73
|
+
await rm(join(installed, 'tests'), { recursive: true })
|
|
74
|
+
await mkdir(join(installed, 'tests'))
|
|
75
|
+
const empty = spawnSync('npm', ['test'], { cwd: installed, encoding: 'utf8' })
|
|
76
|
+
assert.equal(empty.status, 1, 'An empty suite must fail closed')
|
|
77
|
+
assert.match(empty.stderr, /No Device Core unit tests found/)
|
|
78
|
+
await rm(join(installed, 'tests'), { recursive: true })
|
|
79
|
+
const missing = spawnSync('npm', ['test'], { cwd: installed, encoding: 'utf8' })
|
|
80
|
+
assert.equal(missing.status, 1, 'Missing tests must fail closed')
|
|
81
|
+
console.log(JSON.stringify({ package: packed.name, version: packed.version, entryPoints, unitCount, standalone: true }))
|
|
82
|
+
} finally {
|
|
83
|
+
await rm(temporary, { recursive: true, force: true })
|
|
84
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Application-level HIL checks. All device I/O goes through createDevice's API.
|
|
3
|
+
import { readFile, mkdir, writeFile } from 'node:fs/promises'
|
|
4
|
+
import { dirname } from 'node:path'
|
|
5
|
+
|
|
6
|
+
export const DEVICE_CHECKS = Object.freeze(['identity', 'memory', 'camera', 'framesizes', 'pixformats', 'orientation', 'ipa_stats', 'snapshot_rate', 'preview', 'image_ops', 'display', 'display_stream'])
|
|
7
|
+
const MARKER = '__EVDEVICECHECK__'
|
|
8
|
+
const failure = (code, message) => Object.assign(new Error(message), { code })
|
|
9
|
+
const unsupported = error => error?.code === 'UNSUPPORTED_OPERATION' || /ESP_ERR_NOT_SUPPORTED|ESP_ERR_NOT_FOUND|no module named|ENODEV/i.test(error?.message ?? '')
|
|
10
|
+
function cancelled(signal) { if (signal?.aborted) throw signal.reason instanceof Error ? signal.reason : failure('CANCELLED', 'Device checks cancelled') }
|
|
11
|
+
|
|
12
|
+
export function parseCheckResult(output, name) {
|
|
13
|
+
const lines = output.split(/\r?\n/).filter(line => line.startsWith(MARKER))
|
|
14
|
+
if (lines.length !== 1) throw failure('CHECK_REPORT_INVALID', 'Expected exactly one device check report')
|
|
15
|
+
let result
|
|
16
|
+
try { result = JSON.parse(lines[0].slice(MARKER.length)) } catch { throw failure('CHECK_REPORT_INVALID', 'Invalid device check JSON') }
|
|
17
|
+
if (!result || result.name !== name || !['PASS', 'SKIP', 'FAIL'].includes(result.status)) throw failure('CHECK_REPORT_INVALID', 'Unexpected device check result')
|
|
18
|
+
if (result.status === 'PASS' && (!result.data || typeof result.data !== 'object' || Array.isArray(result.data))) throw failure('CHECK_REPORT_INVALID', 'Missing device measurements')
|
|
19
|
+
if (result.status !== 'PASS' && (!result.error || typeof result.error.message !== 'string')) throw failure('CHECK_REPORT_INVALID', 'Missing device failure details')
|
|
20
|
+
return result
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function saveJpeg(image, output) {
|
|
24
|
+
const data = image?.data
|
|
25
|
+
if (image?.mimeType !== 'image/jpeg' || !(data instanceof Uint8Array) || data.length < 4 || data[0] !== 255 || data[1] !== 216 || data.at(-2) !== 255 || data.at(-1) !== 217) throw failure('CAPTURE_INVALID', 'Capture is not a complete JPEG')
|
|
26
|
+
if (!(image.metadata?.width > 0 && image.metadata?.height > 0)) throw failure('CAPTURE_INVALID', 'Capture has no valid image dimensions')
|
|
27
|
+
if (output) { await mkdir(dirname(output), { recursive: true }); await writeFile(output, data) }
|
|
28
|
+
return { bytes: data.length, width: image.metadata.width, height: image.metadata.height }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function pause(milliseconds, signal) {
|
|
32
|
+
return new Promise((resolve, reject) => {
|
|
33
|
+
const cleanup = () => { clearTimeout(timer); signal?.removeEventListener('abort', abort) }
|
|
34
|
+
const abort = () => { cleanup(); reject(signal.reason ?? failure('CANCELLED', 'Device check cancelled')) }
|
|
35
|
+
const timer = setTimeout(() => { cleanup(); resolve() }, milliseconds)
|
|
36
|
+
signal?.addEventListener('abort', abort, { once: true })
|
|
37
|
+
if (signal?.aborted) abort()
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function runDeviceChecks(device, options, { signal, nonce, report = value => console.log(JSON.stringify(value)) } = {}) {
|
|
42
|
+
const results = [], fixturePath = `/evhil_${nonce}.py`, namespace = `_evhil_${nonce}`
|
|
43
|
+
let ownsFixture = false, loaded = false, namespaceStarted = false, loadError = null
|
|
44
|
+
const parameters = { frames: options.frames, framesize: options.framesize, pixformat: options.pixformat, displayHold: options.displayHold, screenCapture: Boolean(options.screenOutput) }
|
|
45
|
+
const timeoutFor = name => 1000 * (name === 'display' ? 30 + options.displayHold * 3 : name === 'ipa_stats' ? options.frameTimeout + 40 : options.frameTimeout + Math.max(30, options.frames * (name === 'display_stream' ? 2 : 1)))
|
|
46
|
+
const execute = async (source, timeout = 10000, operationSignal = signal) => {
|
|
47
|
+
const result = await device.python.execute({ source, timeout, maxOutputChars: 16384, signal: operationSignal })
|
|
48
|
+
if (result.ok === false || result.outputTruncated) throw failure('PYTHON_CHECK', 'Device Python check did not complete')
|
|
49
|
+
return result.output
|
|
50
|
+
}
|
|
51
|
+
const load = async () => {
|
|
52
|
+
if (loaded) return
|
|
53
|
+
if (loadError) throw loadError
|
|
54
|
+
try {
|
|
55
|
+
const entries = await device.files.list({ path: '/', signal })
|
|
56
|
+
if (entries.some(entry => [fixturePath, `${fixturePath}.tmp`].includes(entry.path))) throw failure('TEST_PATH_EXISTS', 'Fixture path already exists')
|
|
57
|
+
const source = await readFile(new URL('./fixtures/device-checks.py', import.meta.url), 'utf8')
|
|
58
|
+
ownsFixture = true
|
|
59
|
+
await device.files.write({ path: fixturePath, data: source, overwrite: false, signal })
|
|
60
|
+
namespaceStarted = true
|
|
61
|
+
const loader = `with open(${JSON.stringify(fixturePath)}) as _fixture:\n exec(_fixture.read(),globals())`
|
|
62
|
+
await execute(`${namespace}={};exec(${JSON.stringify(loader)},${namespace})`)
|
|
63
|
+
loaded = true
|
|
64
|
+
await device.files.remove({ path: fixturePath, signal })
|
|
65
|
+
} catch (error) { loadError = error; throw error }
|
|
66
|
+
}
|
|
67
|
+
const invoke = async name => {
|
|
68
|
+
await load()
|
|
69
|
+
const output = await execute(`${namespace}['run_check'](${JSON.stringify(name)},${JSON.stringify(JSON.stringify(parameters))})`, timeoutFor(name))
|
|
70
|
+
return parseCheckResult(output, name)
|
|
71
|
+
}
|
|
72
|
+
const requirePass = result => {
|
|
73
|
+
if (result.status !== 'PASS') throw Object.assign(failure(result.status === 'SKIP' ? 'UNSUPPORTED_OPERATION' : 'DEVICE_CHECK_FAILED', result.error.message), { checkResult: result })
|
|
74
|
+
return result.data
|
|
75
|
+
}
|
|
76
|
+
const record = result => { results.push(result); report({ check: result.name, ...result, name: undefined }) }
|
|
77
|
+
try {
|
|
78
|
+
for (const name of options.checks) {
|
|
79
|
+
cancelled(signal)
|
|
80
|
+
const started = Date.now()
|
|
81
|
+
let result
|
|
82
|
+
try {
|
|
83
|
+
let data
|
|
84
|
+
if (name === 'identity') {
|
|
85
|
+
const info = await device.inspect({ fields: ['identity', 'capabilities', 'runtime'], signal })
|
|
86
|
+
if (!info.identity?.board || !info.identity.firmwareVersion || info.capabilities?.protocol?.evMuxVersion !== 4 || info.runtime?.vmReady !== true) throw failure('IDENTITY_INVALID', 'Expected a ready EV-MUX v4 firmware identity')
|
|
87
|
+
data = { identity: info.identity, features: info.capabilities.features, vmReady: info.runtime.vmReady }
|
|
88
|
+
} else if (name === 'camera') {
|
|
89
|
+
const image = await device.capture({ source: 'camera', format: 'jpeg', quality: options.quality, timeout: options.frameTimeout * 1000, signal })
|
|
90
|
+
data = await saveJpeg(image, options.output)
|
|
91
|
+
data.sensor = requirePass(await invoke('sensor_state'))
|
|
92
|
+
} else if (name === 'preview') {
|
|
93
|
+
let count = 0
|
|
94
|
+
const unsubscribe = device.on('preview', image => { if (image.data?.length) count++ })
|
|
95
|
+
try {
|
|
96
|
+
data = requirePass(await invoke(name))
|
|
97
|
+
await pause(2000, signal)
|
|
98
|
+
if (!count) throw failure('PREVIEW_EMPTY', 'No preview frame arrived')
|
|
99
|
+
data.received = count
|
|
100
|
+
} finally { unsubscribe() }
|
|
101
|
+
} else {
|
|
102
|
+
const response = await invoke(name)
|
|
103
|
+
if (name === 'ipa_stats' && response.status === 'SKIP') {
|
|
104
|
+
const info = await device.inspect({ fields: ['identity'], signal })
|
|
105
|
+
const board = (info.identity?.board ?? '').toUpperCase().replaceAll('-', '_')
|
|
106
|
+
if (['ESP32_P4X_EYE', 'ESP32_P4X_FUNCTION_EV_BOARD'].includes(board)) throw failure('IPA_REQUIRED', 'IPA statistics are required on this board')
|
|
107
|
+
}
|
|
108
|
+
data = requirePass(response)
|
|
109
|
+
if (name === 'display' && options.screenOutput) {
|
|
110
|
+
const image = await device.capture({ source: 'screen', format: 'jpeg', quality: options.quality, timeout: options.frameTimeout * 1000, signal })
|
|
111
|
+
const captured = await saveJpeg(image, options.screenOutput)
|
|
112
|
+
if (captured.width !== data.width || captured.height !== data.height) throw failure('SCREEN_SIZE_MISMATCH', 'LCD capture dimensions differ from display dimensions')
|
|
113
|
+
data.screen = captured
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
result = { name, status: 'PASS', data }
|
|
117
|
+
} catch (error) {
|
|
118
|
+
cancelled(signal)
|
|
119
|
+
const skip = unsupported(error) && !(name === 'display' && options.screenOutput)
|
|
120
|
+
result = { name, status: skip ? 'SKIP' : 'FAIL', error: { code: error.code ?? 'DEVICE_CHECK_FAILED', message: String(error.message ?? 'Device check failed').slice(0, 240) } }
|
|
121
|
+
if (error.code === 'WRITE_FAILED') {
|
|
122
|
+
try {
|
|
123
|
+
const status = await device.inspect({ fields: ['storage'], timeout: 3000, signal })
|
|
124
|
+
result.storage = Object.fromEntries(Object.entries(status.storage).map(([volume, state]) => [volume, { present: state.present, mounted: state.mounted, status: state.status, writable: state.writable, reason: state.reason }]))
|
|
125
|
+
} catch { /* Preserve the upload failure. */ }
|
|
126
|
+
}
|
|
127
|
+
} finally {
|
|
128
|
+
if (name === 'display' && loaded) {
|
|
129
|
+
try { await execute(`${namespace}['close_display']()`, 5000, AbortSignal.timeout(6000)) }
|
|
130
|
+
catch { result = { name, status: 'FAIL', error: { code: 'DISPLAY_CLEANUP_FAILED', message: 'Could not release the test display' } } }
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
result.seconds = (Date.now() - started) / 1000
|
|
134
|
+
record(result)
|
|
135
|
+
}
|
|
136
|
+
} finally {
|
|
137
|
+
const cleanupSignal = AbortSignal.timeout(10000)
|
|
138
|
+
let cleanupError
|
|
139
|
+
if (namespaceStarted) {
|
|
140
|
+
try { await execute(`if ${JSON.stringify(namespace)} in globals():\n ${namespace}.get('close_display',lambda:None)()\n del ${namespace}`, 5000, cleanupSignal) }
|
|
141
|
+
catch (error) { cleanupError = error }
|
|
142
|
+
}
|
|
143
|
+
if (ownsFixture) {
|
|
144
|
+
try {
|
|
145
|
+
const entries = await device.files.list({ path: '/', signal: cleanupSignal })
|
|
146
|
+
for (const entry of entries) if ([fixturePath, `${fixturePath}.tmp`].includes(entry.path)) await device.files.remove({ path: entry.path, signal: cleanupSignal })
|
|
147
|
+
} catch (error) { cleanupError ??= error }
|
|
148
|
+
}
|
|
149
|
+
if (cleanupError) record({ name: 'fixture-cleanup', status: 'FAIL', error: { code: 'FIXTURE_CLEANUP_FAILED', message: 'Could not clean up the hardware fixture', path: fixturePath } })
|
|
150
|
+
}
|
|
151
|
+
const summary = { passed: results.filter(result => result.status === 'PASS').length, failed: results.filter(result => result.status === 'FAIL').length, skipped: results.filter(result => result.status === 'SKIP').length }
|
|
152
|
+
report({ check: 'summary', ...summary })
|
|
153
|
+
if (summary.failed) throw Object.assign(failure('DEVICE_CHECKS_FAILED', 'One or more device checks failed'), { results })
|
|
154
|
+
return results
|
|
155
|
+
}
|