@esp-vision/device-core 0.4.2 → 0.5.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 +19 -2
- package/README.md +23 -13
- package/package.json +1 -5
- package/schemas/device-actions-v1.json +1 -1
- package/src/device.js +17 -6
- package/src/device_files.js +5 -4
- package/src/device_python.js +4 -3
- package/src/device_session.js +127 -43
- package/src/evrepl.js +196 -22
- package/src/node.js +10 -0
- package/src/node_inventory.js +7 -4
- package/src/node_serial.py +9 -1
- package/src/node_transport.js +36 -7
- package/tests/check-package.mjs +6 -9
- package/tests/device-checks.mjs +2 -2
- package/tests/fixtures/device-checks.py +205 -1
- package/tests/test-device-api.mjs +96 -6
- package/tests/test-device-execution.mjs +205 -13
- package/tests/test-device-files-api.mjs +85 -1
- package/tests/test-device-functional-checks.mjs +9 -0
- package/tests/test-device-hardware-cli.mjs +8 -0
- package/tests/test-device-python-api.mjs +29 -2
- package/tests/test-firmware-install.mjs +0 -17
- package/tests/test-node-adapter-assets.mjs +16 -0
- package/tests/test-node-inventory.mjs +136 -0
- package/tests/test-node-serial.mjs +104 -0
- package/src/cli.js +0 -185
- package/tests/test-cli-output.mjs +0 -31
- package/tests/test-cli-ports.mjs +0 -50
- package/tests/test-cli.mjs +0 -98
|
@@ -35,8 +35,92 @@ test('file upload selects encoding and creation policy from public parameters',
|
|
|
35
35
|
test('file read is binary by default with explicit UTF-8 decoding and bounded options', async () => {
|
|
36
36
|
const { files, calls } = fixture()
|
|
37
37
|
assert.ok(await files.read({ path: '/model.bin' }) instanceof Uint8Array)
|
|
38
|
+
assert.deepEqual(calls[0][2], { timeout: null, chunkTimeout: 30000, maxBytes: null, signal: null })
|
|
38
39
|
assert.equal(await files.read({ path: '/a.txt', encoding: 'utf-8', maxBytes: 512 }), '你好')
|
|
39
|
-
assert.
|
|
40
|
+
assert.deepEqual(calls[1][2], { timeout: null, chunkTimeout: 30000, maxBytes: 512, signal: null })
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test('files.read forwards aggregate and per-command timeouts and validates both', async () => {
|
|
44
|
+
const { files, calls } = fixture()
|
|
45
|
+
await files.read({ path: '/a', timeout: 90000, chunkTimeout: 4500, maxBytes: 2048 })
|
|
46
|
+
assert.deepEqual(calls[0][2], { timeout: 90000, chunkTimeout: 4500, maxBytes: 2048, signal: null })
|
|
47
|
+
await files.read({ path: '/a', timeout: null, chunkTimeout: 12000 })
|
|
48
|
+
assert.deepEqual(calls[1][2], { timeout: null, chunkTimeout: 12000, maxBytes: null, signal: null })
|
|
49
|
+
for (const timeout of [0, -1, NaN, Infinity, '100']) {
|
|
50
|
+
await assert.rejects(files.read({ path: '/a', timeout }), RangeError)
|
|
51
|
+
}
|
|
52
|
+
for (const chunkTimeout of [null, 0, -1, NaN, Infinity, '100']) {
|
|
53
|
+
await assert.rejects(files.read({ path: '/a', chunkTimeout }), RangeError)
|
|
54
|
+
}
|
|
55
|
+
assert.equal(calls.length, 2, 'invalid timeout settings must fail before device I/O')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
function installSimulatedFileRead(repl, data, { commandDurationMs, advanceClock }) {
|
|
59
|
+
const budgets = []
|
|
60
|
+
repl._execPy = async (source, timeout) => {
|
|
61
|
+
budgets.push(timeout)
|
|
62
|
+
if (commandDurationMs > timeout) {
|
|
63
|
+
advanceClock(timeout)
|
|
64
|
+
throw Object.assign(new Error('repl exec timeout'), { code: 'TIMEOUT' })
|
|
65
|
+
}
|
|
66
|
+
advanceClock(commandDurationMs)
|
|
67
|
+
const offset = Number(/f.seek\((\d+)\)/.exec(source)[1])
|
|
68
|
+
const count = Number(/f.read\((\d+)\)/.exec(source)[1])
|
|
69
|
+
const chunk = data.slice(offset, offset + count)
|
|
70
|
+
const hex = [...chunk].map(byte => byte.toString(16).padStart(2, '0')).join('')
|
|
71
|
+
return `__EVREAD_OK__${data.length}:${chunk.length}:${hex}\n`
|
|
72
|
+
}
|
|
73
|
+
return budgets
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
test('a 56 KiB file read can take longer than 30 seconds with a fresh bound per REPL command', async () => {
|
|
77
|
+
const repl = new EvRepl({ writeEvmux() {} })
|
|
78
|
+
const data = Uint8Array.from({ length: 56 * 1024 }, (_, index) => index % 256)
|
|
79
|
+
const originalNow = Date.now
|
|
80
|
+
let now = 100000
|
|
81
|
+
Date.now = () => now
|
|
82
|
+
try {
|
|
83
|
+
const budgets = installSimulatedFileRead(repl, data, {
|
|
84
|
+
commandDurationMs: 1000,
|
|
85
|
+
advanceClock: duration => { now += duration },
|
|
86
|
+
})
|
|
87
|
+
assert.deepEqual(await repl.readFile('/slow.bin'), data)
|
|
88
|
+
assert.equal(now - 100000, 56000)
|
|
89
|
+
assert.equal(budgets.length, 56)
|
|
90
|
+
assert.ok(budgets.every(timeout => timeout === 30000))
|
|
91
|
+
} finally {
|
|
92
|
+
Date.now = originalNow
|
|
93
|
+
}
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test('file reads keep per-command bounds and an optional aggregate deadline', async () => {
|
|
97
|
+
const data = Uint8Array.from({ length: 4096 }, (_, index) => index % 256)
|
|
98
|
+
const originalNow = Date.now
|
|
99
|
+
let now = 200000
|
|
100
|
+
Date.now = () => now
|
|
101
|
+
try {
|
|
102
|
+
const chunkedRepl = new EvRepl({ writeEvmux() {} })
|
|
103
|
+
const chunkBudgets = installSimulatedFileRead(chunkedRepl, data, {
|
|
104
|
+
commandDurationMs: 4000,
|
|
105
|
+
advanceClock: duration => { now += duration },
|
|
106
|
+
})
|
|
107
|
+
assert.deepEqual(await chunkedRepl.readFile('/slow.bin', { timeout: 20000, chunkTimeout: 5000 }), data)
|
|
108
|
+
assert.deepEqual(chunkBudgets, [5000, 5000, 5000, 5000])
|
|
109
|
+
|
|
110
|
+
now = 300000
|
|
111
|
+
const totalTimeoutRepl = new EvRepl({ writeEvmux() {} })
|
|
112
|
+
const totalBudgets = installSimulatedFileRead(totalTimeoutRepl, data, {
|
|
113
|
+
commandDurationMs: 8000,
|
|
114
|
+
advanceClock: duration => { now += duration },
|
|
115
|
+
})
|
|
116
|
+
await assert.rejects(
|
|
117
|
+
totalTimeoutRepl.readFile('/slow.bin', { timeout: 25000, chunkTimeout: 30000 }),
|
|
118
|
+
{ code: 'TIMEOUT' },
|
|
119
|
+
)
|
|
120
|
+
assert.deepEqual(totalBudgets, [25000, 17000, 9000, 1000])
|
|
121
|
+
} finally {
|
|
122
|
+
Date.now = originalNow
|
|
123
|
+
}
|
|
40
124
|
})
|
|
41
125
|
|
|
42
126
|
test('file listing passes scoped path and recursion without walking the entire device', async () => {
|
|
@@ -135,3 +135,12 @@ test('identity and RAM checks do not require optional storage inspection support
|
|
|
135
135
|
await runDeviceChecks(device, parseArgs(['--tcp', 'test', '--checks', 'identity,memory']), { nonce: 'abc123', report: value => reports.push(value) })
|
|
136
136
|
assert.deepEqual(reports.at(-1), { check: 'summary', passed: 2, failed: 0, skipped: 0 })
|
|
137
137
|
})
|
|
138
|
+
|
|
139
|
+
test('P4 issue regressions are included in the strict target-check path', async () => {
|
|
140
|
+
const required = ['camera_display_lifecycle', 'image_copy_to_fb', 'image_transform_argument', 'sensor_snapshot_buffer', 'memory_policy']
|
|
141
|
+
assert.ok(required.every(name => DEVICE_CHECKS.includes(name)))
|
|
142
|
+
const device = devicePeer(), reports = []
|
|
143
|
+
await runDeviceChecks(device, parseArgs(['--tcp', 'test', '--checks', required.join(','), '--require-all']),
|
|
144
|
+
{ nonce: 'issue910', report: value => reports.push(value) })
|
|
145
|
+
assert.deepEqual(reports.at(-1), { check: 'summary', passed: required.length, failed: 0, skipped: 0 })
|
|
146
|
+
})
|
|
@@ -19,9 +19,17 @@ test('default hardware acceptance exercises real facade over a fake EV-MUX TCP p
|
|
|
19
19
|
const server = createServer(socket => {
|
|
20
20
|
sockets.add(socket)
|
|
21
21
|
socket.on('close', () => sockets.delete(socket))
|
|
22
|
+
let pythonMarker = null
|
|
22
23
|
const parser = new EvMuxParser({ filterFrame(metadata, bytes) {
|
|
23
24
|
const text = new TextDecoder().decode(bytes)
|
|
24
25
|
if (metadata.channel === 'repl.stdin') {
|
|
26
|
+
pythonMarker ??= /EV_HARDWARE_[a-z0-9]+/.exec(text)?.[0] ?? null
|
|
27
|
+
const completionMarker = /~~EVSCRIPT-END:[A-Za-z0-9-]+~~/.exec(text)?.[0]
|
|
28
|
+
if (completionMarker) {
|
|
29
|
+
socket.write(makeEvMuxFrame({ channel: 'repl.stdout', type: 'data', sessionId, vmEpoch: 1,
|
|
30
|
+
payload: `${text}\r\n${pythonMarker ?? ''}\r\n${completionMarker}\r\n>>> ` }))
|
|
31
|
+
return false
|
|
32
|
+
}
|
|
25
33
|
const marker = /EV_HARDWARE_[a-z0-9]+/.exec(text)?.[0] ?? 'EVIDE_BOOT_OK'
|
|
26
34
|
socket.write(makeEvMuxFrame({ channel: 'repl.stdout', type: 'data', sessionId, vmEpoch: 1, payload: `${text}\r\n${marker}\r\n>>> ` }))
|
|
27
35
|
return false
|
|
@@ -7,6 +7,7 @@ import { createFilesApi } from '../src/device_files.js'
|
|
|
7
7
|
function fixture() {
|
|
8
8
|
const calls = [], state = {}
|
|
9
9
|
const session = {
|
|
10
|
+
state: 'ready',
|
|
10
11
|
repl: { writeStdin: async data => calls.push(['input', data]), removeFile: async path => calls.push(['remove', path]) },
|
|
11
12
|
executePython: async (source, options) => { calls.push(['source', source, options]); return '42\r\n' },
|
|
12
13
|
runScript: async (path, content, options) => { calls.push(['run', path, content, options]); return { ok: true, output: 'existing', outputTruncated: false } },
|
|
@@ -19,18 +20,28 @@ test('source wait and path wait return the same result shape without rewriting p
|
|
|
19
20
|
const { python, calls } = fixture()
|
|
20
21
|
assert.deepEqual(await python.execute({ source: 'print(42)' }), { ok: true, path: null, output: '42\n', outputTruncated: false })
|
|
21
22
|
assert.deepEqual(await python.execute({ path: '/existing.py' }), { ok: true, path: '/existing.py', output: 'existing', outputTruncated: false })
|
|
23
|
+
assert.equal(calls[0][2].timeout, null, 'wait execution has no aggregate deadline by default')
|
|
22
24
|
assert.deepEqual(calls[1].slice(0, 3), ['run', '/existing.py', undefined])
|
|
25
|
+
assert.equal(calls[1][3].timeout, null, 'path execution has no aggregate deadline by default')
|
|
23
26
|
assert.equal(calls[1][3].inlineCompletion, true)
|
|
24
27
|
})
|
|
25
28
|
|
|
26
29
|
test('execution rejects ambiguous input, unsupported modes, unsafe paths and invalid deadlines before I/O', async () => {
|
|
27
30
|
const { python, calls } = fixture()
|
|
28
|
-
for (const request of [{}, { source: '', path: '/a.py' }, { source: 0 }, { path: '../a.py' }, { path: '/a/../b.py' }, { source: '', completion: 'other' }, { source: '', timeout: 0 }, { source: '', completion: 'start', timeout: 3 }]) {
|
|
31
|
+
for (const request of [{}, { source: '', path: '/a.py' }, { source: 0 }, { path: '../a.py' }, { path: '/a/../b.py' }, { source: '', completion: 'other' }, { source: '', timeout: 0 }, { source: '', completion: 'start', timeout: 3 }, { source: '', completion: 'start', timeout: null }]) {
|
|
29
32
|
await assert.rejects(python.execute(request))
|
|
30
33
|
}
|
|
31
34
|
assert.equal(calls.length, 0)
|
|
32
35
|
})
|
|
33
36
|
|
|
37
|
+
test('wait execution accepts null and forwards an explicit positive deadline', async () => {
|
|
38
|
+
const { python, calls } = fixture()
|
|
39
|
+
await python.execute({ source: 'print(42)', timeout: null })
|
|
40
|
+
await python.execute({ source: 'print(42)', timeout: 12000 })
|
|
41
|
+
assert.equal(calls[0][2].timeout, null)
|
|
42
|
+
assert.equal(calls[1][2].timeout, 12000)
|
|
43
|
+
})
|
|
44
|
+
|
|
34
45
|
test('pre-aborted execution never starts Python', async () => {
|
|
35
46
|
const { python, calls } = fixture()
|
|
36
47
|
await assert.rejects(python.execute({ source: '', signal: AbortSignal.abort() }), { name: 'AbortError' })
|
|
@@ -76,6 +87,21 @@ test('started source uses a private temporary file and removes it only after com
|
|
|
76
87
|
assert.deepEqual(calls.at(-1), ['remove', handle.path])
|
|
77
88
|
})
|
|
78
89
|
|
|
90
|
+
test('disconnect skips remote cleanup for a started temporary source', async () => {
|
|
91
|
+
const { python, calls, session, state } = fixture()
|
|
92
|
+
let rejectDone
|
|
93
|
+
session.stopForRepl = async () => { calls.push(['stop']); return true }
|
|
94
|
+
session.startScript = async () => ({ done: new Promise((_resolve, reject) => { rejectDone = reject }) })
|
|
95
|
+
const handle = await python.execute({ source: 'while True: pass', completion: 'start' })
|
|
96
|
+
const disconnected = assert.rejects(handle.done, /device disconnected/)
|
|
97
|
+
session.state = 'disconnected'
|
|
98
|
+
rejectDone(new Error('device disconnected'))
|
|
99
|
+
await disconnected
|
|
100
|
+
assert.equal(calls.some(([kind]) => kind === 'stop' || kind === 'remove'), false)
|
|
101
|
+
assert.equal(state.operation, null)
|
|
102
|
+
assert.equal(state.run, null)
|
|
103
|
+
})
|
|
104
|
+
|
|
79
105
|
test('execution failures release the operation and retain the original error', async () => {
|
|
80
106
|
const { python, session, state } = fixture()
|
|
81
107
|
const failure = Object.assign(new Error('disconnected'), { code: 'SESSION_REQUIRED' })
|
|
@@ -123,7 +149,8 @@ test('interactive script input receives only user data, never the completion mar
|
|
|
123
149
|
stdout(`${text}\r\nreceived:${text.trim()}\r\n${session.repl.scriptEndMarker}\r\n>>> `)
|
|
124
150
|
} else if (text.includes('/interactive.py')) {
|
|
125
151
|
waitingForInput = true
|
|
126
|
-
|
|
152
|
+
const startMarker = /~~EVSCRIPT-START:[A-Za-z0-9-]+~~/.exec(text)?.[0]
|
|
153
|
+
stdout(`${text}\r\n${startMarker ? `${startMarker}\r\n` : ''}NAME?`)
|
|
127
154
|
} else stdout(`${text}\r\n>>> `)
|
|
128
155
|
return
|
|
129
156
|
}
|
|
@@ -4,7 +4,6 @@ import { test } from 'node:test'
|
|
|
4
4
|
import assert from 'node:assert/strict'
|
|
5
5
|
import { createDevice } from '../src/index.js'
|
|
6
6
|
import { firmwareSha256 } from '../src/firmware_flash.js'
|
|
7
|
-
import { parseCli, runCli } from '../src/cli.js'
|
|
8
7
|
|
|
9
8
|
async function fixture() {
|
|
10
9
|
const data = new Uint8Array(0x11000).fill(0xff), calls = []
|
|
@@ -108,19 +107,3 @@ test('installation with damaged readback does not reset or report success', asyn
|
|
|
108
107
|
await assert.rejects(f.device.firmware.update(f.request), /verification failed/)
|
|
109
108
|
assert.ok(!f.calls.includes('reset')); assert.equal(f.calls.at(-1), 'close')
|
|
110
109
|
})
|
|
111
|
-
|
|
112
|
-
test('CLI install uses ROM directly and passes mode through both check and update', async () => {
|
|
113
|
-
const f = await fixture(), checked = []
|
|
114
|
-
const options = parseCli(['firmware', 'update', '--mode', 'install', '--board', 'BOARD', '--port', '/dev/ttyACM0', '--version', 'new'])
|
|
115
|
-
const result = await runCli(options, {
|
|
116
|
-
transportFor: () => { throw new Error('No application transport should be acquired') },
|
|
117
|
-
async readFile() { throw new Error('No user catalog required') },
|
|
118
|
-
createDevice: () => ({ firmware: {
|
|
119
|
-
async check(request) { checked.push(request.mode); return { release: f.release } },
|
|
120
|
-
async update(request) { assert.equal(request.mode, 'install'); return { verified: true } },
|
|
121
|
-
}, async disconnect() {} }),
|
|
122
|
-
})
|
|
123
|
-
assert.equal(result.verified, true); assert.deepEqual(checked, ['install'])
|
|
124
|
-
assert.throws(() => parseCli(['firmware', 'update', '--mode', 'install', '--port', 'COM3', '--version', 'new']), /explicit --board/)
|
|
125
|
-
assert.throws(() => parseCli(['firmware', 'update', '--mode', 'install', '--tcp', 'host', '--board', 'BOARD', '--version', 'new']), /local --port/)
|
|
126
|
-
})
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
import assert from 'node:assert/strict'
|
|
4
|
+
import { readFile } from 'node:fs/promises'
|
|
5
|
+
import { test } from 'node:test'
|
|
6
|
+
import { nodeAdapterAssets } from '../src/node.js'
|
|
7
|
+
|
|
8
|
+
test('public Node asset URLs identify the exact passive SDK adapters', async () => {
|
|
9
|
+
const assets = nodeAdapterAssets()
|
|
10
|
+
assert.ok(Object.isFrozen(assets))
|
|
11
|
+
assert.deepEqual(Object.keys(assets).sort(), ['rom', 'serial'])
|
|
12
|
+
assert.match(await readFile(assets.serial, 'utf8'), /serial/)
|
|
13
|
+
assert.match(await readFile(assets.rom, 'utf8'), /esptool/)
|
|
14
|
+
assets.serial.pathname = '/changed-by-consumer'
|
|
15
|
+
assert.ok(nodeAdapterAssets().serial.pathname.endsWith('/node_serial.py'))
|
|
16
|
+
})
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
import assert from 'node:assert/strict'
|
|
4
|
+
import test from 'node:test'
|
|
5
|
+
import {
|
|
6
|
+
defaultPython, filterSerialPorts, inventory, listSerial, serialEntry, transportFor,
|
|
7
|
+
} from '../src/node.js'
|
|
8
|
+
|
|
9
|
+
test('the public Node entry point exposes passive inventory and serial adapters', () => {
|
|
10
|
+
assert.equal(typeof defaultPython, 'string')
|
|
11
|
+
assert.equal(typeof listSerial, 'function')
|
|
12
|
+
assert.equal(typeof filterSerialPorts, 'function')
|
|
13
|
+
assert.equal(typeof serialEntry, 'function')
|
|
14
|
+
assert.equal(typeof transportFor, 'function')
|
|
15
|
+
assert.equal(typeof inventory, 'function')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
test('serial listing passes a bounded non-opening enumeration request to Python', async () => {
|
|
19
|
+
const controller = new AbortController()
|
|
20
|
+
let invocation
|
|
21
|
+
const ports = await listSerial({ python: 'python-test', signal: controller.signal, timeout: 2345 }, {
|
|
22
|
+
async execFile(command, args, options) {
|
|
23
|
+
invocation = { command, args, options }
|
|
24
|
+
return { stdout: '[{"path":"/dev/ttyACM0","vendorId":1234}]' }
|
|
25
|
+
},
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
assert.deepEqual(ports, [{ path: '/dev/ttyACM0', vendorId: 1234 }])
|
|
29
|
+
assert.equal(invocation.command, 'python-test')
|
|
30
|
+
assert.match(invocation.args[0], /node_serial\.py$/)
|
|
31
|
+
assert.deepEqual(invocation.args.slice(1), ['--list'])
|
|
32
|
+
assert.equal(invocation.options.signal, controller.signal)
|
|
33
|
+
assert.equal(invocation.options.timeout, 2345)
|
|
34
|
+
assert.equal(invocation.options.maxBuffer, 1024 * 1024)
|
|
35
|
+
assert.equal(invocation.options.windowsHide, true)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('serial enumeration reports a bounded-process timeout and preserves cancellation', async () => {
|
|
39
|
+
const timedOut = Object.assign(new Error('private process details'), { code: 'ETIMEDOUT' })
|
|
40
|
+
await assert.rejects(
|
|
41
|
+
listSerial({ timeout: 250 }, { execFile: async () => { throw timedOut } }),
|
|
42
|
+
error => error.code === 'TIMEOUT' && error.cause === timedOut && !error.message.includes('private'),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
const controller = new AbortController()
|
|
46
|
+
const reason = new Error('cancelled')
|
|
47
|
+
const pending = listSerial({ signal: controller.signal }, {
|
|
48
|
+
execFile: async (_command, _args, options) => new Promise((_resolve, reject) => {
|
|
49
|
+
options.signal.addEventListener('abort', () => reject(reason), { once: true })
|
|
50
|
+
}),
|
|
51
|
+
})
|
|
52
|
+
controller.abort(reason)
|
|
53
|
+
await assert.rejects(pending, error => error === reason)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('serial candidate filtering stays platform-aware and classification is descriptor-only', () => {
|
|
57
|
+
const ports = [
|
|
58
|
+
{ path: '/dev/ttyACM0', vendorId: 0x303a, productId: 0x1001, product: 'USB JTAG/serial' },
|
|
59
|
+
{ path: '/dev/ttyUSB0' },
|
|
60
|
+
{ path: '/dev/ttyS0' },
|
|
61
|
+
]
|
|
62
|
+
assert.deepEqual(filterSerialPorts(ports, { platform: 'linux' }), ports.slice(0, 2))
|
|
63
|
+
assert.deepEqual(filterSerialPorts(ports, { platform: 'linux', allPorts: true }), ports)
|
|
64
|
+
const entry = serialEntry(ports[0])
|
|
65
|
+
assert.equal(entry.interface, 'usj')
|
|
66
|
+
assert.equal(entry.status, 'unprobed')
|
|
67
|
+
assert.equal(entry.identity, null)
|
|
68
|
+
const transport = transportFor(entry, { python: 'python-test' })
|
|
69
|
+
assert.equal(typeof transport.connect, 'function')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('inventory forwards timeout and cancellation without probing by default', async () => {
|
|
73
|
+
const controller = new AbortController()
|
|
74
|
+
let serialRequest
|
|
75
|
+
let opened = 0
|
|
76
|
+
const result = await inventory({ interfaces: ['serial'], timeout: 750, signal: controller.signal }, {
|
|
77
|
+
platform: 'linux',
|
|
78
|
+
async listSerial(request) {
|
|
79
|
+
serialRequest = request
|
|
80
|
+
return [{ path: '/dev/ttyACM1', vendorId: 0x303a, productId: 0x1001 }]
|
|
81
|
+
},
|
|
82
|
+
createDevice() {
|
|
83
|
+
opened += 1
|
|
84
|
+
throw new Error('passive inventory opened a serial port')
|
|
85
|
+
},
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
assert.equal(serialRequest.timeout, 750)
|
|
89
|
+
assert.equal(serialRequest.signal, controller.signal)
|
|
90
|
+
assert.equal(opened, 0)
|
|
91
|
+
assert.equal(result.devices.length, 1)
|
|
92
|
+
assert.equal(result.devices[0].address, '/dev/ttyACM1')
|
|
93
|
+
assert.equal(result.devices[0].status, 'unprobed')
|
|
94
|
+
assert.equal(result.devices[0].identity, null)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test('explicit inventory probing acquires and reads metadata without interrupting the REPL', async () => {
|
|
98
|
+
const controller = new AbortController()
|
|
99
|
+
let connection, inspection, disconnected = false
|
|
100
|
+
const result = await inventory({
|
|
101
|
+
interfaces: ['serial'], probe: true, probeTimeout: 1250, signal: controller.signal,
|
|
102
|
+
}, {
|
|
103
|
+
platform: 'linux',
|
|
104
|
+
async listSerial() { return [{ path: '/dev/ttyUSB7', vendorId: 0x1234 }] },
|
|
105
|
+
transportFor(row) { return { address: row.address } },
|
|
106
|
+
createDevice() {
|
|
107
|
+
return {
|
|
108
|
+
async connect(options) {
|
|
109
|
+
connection = options
|
|
110
|
+
return { identity: { deviceId: 'e8f60ae75642', board: 'TEST' } }
|
|
111
|
+
},
|
|
112
|
+
async inspect(options) { inspection = options; return { runtime: { vmReady: false } } },
|
|
113
|
+
async disconnect() { disconnected = true },
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
assert.equal(connection.role, 'unknown')
|
|
119
|
+
assert.equal(connection.timeout, 1250)
|
|
120
|
+
assert.equal(connection.signal, controller.signal)
|
|
121
|
+
assert.equal(connection.probeRepl, false)
|
|
122
|
+
assert.equal(inspection.signal, controller.signal)
|
|
123
|
+
assert.equal(disconnected, true)
|
|
124
|
+
assert.equal(result.devices[0].status, 'identified')
|
|
125
|
+
assert.deepEqual(result.devices[0].inspection, { runtime: { vmReady: false } })
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
test('inventory propagates an aborted serial enumeration', async () => {
|
|
129
|
+
const reason = new Error('cancelled')
|
|
130
|
+
await assert.rejects(
|
|
131
|
+
inventory({ interfaces: ['serial'], signal: AbortSignal.abort(reason) }, {
|
|
132
|
+
async listSerial({ signal }) { throw signal.reason },
|
|
133
|
+
}),
|
|
134
|
+
error => error === reason,
|
|
135
|
+
)
|
|
136
|
+
})
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { spawnSync } from 'node:child_process'
|
|
4
|
+
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
|
5
|
+
import { delimiter, dirname, join, resolve } from 'node:path'
|
|
6
|
+
import { fileURLToPath } from 'node:url'
|
|
7
|
+
import { test } from 'node:test'
|
|
8
|
+
import { createDevice } from '../src/device.js'
|
|
9
|
+
import { serialTransport } from '../src/node_transport.js'
|
|
10
|
+
|
|
11
|
+
const coreRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
|
12
|
+
const adapter = join(coreRoot, 'src/node_serial.py')
|
|
13
|
+
const tempBase = resolve(process.env.ESP_VISION_NPM_TEMP_DIR ?? process.env.TMPDIR ?? join(coreRoot, '.cache/node-serial'))
|
|
14
|
+
const python = [process.env.PYTHON, 'python3', 'python'].filter(Boolean).find(candidate => {
|
|
15
|
+
const result = spawnSync(candidate, ['-c', 'pass'], { encoding: 'utf8', windowsHide: true })
|
|
16
|
+
return result.status === 0
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
async function createSerialFixture() {
|
|
20
|
+
await mkdir(tempBase, { recursive: true })
|
|
21
|
+
const root = await mkdtemp(join(tempBase, 'serial-lock-'))
|
|
22
|
+
const moduleDir = join(root, 'python')
|
|
23
|
+
await mkdir(moduleDir, { recursive: true })
|
|
24
|
+
const stateFile = join(root, 'open-state.json')
|
|
25
|
+
const serialModule = `
|
|
26
|
+
import json
|
|
27
|
+
|
|
28
|
+
class SerialException(Exception):
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
class Serial:
|
|
32
|
+
def __init__(self, *args, **kwargs):
|
|
33
|
+
self.rts = None
|
|
34
|
+
self.dtr = None
|
|
35
|
+
self.port = None
|
|
36
|
+
self.exclusive = None
|
|
37
|
+
|
|
38
|
+
def open(self):
|
|
39
|
+
with open(${JSON.stringify(stateFile)}, 'w', encoding='utf-8') as stream:
|
|
40
|
+
json.dump({'port': self.port, 'rts': self.rts, 'dtr': self.dtr, 'exclusive': self.exclusive}, stream)
|
|
41
|
+
raise SerialException('Could not exclusively lock port /dev/fixture: [Errno 11] Resource temporarily unavailable')
|
|
42
|
+
|
|
43
|
+
def close(self):
|
|
44
|
+
pass
|
|
45
|
+
`
|
|
46
|
+
await writeFile(join(moduleDir, 'serial.py'), serialModule)
|
|
47
|
+
return { root, moduleDir, stateFile }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function fixturePythonEnv(moduleDir) {
|
|
51
|
+
return {
|
|
52
|
+
...process.env,
|
|
53
|
+
PYTHONPATH: [moduleDir, process.env.PYTHONPATH].filter(Boolean).join(delimiter),
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
test('POSIX serial open requests an exclusive lock and exposes SERIAL_BUSY', {
|
|
58
|
+
skip: !python || process.platform === 'win32',
|
|
59
|
+
}, async (t) => {
|
|
60
|
+
const fixture = await createSerialFixture()
|
|
61
|
+
t.after(() => rm(fixture.root, { recursive: true, force: true }))
|
|
62
|
+
const previousPythonPath = process.env.PYTHONPATH
|
|
63
|
+
process.env.PYTHONPATH = [fixture.moduleDir, previousPythonPath].filter(Boolean).join(delimiter)
|
|
64
|
+
t.after(() => {
|
|
65
|
+
if (previousPythonPath === undefined) delete process.env.PYTHONPATH
|
|
66
|
+
else process.env.PYTHONPATH = previousPythonPath
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
const direct = spawnSync(python, ['-u', adapter, '/dev/fixture'], {
|
|
70
|
+
env: fixturePythonEnv(fixture.moduleDir),
|
|
71
|
+
encoding: 'utf8',
|
|
72
|
+
windowsHide: true,
|
|
73
|
+
})
|
|
74
|
+
assert.equal(direct.status, 2, direct.stderr || direct.error?.message || 'Python adapter fixture did not fail as expected')
|
|
75
|
+
assert.equal(direct.stderr.trim(), 'SERIAL_BUSY')
|
|
76
|
+
const opened = JSON.parse(await readFile(fixture.stateFile, 'utf8'))
|
|
77
|
+
assert.deepEqual(opened, { port: '/dev/fixture', rts: false, dtr: true, exclusive: true })
|
|
78
|
+
await rm(fixture.stateFile)
|
|
79
|
+
|
|
80
|
+
const device = createDevice({ transport: serialTransport({ python, serial: '/dev/fixture' }) })
|
|
81
|
+
t.after(() => device.disconnect())
|
|
82
|
+
await assert.rejects(device.connect({ timeout: 5000 }), { code: 'SERIAL_BUSY' })
|
|
83
|
+
assert.deepEqual(JSON.parse(await readFile(fixture.stateFile, 'utf8')), opened)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
test('non-POSIX serial backends do not receive the unsupported exclusive option', {
|
|
87
|
+
skip: !python,
|
|
88
|
+
}, async () => {
|
|
89
|
+
const source = [
|
|
90
|
+
'import importlib.util, sys, types',
|
|
91
|
+
'sys.modules["serial"] = types.SimpleNamespace()',
|
|
92
|
+
'spec = importlib.util.spec_from_file_location("node_serial", sys.argv[1])',
|
|
93
|
+
'module = importlib.util.module_from_spec(spec)',
|
|
94
|
+
'spec.loader.exec_module(module)',
|
|
95
|
+
'port = types.SimpleNamespace()',
|
|
96
|
+
'module._configure_serial_exclusive(port, "nt")',
|
|
97
|
+
'assert not hasattr(port, "exclusive")',
|
|
98
|
+
].join('\n')
|
|
99
|
+
const result = spawnSync(python, ['-c', source, adapter], {
|
|
100
|
+
encoding: 'utf8',
|
|
101
|
+
windowsHide: true,
|
|
102
|
+
})
|
|
103
|
+
assert.equal(result.status, 0, result.stderr || result.error?.message || 'Python fixture failed')
|
|
104
|
+
})
|