@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.
@@ -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
- if (this.capabilities && payloadData.length > this.limits.rxPayloadMax) {
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
- const chunkBytes = maxScriptChunkBytes({
1093
- path, encoding, mode, runAfterWrite, rxPayloadMax: this.limits.rxPayloadMax, totalBytes: bytes.length,
1094
- })
1095
- if (this.hasFeature('script.write.chunked') && bytes.length > chunkBytes) {
1096
- let lastResponse = null
1097
- for (let offset = 0; offset < bytes.length; offset += chunkBytes) {
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 + chunkBytes, bytes.length))
1100
- lastResponse = await this.rpc('script.write', {
1101
- path,
1102
- encoding,
1103
- mode,
1104
- offset,
1105
- totalBytes: bytes.length,
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
- return lastResponse
1112
- }
1113
-
1114
- const payload = {
1115
- path,
1116
- encoding,
1117
- mode,
1118
- contentBase64: bytesToBase64(bytes),
1119
- runAfterWrite,
1120
- }
1121
- if (utf8Bytes(JSON.stringify(payload)).length > this.limits.rxPayloadMax) {
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('script.write') || !this.hasFeature('script.run')) {
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 script.write and script.run support.',
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('script.write')) || !this.hasFeature('script.run')) {
1369
- throw new Error('Running scripts requires script.run and uploading source also requires script.write')
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 header = new TextEncoder().encode(`\x1eEVMUX/1 h=${metadataBytes.length} p=${payloadBytes.length} c=00000000\r\n`)
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
+ }