@esp-vision/device-core 0.4.1 → 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.
@@ -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.equal(calls[1][2].maxBytes, 512)
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 () => {
@@ -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
- assert.equal((await f.device.firmware.update(f.request)).needsReset, true)
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 () => {
@@ -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
- stdout(`${text}\r\nNAME?`)
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
+ })