@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 +29 -17
- package/README.md +48 -13
- package/package.json +8 -4
- package/schemas/device-actions-v1.json +2 -2
- package/scripts/generate-action-schema.mjs +36 -0
- package/scripts/release-npm.mjs +68 -0
- package/scripts/run-tests.mjs +16 -0
- package/scripts/verify-registry.mjs +36 -0
- package/src/device_actions.js +1 -1
- package/src/device_bindings.js +3 -12
- package/src/device_session.js +56 -63
- package/src/evmux.js +4 -3
- 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 +294 -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-file-binary.mjs +112 -0
- package/tests/test-firmware-flash.mjs +89 -0
- package/tests/test-network-discovery.mjs +153 -0
- package/tests/test-release-verification.mjs +58 -0
package/src/device_session.js
CHANGED
|
@@ -47,6 +47,7 @@ const EV_MUX_USER_RPC_METHODS = new Set([
|
|
|
47
47
|
'capabilities',
|
|
48
48
|
'session.keepalive',
|
|
49
49
|
'session.release',
|
|
50
|
+
'file.write',
|
|
50
51
|
'script.write',
|
|
51
52
|
'script.run',
|
|
52
53
|
'device.control',
|
|
@@ -146,7 +147,7 @@ function isNetworkAction(method, payload) {
|
|
|
146
147
|
}
|
|
147
148
|
|
|
148
149
|
function isVmRpc(method, payload) {
|
|
149
|
-
if (method === 'script.write' || method === 'script.run' || method === 'debug.capture_frame' || method === 'debug.capture_screen') return true
|
|
150
|
+
if (method === 'file.write' || method === 'script.write' || method === 'script.run' || method === 'debug.capture_frame' || method === 'debug.capture_screen') return true
|
|
150
151
|
if (method === 'debug.info') return payload?.scope !== 'transport.stats'
|
|
151
152
|
return false
|
|
152
153
|
}
|
|
@@ -155,30 +156,6 @@ function payloadBytes(payload) {
|
|
|
155
156
|
return payload instanceof Uint8Array ? payload : utf8Bytes(payload)
|
|
156
157
|
}
|
|
157
158
|
|
|
158
|
-
function maxScriptChunkBytes({ path, encoding, mode, runAfterWrite, rxPayloadMax, totalBytes }) {
|
|
159
|
-
let low = 1
|
|
160
|
-
let high = Math.max(1, Math.min(EV_MUX_SCRIPT_CHUNK_BYTES, rxPayloadMax))
|
|
161
|
-
while (low < high) {
|
|
162
|
-
const candidate = Math.ceil((low + high) / 2)
|
|
163
|
-
const payload = {
|
|
164
|
-
path, encoding, mode, offset: Math.max(0, totalBytes - 1), totalBytes,
|
|
165
|
-
contentBase64: 'A'.repeat(Math.ceil(candidate / 3) * 4), runAfterWrite,
|
|
166
|
-
}
|
|
167
|
-
if (utf8Bytes(JSON.stringify(payload)).length <= rxPayloadMax) low = candidate
|
|
168
|
-
else high = candidate - 1
|
|
169
|
-
}
|
|
170
|
-
return low
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
function bytesToBase64(bytes) {
|
|
174
|
-
let binary = ''
|
|
175
|
-
const chunkSize = 0x8000
|
|
176
|
-
for (let i = 0; i < bytes.length; i += chunkSize) {
|
|
177
|
-
binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize))
|
|
178
|
-
}
|
|
179
|
-
return btoa(binary)
|
|
180
|
-
}
|
|
181
|
-
|
|
182
159
|
function defaultSleep(ms) {
|
|
183
160
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
184
161
|
}
|
|
@@ -262,6 +239,11 @@ export function validateEvmuxCapabilities(payload) {
|
|
|
262
239
|
}
|
|
263
240
|
|
|
264
241
|
for (const key of Object.keys(EV_MUX_DEFAULT_LIMITS)) validateLimit(payload.limits, key)
|
|
242
|
+
if (payload.features.includes('file.write.binary') && (!Number.isSafeInteger(payload.limits.fileWriteChunkMax)
|
|
243
|
+
|| payload.limits.fileWriteChunkMax < 4096 || payload.limits.fileWriteChunkMax > 16384
|
|
244
|
+
|| payload.limits.fileWriteChunkMax % 4096 !== 0)) {
|
|
245
|
+
throw new Error('EV-MUX v4 capabilities are missing a valid limits.fileWriteChunkMax')
|
|
246
|
+
}
|
|
265
247
|
if (payload.session?.policy !== 'firstCome'
|
|
266
248
|
|| !Number.isSafeInteger(payload.session.leaseMs)
|
|
267
249
|
|| !Number.isSafeInteger(payload.session.keepaliveMs)
|
|
@@ -622,6 +604,7 @@ export class DeviceSession {
|
|
|
622
604
|
redactSsid = true,
|
|
623
605
|
forceNetwork = false,
|
|
624
606
|
} = {}) {
|
|
607
|
+
if (payload instanceof Uint8Array) return { binaryBytes: payload.byteLength }
|
|
625
608
|
if (!forceNetwork && !isNetworkAction(method, payload) && !isNetworkAction(method, requestPayload)) return payload
|
|
626
609
|
const secrets = [requestPayload?.ssid, requestPayload?.password]
|
|
627
610
|
.filter((value) => typeof value === 'string' && value.length > 0)
|
|
@@ -687,6 +670,7 @@ export class DeviceSession {
|
|
|
687
670
|
seq: requestedSeq = null,
|
|
688
671
|
signal = null,
|
|
689
672
|
writeTimeout = null,
|
|
673
|
+
encoding = undefined,
|
|
690
674
|
} = {}) {
|
|
691
675
|
if (this.state === 'disconnected') {
|
|
692
676
|
throw new Error('EV-MUX is not connected')
|
|
@@ -715,7 +699,9 @@ export class DeviceSession {
|
|
|
715
699
|
&& (payloadData.length !== 1 || payloadData[0] !== 0x03)) {
|
|
716
700
|
throw new Error('EV-MUX repl.signal only accepts the single-byte Ctrl-C signal 0x03')
|
|
717
701
|
}
|
|
718
|
-
|
|
702
|
+
const payloadLimit = method === 'file.write' && encoding === 'binary' && this.hasFeature('file.write.binary')
|
|
703
|
+
? this.limits.fileWriteChunkMax + 20 : this.limits.rxPayloadMax
|
|
704
|
+
if (this.capabilities && payloadData.length > payloadLimit) {
|
|
719
705
|
throw new Error(`EV-MUX payload is ${payloadData.length} bytes; device limit is ${this.limits.rxPayloadMax}`)
|
|
720
706
|
}
|
|
721
707
|
if ((channel === 'repl.stdin' || channel === 'repl.signal') && payloadData.length > this.limits.replInputMax) {
|
|
@@ -727,7 +713,7 @@ export class DeviceSession {
|
|
|
727
713
|
seq,
|
|
728
714
|
channel,
|
|
729
715
|
type: frameType,
|
|
730
|
-
encoding: channel.endsWith('.rpc') ? 'json' : 'text',
|
|
716
|
+
encoding: encoding ?? (channel.endsWith('.rpc') ? 'json' : 'text'),
|
|
731
717
|
...(method ? { method } : {}),
|
|
732
718
|
...(needsAuthorization ? { sessionId: this.sessionId } : {}),
|
|
733
719
|
...(usesVm && needsAuthorization ? { vmEpoch: this.vmEpoch } : {}),
|
|
@@ -738,6 +724,7 @@ export class DeviceSession {
|
|
|
738
724
|
const frame = this.makeEvMuxFrame({
|
|
739
725
|
channel,
|
|
740
726
|
type: frameType,
|
|
727
|
+
encoding,
|
|
741
728
|
method,
|
|
742
729
|
payload,
|
|
743
730
|
seq,
|
|
@@ -764,6 +751,7 @@ export class DeviceSession {
|
|
|
764
751
|
throw new Error('EV-MUX is not connected')
|
|
765
752
|
}
|
|
766
753
|
if (signal?.aborted) throw abortError(signal)
|
|
754
|
+
if (payload instanceof Uint8Array && method !== 'file.write') throw new TypeError('Raw binary requests are only supported for file.write')
|
|
767
755
|
if (method === 'script.write' || method === 'script.run') validateRpcPath(payload?.path)
|
|
768
756
|
if (method === 'debug.info' && (payload?.scope === 'fs.list' || payload?.scope === 'fs.read')) {
|
|
769
757
|
validateRpcPath(payload.path ?? (payload.scope === 'fs.list' ? '/' : undefined))
|
|
@@ -816,7 +804,8 @@ export class DeviceSession {
|
|
|
816
804
|
this.handlers.rpcRequest?.({ channel: rpcChannel, method, seq, timeout, payload: safePayload })
|
|
817
805
|
this._diagnostic('rpc.request', { channel: rpcChannel, method, seq })
|
|
818
806
|
try {
|
|
819
|
-
await this.writeFrame(rpcChannel, 'req', method, JSON.stringify(payload), {
|
|
807
|
+
await this.writeFrame(rpcChannel, 'req', method, payload instanceof Uint8Array ? payload : JSON.stringify(payload), {
|
|
808
|
+
encoding: payload instanceof Uint8Array ? 'binary' : undefined,
|
|
820
809
|
vmRequest,
|
|
821
810
|
seq,
|
|
822
811
|
signal,
|
|
@@ -1089,41 +1078,45 @@ export class DeviceSession {
|
|
|
1089
1078
|
: content instanceof Uint8Array
|
|
1090
1079
|
? content
|
|
1091
1080
|
: new Uint8Array(content)
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
}
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1081
|
+
if (!this.hasFeature('file.write.binary')) {
|
|
1082
|
+
throw Object.assign(new Error('File upload requires firmware with file.write.binary; update the device firmware'), { code: 'UNSUPPORTED_OPERATION' })
|
|
1083
|
+
}
|
|
1084
|
+
const limit = this.limits.fileWriteChunkMax
|
|
1085
|
+
if (!Number.isSafeInteger(limit) || limit < 4096 || limit > 16384 || limit % 4096 !== 0 || bytes.length > 0xffffffff) {
|
|
1086
|
+
throw new Error('Invalid negotiated binary file transfer limit or file size')
|
|
1087
|
+
}
|
|
1088
|
+
const digest = new Uint8Array(await globalThis.crypto.subtle.digest('SHA-256', bytes))
|
|
1089
|
+
if (signal?.aborted) throw abortError(signal)
|
|
1090
|
+
const sha256 = Array.from(digest, byte => byte.toString(16).padStart(2, '0')).join('')
|
|
1091
|
+
const uploadId = randomHex()
|
|
1092
|
+
let committed = false
|
|
1093
|
+
try {
|
|
1094
|
+
const begin = await this.rpc('file.write', { action: 'begin', path, mode, totalBytes: bytes.length, sha256, uploadId }, { timeout: 8000, signal })
|
|
1095
|
+
if (begin.uploadId !== uploadId || begin.chunkBytes !== limit) throw new Error('Invalid binary file transfer acknowledgement')
|
|
1096
|
+
const token = Uint8Array.from(begin.uploadId.match(/../g), byte => parseInt(byte, 16))
|
|
1097
|
+
for (let offset = 0; offset < bytes.length; offset += limit) {
|
|
1098
1098
|
if (signal?.aborted) throw abortError(signal)
|
|
1099
|
-
const chunk = bytes.subarray(offset, Math.min(offset +
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
contentBase64: bytesToBase64(chunk),
|
|
1107
|
-
runAfterWrite: runAfterWrite && offset + chunk.length >= bytes.length,
|
|
1108
|
-
}, { timeout: 8000, signal })
|
|
1099
|
+
const chunk = bytes.subarray(offset, Math.min(offset + limit, bytes.length))
|
|
1100
|
+
const payload = new Uint8Array(20 + chunk.length)
|
|
1101
|
+
payload.set(token)
|
|
1102
|
+
new DataView(payload.buffer).setUint32(16, offset, true)
|
|
1103
|
+
payload.set(chunk, 20)
|
|
1104
|
+
const response = await this.rpc('file.write', payload, { timeout: 8000, signal })
|
|
1105
|
+
if (response.writtenBytes !== offset + chunk.length) throw new Error('Incomplete binary file transfer acknowledgement')
|
|
1109
1106
|
onProgress?.({ writtenBytes: offset + chunk.length, totalBytes: bytes.length })
|
|
1110
1107
|
}
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
path,
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
throw new Error(`script.write payload exceeds the device RX limit ${this.limits.rxPayloadMax}; firmware must support script.write.chunked.`)
|
|
1108
|
+
const result = await this.rpc('file.write', { action: 'commit', uploadId: begin.uploadId }, { timeout: 8000, signal })
|
|
1109
|
+
if (result.complete !== true || result.bytes !== bytes.length) throw new Error('Incomplete binary file commit acknowledgement')
|
|
1110
|
+
committed = true
|
|
1111
|
+
if (bytes.length === 0) onProgress?.({ writtenBytes: 0, totalBytes: 0 })
|
|
1112
|
+
if (runAfterWrite) await this.rpc('script.run', { path }, { timeout: 8000, signal })
|
|
1113
|
+
return { ...result, path, runAfterWrite }
|
|
1114
|
+
} finally {
|
|
1115
|
+
if (!committed) {
|
|
1116
|
+
// Cancellation does not cancel cleanup, and cleanup cannot mask the primary error.
|
|
1117
|
+
await this.rpc('file.write', { action: 'abort', uploadId }, { timeout: 1500 }).catch(() => {})
|
|
1118
|
+
}
|
|
1123
1119
|
}
|
|
1124
|
-
const response = await this.rpc('script.write', payload, { timeout: 8000, signal })
|
|
1125
|
-
onProgress?.({ writtenBytes: bytes.length, totalBytes: bytes.length })
|
|
1126
|
-
return response
|
|
1127
1120
|
}
|
|
1128
1121
|
|
|
1129
1122
|
async writeScript(path, content, { runAfterWrite = false, signal = null } = {}) {
|
|
@@ -1217,10 +1210,10 @@ export class DeviceSession {
|
|
|
1217
1210
|
if (commandBytes <= inputLimit) {
|
|
1218
1211
|
return this.repl.execPy(source, { timeout, signal })
|
|
1219
1212
|
}
|
|
1220
|
-
if (!this.hasFeature('
|
|
1213
|
+
if (!this.hasFeature('file.write.binary') || !this.hasFeature('script.run')) {
|
|
1221
1214
|
throw new Error(
|
|
1222
1215
|
`Python source requires ${commandBytes} REPL bytes, above the firmware limit ${inputLimit}; ` +
|
|
1223
|
-
'upgrade firmware with
|
|
1216
|
+
'upgrade firmware with file.write.binary and script.run support.',
|
|
1224
1217
|
)
|
|
1225
1218
|
}
|
|
1226
1219
|
|
|
@@ -1365,8 +1358,8 @@ export class DeviceSession {
|
|
|
1365
1358
|
if (timeout !== null && (!Number.isFinite(timeout) || timeout <= 0)) {
|
|
1366
1359
|
throw new RangeError('Script timeout must be a positive number or null for an interactive run')
|
|
1367
1360
|
}
|
|
1368
|
-
if ((content !== undefined && !this.hasFeature('
|
|
1369
|
-
throw new Error('Running scripts requires script.run and uploading source also requires
|
|
1361
|
+
if ((content !== undefined && !this.hasFeature('file.write.binary')) || !this.hasFeature('script.run')) {
|
|
1362
|
+
throw new Error('Running scripts requires script.run and uploading source also requires file.write.binary')
|
|
1370
1363
|
}
|
|
1371
1364
|
if (this.runCapture || this.runStarting) {
|
|
1372
1365
|
throw new Error('A script is already running')
|
package/src/evmux.js
CHANGED
|
@@ -389,14 +389,14 @@ export class EvMuxParser {
|
|
|
389
389
|
}
|
|
390
390
|
}
|
|
391
391
|
|
|
392
|
-
export function makeEvMuxFrame({ channel, type = 'data', method = null, payload = '', seq = 0, sid = 'web-ide', sessionId = undefined, vmEpoch = undefined, contentType = undefined, width = undefined, height = undefined, route = undefined }) {
|
|
392
|
+
export function makeEvMuxFrame({ channel, type = 'data', method = null, payload = '', seq = 0, sid = 'web-ide', sessionId = undefined, vmEpoch = undefined, contentType = undefined, width = undefined, height = undefined, route = undefined, encoding = undefined }) {
|
|
393
393
|
const payloadBytes = payload instanceof Uint8Array ? payload : new TextEncoder().encode(payload)
|
|
394
394
|
const metadata = {
|
|
395
395
|
sid,
|
|
396
396
|
seq,
|
|
397
397
|
channel,
|
|
398
398
|
type,
|
|
399
|
-
encoding: channel.endsWith('.rpc') ? 'json' : 'text',
|
|
399
|
+
encoding: encoding ?? (channel.endsWith('.rpc') ? 'json' : 'text'),
|
|
400
400
|
...(sessionId ? { sessionId } : {}),
|
|
401
401
|
...(vmEpoch !== undefined ? { vmEpoch } : {}),
|
|
402
402
|
...(contentType ? { contentType } : {}),
|
|
@@ -408,7 +408,8 @@ export function makeEvMuxFrame({ channel, type = 'data', method = null, payload
|
|
|
408
408
|
metadata.method = method
|
|
409
409
|
}
|
|
410
410
|
const metadataBytes = new TextEncoder().encode(JSON.stringify(metadata))
|
|
411
|
-
const
|
|
411
|
+
const checksum = encoding === 'binary' ? crc32(payloadBytes).toString(16).padStart(8, '0') : '00000000'
|
|
412
|
+
const header = new TextEncoder().encode(`\x1eEVMUX/1 h=${metadataBytes.length} p=${payloadBytes.length} c=${checksum}\r\n`)
|
|
412
413
|
const out = new Uint8Array(header.length + metadataBytes.length + payloadBytes.length + 1)
|
|
413
414
|
out.set(header, 0)
|
|
414
415
|
out.set(metadataBytes, header.length)
|
|
@@ -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
|
+
}
|