@esp-vision/device-core 0.4.0 → 0.4.2
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 +14 -1
- package/package.json +1 -1
- package/schemas/device-actions-v1.json +1 -1
- package/src/browser_rom_compat.js +8 -0
- package/src/firmware_flash.js +4 -1
- package/tests/check-browser.mjs +25 -1
- package/tests/device-checks.mjs +29 -24
- package/tests/fixtures/device-checks.py +14 -6
- package/tests/hardware-check.mjs +11 -4
- package/tests/test-device-firmware.mjs +4 -1
- package/tests/test-device-functional-checks.mjs +45 -6
- package/tests/test-device-hardware-cli.mjs +20 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.4.
|
|
3
|
+
## 0.4.2 (unreleased)
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- Route browser ROM reset calls through the active flash stub after upload, preserving its two-byte status decoder during P4 watchdog register writes; report reset failure diagnostics without invalidating successful firmware verification.
|
|
8
|
+
|
|
9
|
+
## 0.4.1 (unreleased)
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- Fail camera mode checks on allocation, timeout and unexpected errors instead of silently classifying those modes as unsupported; inspect optional storage state only when advertised.
|
|
14
|
+
- Keep hardware-check fixtures in bounded RAM-only Python calls, independent of filesystem availability. Add expected board/firmware and required-check validation, report storage mount state, and reject explicit file acceptance on unmounted Flash without formatting.
|
|
15
|
+
|
|
16
|
+
## 0.4.0
|
|
4
17
|
|
|
5
18
|
### Added
|
|
6
19
|
|
package/package.json
CHANGED
|
@@ -15,6 +15,13 @@ export function browserRomCompatibility(loader) {
|
|
|
15
15
|
}
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
|
+
// runStub returns a separate loader. Its command replies use two status bytes;
|
|
19
|
+
// the ROM loader still expects four and cannot issue reset register writes.
|
|
20
|
+
let activeStub
|
|
21
|
+
const hardReset = loader.hardReset
|
|
22
|
+
loader.hardReset = function (...args) {
|
|
23
|
+
return activeStub ? activeStub.hardReset(...args) : hardReset.apply(this, args)
|
|
24
|
+
}
|
|
18
25
|
const runStub = loader.runStub
|
|
19
26
|
loader.runStub = async function (skipFlashDetection = false) {
|
|
20
27
|
const memFinish = this.memFinish, packet = this.readPacket
|
|
@@ -34,6 +41,7 @@ export function browserRomCompatibility(loader) {
|
|
|
34
41
|
const stub = await runStub.call(this, skipFlashDetection)
|
|
35
42
|
if (!stub.IS_STUB) throw new Error('This device has no supported browser flash stub')
|
|
36
43
|
browserStubCompatibility(stub)
|
|
44
|
+
activeStub = stub
|
|
37
45
|
return stub
|
|
38
46
|
} finally { this.memFinish = memFinish; this.readPacket = packet }
|
|
39
47
|
}
|
package/src/firmware_flash.js
CHANGED
|
@@ -112,7 +112,10 @@ export class FirmwareFlashSession {
|
|
|
112
112
|
}
|
|
113
113
|
await this.reset()
|
|
114
114
|
}
|
|
115
|
-
catch
|
|
115
|
+
catch (error) {
|
|
116
|
+
onPhase('Firmware verified. Automatic reset did not complete; wait for runtime reconnection or press RESET.')
|
|
117
|
+
return { verified: true, needsReset: true, resetError: { code: error?.code ?? 'RESET_FAILED', message: error?.message ?? String(error) } }
|
|
118
|
+
}
|
|
116
119
|
onPhase('Firmware verified and reset. Reconnect in the IDE to verify EV-MUX v4.')
|
|
117
120
|
return { verified: true, needsReset: false }
|
|
118
121
|
} catch (error) { this.state = 'error'; throw error }
|
package/tests/check-browser.mjs
CHANGED
|
@@ -23,7 +23,7 @@ try {
|
|
|
23
23
|
assert.ok(Object.keys(result.metafile.inputs).some(path => path.includes('stubs/esp32p4.json')))
|
|
24
24
|
assert.ok(Object.keys(result.metafile.inputs).some(path => path.includes('stubs/esp32s31.json')))
|
|
25
25
|
assert.ok(!Object.keys(result.metafile.inputs).some(path => /src\/(node|cli)/.test(path)))
|
|
26
|
-
const context = vm.createContext({ EventTarget, URL, TextDecoder, TextEncoder, Uint8Array, AbortController, setTimeout, clearTimeout, console, crypto: webcrypto, structuredClone })
|
|
26
|
+
const context = vm.createContext({ atob, btoa, EventTarget, URL, TextDecoder, TextEncoder, Uint8Array, AbortController, setTimeout, clearTimeout, console, crypto: webcrypto, structuredClone })
|
|
27
27
|
vm.runInContext(code, context)
|
|
28
28
|
let opens = 0, closes = 0
|
|
29
29
|
const adapter = context.DeviceCore.openBrowserRom({ port: {
|
|
@@ -46,6 +46,29 @@ try {
|
|
|
46
46
|
raw._inputBuffer.push(0xc0, 0xdb, 0x47, 0xc0)
|
|
47
47
|
await assert.rejects(raw.readPacket(100), /Invalid SLIP escape/)
|
|
48
48
|
}
|
|
49
|
+
// Exercise real upstream stub status decoding. A ROM reset consumes four
|
|
50
|
+
// status bytes while the running stub replies with two; route reset to it.
|
|
51
|
+
{
|
|
52
|
+
const raw = new context.DeviceCore.TestLoader({ getInfo: () => ({ usbVendorId: 0x303a, usbProductId: 0x1001 }) }, { log() {}, error() {}, debug() {} })
|
|
53
|
+
raw.chipFamily = 0x3280; raw.chipRevision = 302
|
|
54
|
+
raw.memBegin = raw.memBlock = raw.memFinish = async () => {}
|
|
55
|
+
raw.readPacket = async () => new Uint8Array([79, 72, 65, 73])
|
|
56
|
+
context.DeviceCore.testCompatibility(raw)
|
|
57
|
+
const stub = await raw.runStub(true)
|
|
58
|
+
let writes = 0
|
|
59
|
+
stub.detectUsbConnectionType = async () => true
|
|
60
|
+
stub.getUsbMode = async () => ({ mode: 'usb-jtag-serial' })
|
|
61
|
+
stub.sendCommand = async () => { writes++ }
|
|
62
|
+
stub.getResponse = async () => [0, new Uint8Array([0, 0])]
|
|
63
|
+
// Confirm the original ROM instance rejects the exact same stub response.
|
|
64
|
+
raw.detectUsbConnectionType = stub.detectUsbConnectionType
|
|
65
|
+
raw.getUsbMode = stub.getUsbMode
|
|
66
|
+
raw.sendCommand = async () => {}
|
|
67
|
+
raw.getResponse = stub.getResponse
|
|
68
|
+
await assert.rejects(context.DeviceCore.TestLoader.prototype.hardReset.call(raw), /status|response|enough/i)
|
|
69
|
+
await raw.hardReset()
|
|
70
|
+
assert.equal(writes, 4, 'P4 watchdog writes must use the running stub status decoder')
|
|
71
|
+
}
|
|
49
72
|
const prototype = context.DeviceCore.TestLoader.prototype
|
|
50
73
|
const originals = Object.fromEntries(['initialize', 'disconnect', 'getSecurityInfo', 'runStub', 'hardReset'].map(key => [key, prototype[key]]))
|
|
51
74
|
let initializeCalls = 0, disconnectCalls = 0
|
|
@@ -115,6 +138,7 @@ try {
|
|
|
115
138
|
async flashData() { writes++; writing.abort() },
|
|
116
139
|
async readFlash(offset) { return offset === 32768 ? table : data },
|
|
117
140
|
async readRegister() { return 0 },
|
|
141
|
+
async hardReset() { resets++ },
|
|
118
142
|
}
|
|
119
143
|
}
|
|
120
144
|
prototype.hardReset = async () => { resets++ }
|
package/tests/device-checks.mjs
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// Application-level HIL checks. All device I/O goes through createDevice's API.
|
|
3
3
|
import { readFile, mkdir, writeFile } from 'node:fs/promises'
|
|
4
4
|
import { dirname } from 'node:path'
|
|
5
|
+
import { EV_REPL_INPUT_MAX_BYTES, evReplExecInputBytes } from '../src/evrepl.js'
|
|
5
6
|
|
|
6
7
|
export const DEVICE_CHECKS = Object.freeze(['identity', 'memory', 'camera', 'framesizes', 'pixformats', 'orientation', 'ipa_stats', 'snapshot_rate', 'preview', 'image_ops', 'display', 'display_stream'])
|
|
7
8
|
const MARKER = '__EVDEVICECHECK__'
|
|
@@ -39,11 +40,14 @@ function pause(milliseconds, signal) {
|
|
|
39
40
|
}
|
|
40
41
|
|
|
41
42
|
export async function runDeviceChecks(device, options, { signal, nonce, report = value => console.log(JSON.stringify(value)) } = {}) {
|
|
42
|
-
const results = [],
|
|
43
|
-
let
|
|
43
|
+
const results = [], namespace = `_evhil_${nonce}`
|
|
44
|
+
let loaded = false, namespaceStarted = false, loadError = null, inputLimit = EV_REPL_INPUT_MAX_BYTES
|
|
44
45
|
const parameters = { frames: options.frames, framesize: options.framesize, pixformat: options.pixformat, displayHold: options.displayHold, screenCapture: Boolean(options.screenOutput) }
|
|
45
46
|
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
47
|
const execute = async (source, timeout = 10000, operationSignal = signal) => {
|
|
48
|
+
// Larger python.execute() requests use temporary files in Device Core.
|
|
49
|
+
// Keep fixtures in RAM by respecting the actual encoded REPL-line budget.
|
|
50
|
+
if (evReplExecInputBytes(source) > inputLimit) throw failure('REPL_INPUT_LIMIT', 'Hardware fixture command exceeds the device REPL input limit')
|
|
47
51
|
const result = await device.python.execute({ source, timeout, maxOutputChars: 16384, signal: operationSignal })
|
|
48
52
|
if (result.ok === false || result.outputTruncated) throw failure('PYTHON_CHECK', 'Device Python check did not complete')
|
|
49
53
|
return result.output
|
|
@@ -52,16 +56,25 @@ export async function runDeviceChecks(device, options, { signal, nonce, report =
|
|
|
52
56
|
if (loaded) return
|
|
53
57
|
if (loadError) throw loadError
|
|
54
58
|
try {
|
|
55
|
-
const
|
|
56
|
-
|
|
59
|
+
const info = await device.inspect({ fields: ['capabilities'], signal })
|
|
60
|
+
const advertised = info.capabilities?.limits?.replInputMax
|
|
61
|
+
if (Number.isSafeInteger(advertised) && advertised > 0) inputLimit = Math.min(inputLimit, advertised)
|
|
57
62
|
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
63
|
namespaceStarted = true
|
|
61
|
-
|
|
62
|
-
|
|
64
|
+
await execute(`${namespace}={'_source':[]}`)
|
|
65
|
+
const append = chunk => `${namespace}['_source'].append(${JSON.stringify(chunk)})`
|
|
66
|
+
let chunk = ''
|
|
67
|
+
for (const character of source) {
|
|
68
|
+
if (evReplExecInputBytes(append(chunk + character)) > inputLimit) {
|
|
69
|
+
if (!chunk) throw failure('REPL_INPUT_LIMIT', 'Device REPL input limit cannot load hardware fixtures')
|
|
70
|
+
await execute(append(chunk))
|
|
71
|
+
chunk = ''
|
|
72
|
+
}
|
|
73
|
+
chunk += character
|
|
74
|
+
}
|
|
75
|
+
if (chunk) await execute(append(chunk))
|
|
76
|
+
await execute(`exec(''.join(${namespace}['_source']),${namespace});del ${namespace}['_source']`)
|
|
63
77
|
loaded = true
|
|
64
|
-
await device.files.remove({ path: fixturePath, signal })
|
|
65
78
|
} catch (error) { loadError = error; throw error }
|
|
66
79
|
}
|
|
67
80
|
const invoke = async name => {
|
|
@@ -83,8 +96,12 @@ export async function runDeviceChecks(device, options, { signal, nonce, report =
|
|
|
83
96
|
let data
|
|
84
97
|
if (name === 'identity') {
|
|
85
98
|
const info = await device.inspect({ fields: ['identity', 'capabilities', 'runtime'], signal })
|
|
99
|
+
if (info.capabilities?.features?.includes('storage.writer.lease')) {
|
|
100
|
+
info.storage = (await device.inspect({ fields: ['storage'], signal })).storage
|
|
101
|
+
}
|
|
86
102
|
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
|
|
103
|
+
data = { identity: info.identity, features: info.capabilities.features, vmReady: info.runtime.vmReady,
|
|
104
|
+
storage: info.storage && Object.fromEntries(Object.entries(info.storage).map(([volume, state]) => [volume, { present: state.present, mounted: state.mounted, writable: state.writable, reason: state.reason }])) }
|
|
88
105
|
} else if (name === 'camera') {
|
|
89
106
|
const image = await device.capture({ source: 'camera', format: 'jpeg', quality: options.quality, timeout: options.frameTimeout * 1000, signal })
|
|
90
107
|
data = await saveJpeg(image, options.output)
|
|
@@ -116,14 +133,8 @@ export async function runDeviceChecks(device, options, { signal, nonce, report =
|
|
|
116
133
|
result = { name, status: 'PASS', data }
|
|
117
134
|
} catch (error) {
|
|
118
135
|
cancelled(signal)
|
|
119
|
-
const skip = unsupported(error) && !(name === 'display' && options.screenOutput)
|
|
136
|
+
const skip = unsupported(error) && !options.requireAll && !(name === 'display' && options.screenOutput)
|
|
120
137
|
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
138
|
} finally {
|
|
128
139
|
if (name === 'display' && loaded) {
|
|
129
140
|
try { await execute(`${namespace}['close_display']()`, 5000, AbortSignal.timeout(6000)) }
|
|
@@ -140,13 +151,7 @@ export async function runDeviceChecks(device, options, { signal, nonce, report =
|
|
|
140
151
|
try { await execute(`if ${JSON.stringify(namespace)} in globals():\n ${namespace}.get('close_display',lambda:None)()\n del ${namespace}`, 5000, cleanupSignal) }
|
|
141
152
|
catch (error) { cleanupError = error }
|
|
142
153
|
}
|
|
143
|
-
if (
|
|
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 } })
|
|
154
|
+
if (cleanupError) record({ name: 'fixture-cleanup', status: 'FAIL', error: { code: 'FIXTURE_CLEANUP_FAILED', message: 'Could not release the hardware fixture namespace' } })
|
|
150
155
|
}
|
|
151
156
|
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
157
|
report({ check: 'summary', ...summary })
|
|
@@ -6,6 +6,11 @@ All transport, ownership and cleanup are supplied by the public JS SDK.
|
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
|
|
9
|
+
def is_unsupported(exc):
|
|
10
|
+
message = str(exc).lower()
|
|
11
|
+
return any(token in message for token in ('esp_err_not_supported', 'esp_err_not_found', 'no module named', 'enodev'))
|
|
12
|
+
|
|
13
|
+
|
|
9
14
|
def framesizes(opts):
|
|
10
15
|
import sensor
|
|
11
16
|
sensor.reset()
|
|
@@ -18,8 +23,10 @@ def framesizes(opts):
|
|
|
18
23
|
img = sensor.snapshot()
|
|
19
24
|
results.append(dict(name=name, supported=True, width=img.width(), height=img.height(),
|
|
20
25
|
reportedWidth=sensor.width(), reportedHeight=sensor.height()))
|
|
21
|
-
except Exception:
|
|
22
|
-
|
|
26
|
+
except Exception as exc:
|
|
27
|
+
if not is_unsupported(exc):
|
|
28
|
+
raise
|
|
29
|
+
results.append(dict(name=name, supported=False, reason=str(exc)))
|
|
23
30
|
working = [item for item in results if item['supported']]
|
|
24
31
|
assert working, 'no framesize produced a frame'
|
|
25
32
|
for item in working:
|
|
@@ -39,8 +46,10 @@ def pixformats(opts):
|
|
|
39
46
|
img = sensor.snapshot()
|
|
40
47
|
results.append(dict(name=name, supported=True, width=img.width(), height=img.height(),
|
|
41
48
|
actualBytes=img.size(), expectedBytes=img.width() * img.height() * depth))
|
|
42
|
-
except Exception:
|
|
43
|
-
|
|
49
|
+
except Exception as exc:
|
|
50
|
+
if not is_unsupported(exc):
|
|
51
|
+
raise
|
|
52
|
+
results.append(dict(name=name, supported=False, reason=str(exc)))
|
|
44
53
|
working = [item for item in results if item['supported']]
|
|
45
54
|
assert working, 'no pixel format produced a frame'
|
|
46
55
|
for item in working:
|
|
@@ -311,7 +320,6 @@ def run_check(name, opts):
|
|
|
311
320
|
result = dict(name=name, status='PASS', data=data)
|
|
312
321
|
except Exception as exc:
|
|
313
322
|
message = str(exc)
|
|
314
|
-
|
|
315
|
-
status = 'SKIP' if any(token.lower() in message.lower() for token in unsupported) else 'FAIL'
|
|
323
|
+
status = 'SKIP' if is_unsupported(exc) else 'FAIL'
|
|
316
324
|
result = dict(name=name, status=status, error=dict(type=type(exc).__name__, message=message))
|
|
317
325
|
print('__EVDEVICECHECK__' + json.dumps(result))
|
package/tests/hardware-check.mjs
CHANGED
|
@@ -7,7 +7,7 @@ export { tcpTransport, serialTransport } from '../src/node_transport.js'
|
|
|
7
7
|
import { createDevice } from '../src/index.js'
|
|
8
8
|
import { DEVICE_CHECKS, runDeviceChecks, saveJpeg } from './device-checks.mjs'
|
|
9
9
|
|
|
10
|
-
export const usage = 'Usage: npm run test:hardware -- (--tcp HOST[:PORT] | --serial PATH) [--role usj|usb|uart] [--python PYTHON] [--write] [--capture] [--checks all|CSV] [--frames N] [--framesize QVGA] [--pixformat RGB565] [--quality 60] [--frame-timeout SECONDS] [--display-hold SECONDS] [--output CAMERA.jpg] [--screen-output LCD.jpg] [--timeout MILLISECONDS]'
|
|
10
|
+
export const usage = 'Usage: npm run test:hardware -- (--tcp HOST[:PORT] | --serial PATH) [--role usj|usb|uart] [--python PYTHON] [--expect-board BOARD] [--expect-firmware-version VERSION] [--write] [--capture] [--checks all|CSV] [--require-all] [--frames N] [--framesize QVGA] [--pixformat RGB565] [--quality 60] [--frame-timeout SECONDS] [--display-hold SECONDS] [--output CAMERA.jpg] [--screen-output LCD.jpg] [--timeout MILLISECONDS]'
|
|
11
11
|
const fail = (code, message) => Object.assign(new Error(message), { code })
|
|
12
12
|
|
|
13
13
|
export function parseArgs(args) {
|
|
@@ -16,8 +16,8 @@ export function parseArgs(args) {
|
|
|
16
16
|
for (let index = 0; index < args.length; index++) {
|
|
17
17
|
const option = args[index]
|
|
18
18
|
if (option === '--help') return { help: true }
|
|
19
|
-
if (
|
|
20
|
-
if (!['--tcp', '--serial', '--role', '--python', '--timeout', '--checks', '--screen-output', '--output', '--frames', '--display-hold', '--framesize', '--pixformat', '--quality', '--frame-timeout'].includes(option)) throw fail('ARGUMENT', 'Unknown argument')
|
|
19
|
+
if (['--write', '--capture', '--require-all'].includes(option)) { options[option === '--require-all' ? 'requireAll' : option.slice(2)] = true; continue }
|
|
20
|
+
if (!['--tcp', '--serial', '--role', '--python', '--timeout', '--checks', '--screen-output', '--output', '--frames', '--display-hold', '--framesize', '--pixformat', '--quality', '--frame-timeout', '--expect-board', '--expect-firmware-version'].includes(option)) throw fail('ARGUMENT', 'Unknown argument')
|
|
21
21
|
const value = args[++index]
|
|
22
22
|
if (!value || value.startsWith('--')) throw fail('ARGUMENT', 'Missing argument value')
|
|
23
23
|
const key = option.slice(2).replace(/-([a-z])/g, (_match, letter) => letter.toUpperCase())
|
|
@@ -75,13 +75,20 @@ export async function checkHardware(options, { device = createDevice({ transport
|
|
|
75
75
|
await device.connect({ role: options.role, timeout: Math.min(options.timeout, 30000), signal: controller.signal })
|
|
76
76
|
cancelled()
|
|
77
77
|
const status = await device.inspect({ fields: ['identity', 'capabilities', 'runtime'], signal: controller.signal })
|
|
78
|
+
if (options.expectBoard && status.identity?.board !== options.expectBoard) throw fail('BOARD_MISMATCH', 'Connected board does not match the requested board')
|
|
79
|
+
if (options.expectFirmwareVersion && status.identity?.firmwareVersion !== options.expectFirmwareVersion) throw fail('FIRMWARE_MISMATCH', 'Running firmware does not match the selected build artifact')
|
|
78
80
|
// Only selected public identity fields are logged; never network credentials or Python output.
|
|
79
|
-
report({ check: 'inspect', ok: true, identity: { deviceId: status.identity?.deviceId ?? null, board: status.identity?.board ?? null, hostname: status.identity?.hostname ?? null } })
|
|
81
|
+
report({ check: 'inspect', ok: true, identity: { deviceId: status.identity?.deviceId ?? null, board: status.identity?.board ?? null, hostname: status.identity?.hostname ?? null, firmwareVersion: status.identity?.firmwareVersion ?? null } })
|
|
80
82
|
const marker = `EV_HARDWARE_${nonce}`
|
|
81
83
|
const execution = await device.python.execute({ source: `print('${marker}')`, timeout: 10000, maxOutputChars: 1024, signal: controller.signal })
|
|
82
84
|
if (!execution.ok || !execution.output.split(/\r?\n/).some(line => line.trim() === marker)) throw fail('PYTHON_CHECK', 'Python marker was not received')
|
|
83
85
|
report({ check: 'python', ok: true })
|
|
84
86
|
if (options.write) {
|
|
87
|
+
const storage = await device.inspect({ fields: ['storage'], signal: controller.signal })
|
|
88
|
+
const flash = storage.storage?.flash
|
|
89
|
+
const ready = flash?.present === true && flash?.mounted === true && flash?.writable === true
|
|
90
|
+
report({ check: 'storage', ok: ready, flash: { present: flash?.present ?? null, mounted: flash?.mounted ?? null, writable: flash?.writable ?? null, reason: flash?.reason ?? null } })
|
|
91
|
+
if (!ready) throw fail('STORAGE_UNAVAILABLE', 'File acceptance requires a mounted writable Flash filesystem; no formatting was attempted')
|
|
85
92
|
const entries = await device.files.list({ path: '/', signal: controller.signal })
|
|
86
93
|
if (entries.some(entry => entry.path === path || entry.path === `${path}.tmp`)) throw fail('TEST_PATH_EXISTS', 'Test path already exists; refusing to modify it')
|
|
87
94
|
ownedPath = path
|
|
@@ -80,7 +80,10 @@ test('failed readback never reports verified or resets the device', async () =>
|
|
|
80
80
|
})
|
|
81
81
|
test('reset failure returns verified with explicit manual reset requirement', async () => {
|
|
82
82
|
const f = await fixture(); f.loader.hardReset = async () => { throw new Error('reset unavailable') }
|
|
83
|
-
|
|
83
|
+
const result = await f.device.firmware.update(f.request)
|
|
84
|
+
assert.equal(result.needsReset, true)
|
|
85
|
+
assert.equal(result.verified, true)
|
|
86
|
+
assert.deepEqual(result.resetError, { code: 'RESET_FAILED', message: 'reset unavailable' })
|
|
84
87
|
})
|
|
85
88
|
|
|
86
89
|
test('firmware check lists all board versions and selects an explicit older version', async () => {
|
|
@@ -3,6 +3,7 @@ import test from 'node:test'
|
|
|
3
3
|
import assert from 'node:assert/strict'
|
|
4
4
|
import { readFile, mkdtemp, rm } from 'node:fs/promises'
|
|
5
5
|
import { join } from 'node:path'
|
|
6
|
+
import { EV_REPL_INPUT_MAX_BYTES, evReplExecInputBytes } from '../src/evrepl.js'
|
|
6
7
|
import { tmpdir } from 'node:os'
|
|
7
8
|
import { parseArgs } from './hardware-check.mjs'
|
|
8
9
|
import { DEVICE_CHECKS, parseCheckResult, runDeviceChecks, saveJpeg } from './device-checks.mjs'
|
|
@@ -11,11 +12,11 @@ function devicePeer({ results = {}, board = 'TEST', screenFails = false } = {})
|
|
|
11
12
|
const files = new Map(), calls = [], listeners = new Map()
|
|
12
13
|
return {
|
|
13
14
|
calls, files, listeners,
|
|
14
|
-
async inspect({ fields }) { calls.push(['inspect', fields]); return { identity: { board, firmwareVersion: 'test' }, capabilities: { protocol: { evMuxVersion: 4 }, features: [] }, runtime: { vmReady: true } } },
|
|
15
|
+
async inspect({ fields }) { calls.push(['inspect', fields]); return { identity: { board, firmwareVersion: 'test' }, capabilities: { protocol: { evMuxVersion: 4 }, features: ['storage.writer.lease'], limits: { replInputMax: EV_REPL_INPUT_MAX_BYTES } }, runtime: { vmReady: true }, storage: { flash: { present: true, mounted: false, writable: false } } } },
|
|
15
16
|
files: Object.assign(files, {
|
|
16
|
-
async list() {
|
|
17
|
-
async write(
|
|
18
|
-
async remove({
|
|
17
|
+
async list() { throw new Error('Runtime checks must not access storage') },
|
|
18
|
+
async write() { throw Object.assign(new Error('Flash unmounted'), { code: 'FILE_OPEN_FAILED' }) },
|
|
19
|
+
async remove() { throw new Error('Runtime checks must not access storage') },
|
|
19
20
|
}),
|
|
20
21
|
python: { async execute({ source }) {
|
|
21
22
|
calls.push(['execute', source])
|
|
@@ -48,10 +49,15 @@ test('device check reports reject stale, duplicated and malformed markers', () =
|
|
|
48
49
|
for (const output of ['', valid + '\n' + valid, '__EVDEVICECHECK__bad', valid.replace('memory', 'camera'), valid.replace('PASS', 'UNKNOWN')]) assert.throws(() => parseCheckResult(output, 'memory'), { code: 'CHECK_REPORT_INVALID' })
|
|
49
50
|
})
|
|
50
51
|
|
|
51
|
-
test('functional checks
|
|
52
|
+
test('functional checks load fixtures in RAM despite unmounted storage and consume public preview events', async () => {
|
|
52
53
|
const device = devicePeer(), reports = []
|
|
53
54
|
await runDeviceChecks(device, parseArgs(['--tcp', 'test', '--checks', 'memory,preview,orientation']), { nonce: 'abc123', report: value => reports.push(value) })
|
|
54
|
-
|
|
55
|
+
const commands = device.calls.filter(call => call[0] === 'execute').map(call => call[1])
|
|
56
|
+
assert.ok(commands.every(source => evReplExecInputBytes(source) <= EV_REPL_INPUT_MAX_BYTES))
|
|
57
|
+
assert.equal(commands.filter(source => source.startsWith("exec(''.join(")).length, 1)
|
|
58
|
+
const loader = commands.filter(source => source.includes("['_source']") || source.includes("={'_source':"))
|
|
59
|
+
const uploaded = loader.filter(source => source.includes("['_source'].append(")).map(source => JSON.parse(source.slice(source.indexOf('.append(') + 8, -1))).join('')
|
|
60
|
+
assert.equal(uploaded, await readFile(new URL('./fixtures/device-checks.py', import.meta.url), 'utf8'))
|
|
55
61
|
assert.equal(device.files.size, 0)
|
|
56
62
|
assert.equal(device.listeners.size, 0)
|
|
57
63
|
assert.equal(reports.find(result => result.check === 'preview').data.received, 1)
|
|
@@ -96,3 +102,36 @@ test('truncated JPEG and missing dimensions are refused before host artifact is
|
|
|
96
102
|
await assert.rejects(saveJpeg({ data: new Uint8Array([255, 216]), mimeType: 'image/jpeg', metadata: { width: 1, height: 1 } }), { code: 'CAPTURE_INVALID' })
|
|
97
103
|
await assert.rejects(saveJpeg({ data: new Uint8Array([255, 216, 255, 217]), mimeType: 'image/jpeg' }), { code: 'CAPTURE_INVALID' })
|
|
98
104
|
})
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
test('strict acceptance fails unsupported selected checks without preventing independent identity evidence', async () => {
|
|
108
|
+
const device = devicePeer({ results: { memory: { name: 'memory', status: 'SKIP', error: { message: 'ESP_ERR_NOT_SUPPORTED' } } } }), reports = []
|
|
109
|
+
await assert.rejects(runDeviceChecks(device, parseArgs(['--tcp', 'test', '--checks', 'memory,identity', '--require-all']), { nonce: 'abc123', report: value => reports.push(value) }), { code: 'DEVICE_CHECKS_FAILED' })
|
|
110
|
+
assert.equal(reports.find(result => result.check === 'memory').status, 'FAIL')
|
|
111
|
+
assert.equal(reports.find(result => result.check === 'identity').status, 'PASS')
|
|
112
|
+
assert.equal(reports.find(result => result.check === 'identity').data.storage.flash.mounted, false)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('failed partial RAM loading still releases its namespace without filesystem cleanup', async () => {
|
|
116
|
+
const device = devicePeer(), reports = [], execute = device.python.execute
|
|
117
|
+
let failed = false
|
|
118
|
+
device.python.execute = async request => {
|
|
119
|
+
if (!failed && request.source.includes("['_source'].append")) { failed = true; throw new Error('fixture load failed') }
|
|
120
|
+
return execute(request)
|
|
121
|
+
}
|
|
122
|
+
await assert.rejects(runDeviceChecks(device, parseArgs(['--tcp', 'test', '--checks', 'memory']), { nonce: 'abc123', report: value => reports.push(value) }), { code: 'DEVICE_CHECKS_FAILED' })
|
|
123
|
+
assert.ok(device.calls.some(call => call[0] === 'execute' && call[1].includes('del _evhil_abc123')))
|
|
124
|
+
assert.equal(reports.at(-1).failed, 1)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
test('identity and RAM checks do not require optional storage inspection support', async () => {
|
|
128
|
+
const device = devicePeer(), inspect = device.inspect, reports = []
|
|
129
|
+
device.inspect = async request => {
|
|
130
|
+
assert.ok(!request.fields.includes('storage'))
|
|
131
|
+
const info = await inspect(request)
|
|
132
|
+
info.capabilities.features = []
|
|
133
|
+
return info
|
|
134
|
+
}
|
|
135
|
+
await runDeviceChecks(device, parseArgs(['--tcp', 'test', '--checks', 'identity,memory']), { nonce: 'abc123', report: value => reports.push(value) })
|
|
136
|
+
assert.deepEqual(reports.at(-1), { check: 'summary', passed: 2, failed: 0, skipped: 0 })
|
|
137
|
+
})
|
|
@@ -54,7 +54,7 @@ function fakeDevice({ mismatch = false, collision = false, writeFails = false }
|
|
|
54
54
|
return {
|
|
55
55
|
calls, files,
|
|
56
56
|
async connect() { calls.push('connect') },
|
|
57
|
-
async inspect() { return { identity: { board: 'TEST' }, network: { password: 'NEVER_PRINT' } } },
|
|
57
|
+
async inspect() { return { identity: { board: 'TEST', firmwareVersion: 'test' }, storage: { flash: { present: true, mounted: true, writable: true } }, network: { password: 'NEVER_PRINT' } } },
|
|
58
58
|
python: { async execute() { return { ok: true, output: 'EV_HARDWARE_abc123\n' } } },
|
|
59
59
|
files: Object.assign(files, {
|
|
60
60
|
async list() { return [...files.keys()].map(path => ({ path })) },
|
|
@@ -101,3 +101,22 @@ test('missing optional Python executable fails cleanly without opening a port',
|
|
|
101
101
|
await assert.rejects(transport.connect({ signal: AbortSignal.timeout(1000) }), { code: 'SERIAL_ERROR' })
|
|
102
102
|
} finally { unsubscribe(); await transport.disconnect() }
|
|
103
103
|
})
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
test('expected board and firmware must match before any Python or file operations', async () => {
|
|
107
|
+
for (const args of [['--expect-board', 'OTHER'], ['--expect-firmware-version', 'stale']]) {
|
|
108
|
+
const device = fakeDevice()
|
|
109
|
+
device.python.execute = async () => assert.fail('Wrong device must not execute Python')
|
|
110
|
+
await assert.rejects(checkHardware(parseArgs(['--tcp', 'localhost', ...args]), { device, report() {} }), { code: args[0] === '--expect-board' ? 'BOARD_MISMATCH' : 'FIRMWARE_MISMATCH' })
|
|
111
|
+
assert.deepEqual(device.calls, ['connect', 'disconnect'])
|
|
112
|
+
}
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('explicit file acceptance reports unmounted storage and fails without writing or formatting', async () => {
|
|
116
|
+
const device = fakeDevice(), inspect = device.inspect, reports = []
|
|
117
|
+
device.inspect = async request => ({ ...await inspect(request), storage: { flash: { present: true, mounted: false, writable: true, reason: 'HOST_WRITER_ACTIVE' } } })
|
|
118
|
+
await assert.rejects(checkHardware(parseArgs(['--tcp', 'localhost', '--write']), { device, nonce: 'abc123', report: value => reports.push(value) }), { code: 'STORAGE_UNAVAILABLE' })
|
|
119
|
+
assert.deepEqual(device.calls, ['connect', 'disconnect'])
|
|
120
|
+
assert.equal(reports.find(result => result.check === 'storage').flash.mounted, false)
|
|
121
|
+
assert.equal(reports.find(result => result.check === 'storage').ok, false)
|
|
122
|
+
})
|