@esp-vision/device-core 0.4.2 → 0.5.1

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.
@@ -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
+ })
package/src/cli.js DELETED
@@ -1,185 +0,0 @@
1
- #!/usr/bin/env node
2
- // SPDX-FileCopyrightText: 2026 ESP-VISION-IDE
3
- // SPDX-License-Identifier: Apache-2.0
4
- import { parseArgs } from 'node:util'
5
- import { readFile } from 'node:fs/promises'
6
- import { realpathSync } from 'node:fs'
7
- import { resolve } from 'node:path'
8
- import { pathToFileURL } from 'node:url'
9
- import { createInterface } from 'node:readline/promises'
10
- import { Writable } from 'node:stream'
11
- import stringWidth from 'string-width'
12
- import { createDevice } from './index.js'
13
- import { openNodeRom } from './node_rom.js'
14
- import { inventory, transportFor, defaultPython } from './node_inventory.js'
15
-
16
- export const usage = `esp-vision-device inspect [--port PATH | --tcp HOST[:PORT] | --select N]
17
- [--fields identity,capabilities,runtime,network,storage] [--probe]
18
- [--interface all|serial|usb|usj|uart|tcp] [--all-ports] [--json]
19
- esp-vision-device configure (--port PATH | --tcp HOST[:PORT])
20
- [--ssid SSID [--password-stdin]] [--forget-wifi] [--network-enabled true|false]
21
- esp-vision-device control (--port PATH | --tcp HOST[:PORT])
22
- --action stop|reset|bootloader [--mode soft|hard]
23
- esp-vision-device firmware check [--port PATH | --tcp HOST[:PORT]]
24
- [--catalog FILE|HTTPS_URL] [--board BOARD --current-version VERSION] [--version VERSION] [--mode update|install]
25
- esp-vision-device firmware update [--port PATH | --tcp HOST[:PORT]]
26
- [--catalog FILE|HTTPS_URL] --version VERSION [--image FIRMWARE.bin] [--mode update|install]
27
- [--board BOARD --current-version VERSION]
28
- Common: --timeout MS (default 3000 enumeration, 30000 command),
29
- --probe-timeout MS (default 5000 per device), --role usb|usj|uart|unknown,
30
- --python PYTHON (default python3; Windows python), --json, --help
31
- Default inspect enumerates USB serial ports and LAN advertisements; --all-ports
32
- also includes native UART/Bluetooth ports. No serial
33
- port is opened. --probe and targeted commands acquire a session and can interrupt
34
- Python. Rows with unknown firmware remain visible; numbering is per invocation.
35
- Firmware versions come from dl.espressif.com by default; local catalogs are advanced overrides.
36
- Firmware update uses the built-in serial flasher (Python esptool>=5.3,<6). TCP is not OTA.
37
- Default mode update preserves the existing layout/data; explicit mode install writes
38
- a complete image, replacing data in its written range. Blank boards require --board
39
- and a ROM-capable USJ/UART port, not an application USB-OTG interface.`
40
- const error = (code, message) => Object.assign(new Error(message), { code })
41
- const fields = ['identity', 'capabilities', 'runtime', 'network', 'storage']
42
- export function parseCli(args) {
43
- const boolean = ['help', 'json', 'all-ports', 'probe', 'forget-wifi', 'password-stdin']
44
- const string = ['port', 'tcp', 'select', 'fields', 'interface', 'python', 'timeout', 'probe-timeout', 'role', 'ssid', 'network-enabled', 'action', 'mode', 'catalog', 'board', 'current-version', 'version', 'image']
45
- const { values: v, positionals: p } = parseArgs({ args, allowPositionals: true, options: Object.fromEntries([...boolean.map(key => [key, { type: 'boolean' }]), ...string.map(key => [key, { type: 'string' }])]) })
46
- if (v.help) return { help: true }
47
- const command = p[0] ?? 'inspect', action = command === 'firmware' ? p[1] : null
48
- if (!['inspect', 'configure', 'control', 'firmware'].includes(command) || p.length > (command === 'firmware' ? 2 : 1) || (command === 'firmware' && !['check', 'update'].includes(action))) throw error('ARGUMENT', 'Unknown command; use --help')
49
- const allowed = { inspect: ['all-ports', 'select', 'fields', 'interface', 'probe', 'probe-timeout'], configure: ['ssid', 'forget-wifi', 'password-stdin', 'network-enabled'], control: ['action', 'mode'], firmware: ['mode', 'catalog', 'board', 'current-version', 'version', 'image'] }[command]
50
- for (const key of Object.keys(v)) if (!['json', 'port', 'tcp', 'python', 'timeout', 'role', ...allowed].includes(key)) throw error('ARGUMENT', `Option --${key} is not valid for ${command}`)
51
- if ([v.port, v.tcp, v.select].filter(value => value !== undefined).length > 1) throw error('ARGUMENT', 'Select exactly one device address or number')
52
- if (v.role && !['usb', 'usj', 'uart', 'unknown'].includes(v.role)) throw error('ARGUMENT', 'Invalid serial role')
53
- if (v.role && !v.port) throw error('ARGUMENT', '--role requires --port')
54
- if (v.select !== undefined && (!/^\d+$/.test(v.select) || Number(v.select) < 1)) throw error('ARGUMENT', '--select must be a positive result number')
55
- const timeout = Number(v.timeout ?? (command === 'inspect' && !v.port && !v.tcp ? 3000 : 30000))
56
- if (!Number.isSafeInteger(timeout) || timeout < 50 || timeout > 600000) throw error('ARGUMENT', 'Timeout must be 50..600000 ms')
57
- const interfaces = (v.interface ?? 'all').split(',')
58
- if (interfaces.some(value => !['all', 'serial', 'usb', 'usj', 'uart', 'tcp'].includes(value))) throw error('ARGUMENT', 'Unknown interface filter')
59
- const selectedFields = v.fields?.split(',')
60
- if (selectedFields?.some(value => !fields.includes(value))) throw error('ARGUMENT', 'Unknown inspect field')
61
- if (selectedFields && !v.port && !v.tcp && !v.probe && !v.select) throw error('ARGUMENT', '--fields requires a target, --select or --probe')
62
- const probeTimeout = Number(v['probe-timeout'] ?? 5000)
63
- if (!Number.isSafeInteger(probeTimeout) || probeTimeout < 50 || probeTimeout > 60000) throw error('ARGUMENT', 'Probe timeout must be 50..60000 ms')
64
- let endpoint
65
- if (v.tcp) {
66
- const m = /^(?:\[([0-9a-fA-F:]+)\]|([^\s:/@?#]+))(?::([0-9]+))?$/.exec(v.tcp)
67
- if (!m || Number(m[3] ?? 3232) < 1 || Number(m[3] ?? 3232) > 65535) throw error('ARGUMENT', 'Expected TCP host[:port] without credentials or URL')
68
- endpoint = { interface: 'tcp', host: m[1] ?? m[2], port: Number(m[3] ?? 3232), address: v.tcp }
69
- }
70
- if (v.port) endpoint = { interface: v.role ?? 'unknown', address: v.port }
71
- if (command === 'configure' || command === 'control') {
72
- if (!endpoint) throw error('ARGUMENT', 'Specify --port or --tcp')
73
- }
74
- if (command === 'configure') {
75
- if (v['network-enabled'] !== undefined && !['true', 'false'].includes(v['network-enabled'])) throw error('ARGUMENT', 'network-enabled must be true or false')
76
- if ((v.ssid && v['forget-wifi']) || (v['password-stdin'] && !v.ssid) || (!v.ssid && !v['forget-wifi'] && v['network-enabled'] === undefined)) throw error('ARGUMENT', 'Specify SSID, forget-wifi or network-enabled')
77
- }
78
- if (command === 'control' && (!['stop', 'reset', 'bootloader'].includes(v.action) || (v.action === 'reset' ? !['soft', 'hard'].includes(v.mode) : v.mode !== undefined))) throw error('ARGUMENT', 'Control requires action; reset requires mode soft or hard')
79
- if (command === 'firmware') {
80
- if (v.mode !== undefined && !['update', 'install'].includes(v.mode)) throw error('ARGUMENT', 'Firmware mode must be update or install')
81
- if (v.mode === 'install' && !v.board) throw error('ARGUMENT', 'Firmware installation requires explicit --board')
82
- if (!endpoint && !v.board) throw error('ARGUMENT', 'Firmware requires a connected device or --board')
83
- if (action === 'update' && (!v.port || !v.version)) throw error('ARGUMENT', 'Firmware update requires local --port and explicit --version')
84
- if (action === 'check' && v.image) throw error('ARGUMENT', 'Image applies only to firmware update')
85
- }
86
- return { ...v, allPorts: v['all-ports'] ?? false, command, action: command === 'firmware' ? action : v.action, endpoint, timeout, probeTimeout, interfaces, fields: selectedFields, select: v.select === undefined ? undefined : Number(v.select), python: v.python ?? defaultPython }
87
- }
88
- async function passwordFromInput(options) {
89
- if (options['password-stdin']) {
90
- let value = ''
91
- for await (const chunk of process.stdin) { value += chunk; if (value.length > 4096) throw error('ARGUMENT', 'Password input is too long') }
92
- return value.replace(/\r?\n$/, '')
93
- }
94
- if (!process.stdin.isTTY) throw error('ARGUMENT', 'Use --password-stdin for non-interactive Wi-Fi configuration')
95
- process.stderr.write('Wi-Fi password (hidden; empty for open network): ')
96
- const output = new Writable({ write(_chunk, _encoding, callback) { callback() } })
97
- const terminal = createInterface({ input: process.stdin, output, terminal: true })
98
- terminal.once('SIGINT', () => process.kill(process.pid, 'SIGINT'))
99
- try { return await terminal.question('', { signal: options.signal }) } finally { terminal.close(); process.stderr.write('\n') }
100
- }
101
- export async function runCli(options, dependencies = {}) {
102
- if (options.help) return { help: usage }
103
- const signal = dependencies.signal
104
- const makeDevice = dependencies.createDevice ?? createDevice
105
- const scan = dependencies.inventory ?? inventory
106
- let endpoint = options.endpoint
107
- if (options.command === 'inspect' && !endpoint) {
108
- const listing = await scan({ ...options, signal, timeout: Math.min(options.timeout, 10000) }, dependencies)
109
- if (!options.select) {
110
- if (listing.sourcesAvailable === 0) throw error('ENUMERATION_UNAVAILABLE', 'No inventory source succeeded; check Python/pyserial, multicast-dns and permissions')
111
- return listing
112
- }
113
- endpoint = listing.devices.find(row => row.number === options.select)
114
- if (!endpoint) throw error('DEVICE_NOT_FOUND', 'Selected result number is unavailable in this enumeration')
115
- }
116
- const offline = options.command === 'firmware' && options.board
117
- const device = makeDevice(endpoint && !offline ? { transport: (dependencies.transportFor ?? transportFor)(endpoint, options) } : {})
118
- try {
119
- if (endpoint && !offline) await device.connect({ role: endpoint.interface === 'serial' ? 'unknown' : endpoint.interface, timeout: options.timeout, signal, ...(endpoint.advertisement ? { expectedDevice: endpoint.advertisement } : {}) })
120
- if (options.command === 'inspect') return await device.inspect({ fields: options.fields, timeout: options.timeout, signal })
121
- if (options.command === 'configure') {
122
- const network = {}
123
- if (options.ssid) network.credentials = { ssid: options.ssid, password: await (dependencies.readPassword ?? passwordFromInput)({ ...options, signal }) }
124
- if (options['forget-wifi']) network.credentials = null
125
- if (options['network-enabled'] !== undefined) network.enabled = options['network-enabled'] === 'true'
126
- await device.configure({ network, timeout: options.timeout, signal })
127
- return { ok: true } // Do not echo credentials or configuration payloads.
128
- }
129
- if (options.command === 'control') return await device.control({ action: options.action, ...(options.mode ? { mode: options.mode } : {}), timeout: options.timeout, signal })
130
- let catalog = options.catalog
131
- const mode = options.mode ?? 'update'
132
- if (catalog !== undefined && !/^https:\/\//.test(catalog)) catalog = JSON.parse(await (dependencies.readFile ?? readFile)(catalog, 'utf8'))
133
- const checked = await device.firmware.check({ catalog, mode, ...(endpoint && !offline ? {} : { identity: { board: options.board, firmwareVersion: options['current-version'] ?? 'unknown' } }), ...(options.version ? { version: options.version } : {}), signal })
134
- if (options.action === 'check') return checked
135
- return await device.firmware.update({ mode, release: checked.release, board: options.board, ...(options.image ? { data: new Uint8Array(await (dependencies.readFile ?? readFile)(options.image)) } : {}), openLoader: ({ signal }) => (dependencies.openLoader ?? openNodeRom)({ port: options.port, python: options.python, signal }), onPhase: dependencies.onPhase ?? (() => {}), signal })
136
- } finally { await device.disconnect() }
137
- }
138
- const safe = value => String(value ?? '—').replace(/[\x00-\x1f\x7f-\x9f]/g, '?')
139
- const graphemes = new Intl.Segmenter(undefined, { granularity: 'grapheme' })
140
- function wrapCell(value, width) {
141
- const lines = []; let line = '', used = 0
142
- for (const { segment } of graphemes.segment(value)) {
143
- const size = stringWidth(segment)
144
- if (used + size > width && line) { lines.push(line); line = ''; used = 0 }
145
- line += segment; used += size
146
- }
147
- lines.push(line)
148
- return lines
149
- }
150
- export function formatResult(result, { columns = process.stdout.columns ?? 120 } = {}) {
151
- if (!result.devices) return JSON.stringify(result, null, 2)
152
- const headers = ['#', 'Group', 'Interface', 'Address', 'Status', 'Board', 'Firmware', 'Device ID', 'USB serial', 'Hostname', 'USB VID:PID', 'USB product', 'Error']
153
- const rows = result.devices.map(row => [row.number, row.group, row.interface, row.address, row.status, row.identity?.board, row.identity?.firmwareVersion, row.identity?.deviceId, row.usb?.serialNumber, row.identity?.hostname, row.usb?.vendorId == null ? null : `${row.usb.vendorId.toString(16).padStart(4, '0')}:${(row.usb.productId ?? 0).toString(16).padStart(4, '0')}`, row.usb?.product, row.error?.code].map(safe))
154
- if (!rows.length) return 'No devices found.'
155
- const widths = headers.map((header, index) => Math.max(stringWidth(header), ...rows.map(row => stringWidth(row[index]))))
156
- if (widths.reduce((sum, width) => sum + width, 0) + 2 * (headers.length - 1) <= columns) {
157
- return [headers, ...rows].map(row => row.map((value, index) => value + ' '.repeat(widths[index] - stringWidth(value))).join(' ').trimEnd()).join('\n')
158
- }
159
- // Keep every field readable when a full table would wrap in a narrow terminal.
160
- const labelWidth = Math.max(...headers.map(value => stringWidth(value)))
161
- const indent = ' '.repeat(labelWidth + 2)
162
- return rows.map(row => headers.map((header, index) => {
163
- const lines = wrapCell(row[index], Math.max(2, columns - indent.length))
164
- return `${header.padEnd(labelWidth)}: ${lines.join(`\n${indent}`)}`
165
- }).join('\n')).join('\n\n')
166
- }
167
-
168
- function isMain() {
169
- try { return Boolean(process.argv[1]) && pathToFileURL(realpathSync(process.argv[1])).href === import.meta.url }
170
- catch { return false }
171
- }
172
- if (isMain()) {
173
- const controller = new AbortController()
174
- const abort = () => controller.abort(error('CANCELLED', 'Command cancelled'))
175
- process.once('SIGINT', abort); process.once('SIGTERM', abort)
176
- try {
177
- const options = parseCli(process.argv.slice(2))
178
- const result = await runCli(options, { signal: controller.signal, onPhase: message => console.error(message) })
179
- console.log(options.help ? usage : options.json ? JSON.stringify(result) : formatResult(result))
180
- if (!options.json) for (const warning of result.warnings ?? []) console.error(`${warning.source}: ${warning.message}`)
181
- } catch (failure) {
182
- console.error(JSON.stringify({ ok: false, code: failure.code ?? 'COMMAND_FAILED', message: failure.code === 'ESPTOOL_UNAVAILABLE' || failure.code === 'ARGUMENT' || failure.code?.startsWith('ERR_PARSE_ARGS') ? failure.message : 'Command failed; check device access, firmware compatibility, arguments and adapter dependencies' }))
183
- process.exitCode = controller.signal.aborted ? 130 : 1
184
- } finally { process.removeListener('SIGINT', abort); process.removeListener('SIGTERM', abort) }
185
- }
@@ -1,31 +0,0 @@
1
- // SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
2
- // SPDX-License-Identifier: Apache-2.0
3
- import test from 'node:test'
4
- import assert from 'node:assert/strict'
5
- import stringWidth from 'string-width'
6
- import { formatResult } from '../src/cli.js'
7
-
8
- test('wide tables align columns by terminal cells with CJK, combining characters and emoji', () => {
9
- const devices = ['COM3', '/dev/开发板0', '/dev/e\u0301-👩‍💻'].map((address, index) => ({ number: index + 1, interface: 'usb', address, status: 'unprobed', identity: { board: 'BOARD', firmwareVersion: 'v1' } }))
10
- const text = formatResult({ devices }, { columns: 1000 })
11
- assert.ok(!text.includes('\t') && !text.includes('\\t'))
12
- const lines = text.split('\n')
13
- assert.equal(lines.length, 4)
14
- const column = (line, word) => stringWidth(line.slice(0, line.indexOf(word)))
15
- assert.ok(lines.slice(1).every(line => column(line, 'unprobed') === column(lines[0], 'Status')))
16
- assert.ok(lines.slice(1).every(line => column(line, 'BOARD') === column(lines[0], 'Board')))
17
- })
18
-
19
- test('narrow terminals wrap full identifiers without losing Unicode graphemes or data', () => {
20
- const address = '/dev/serial/by-id/usb-' + '开发板👩‍💻e\u0301'.repeat(12)
21
- const text = formatResult({ devices: [{ number: 1, interface: 'usj', address, status: 'unprobed', usb: { serialNumber: 'SERIAL-123' } }] }, { columns: 60 })
22
- assert.ok(text.split('\n').every(line => stringWidth(line) <= 60))
23
- const value = text.split('\n').slice(text.split('\n').findIndex(line => line.startsWith('Address'))).join('\n').split('\nStatus')[0]
24
- assert.equal(value.replace(/^Address\s*: /, '').replace(/\n +/g, ''), address)
25
- assert.ok(text.includes('USB serial : SERIAL-123'))
26
- })
27
-
28
- test('empty discovery is explicit and non-inventory results keep their JSON format', () => {
29
- assert.equal(formatResult({ devices: [] }), 'No devices found.')
30
- assert.equal(formatResult({ ok: true }), JSON.stringify({ ok: true }, null, 2))
31
- })
@@ -1,50 +0,0 @@
1
- // SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
2
- // SPDX-License-Identifier: Apache-2.0
3
- import test from 'node:test'
4
- import assert from 'node:assert/strict'
5
- import { filterSerialPorts, inventory } from '../src/node_inventory.js'
6
- import { parseCli, runCli } from '../src/cli.js'
7
-
8
- const names = (ports, platform, allPorts = false) => filterSerialPorts(ports, { platform, allPorts }).map(port => port.path)
9
-
10
- test('Linux USB candidates survive missing descriptors and unrecognized vendors; native ports are opt-in', () => {
11
- const ports = ['/dev/ttyACM0', '/dev/ttyUSB3', '/dev/ttyXRUSB0', '/dev/ttyS0', '/dev/ttyAMA0', '/dev/ttyTHS1', '/dev/rfcomm0', '/dev/tty0', '/dev/console', '/dev/pts/1', 'COM3'].map(path => ({ path }))
12
- ports.push({ path: '/dev/customDriver0', vendorId: 0x9999 }, { path: '/dev/another0', hwid: 'USB VID:PID=9999:0001' })
13
- assert.deepEqual(names(ports, 'linux'), ['/dev/ttyACM0', '/dev/ttyUSB3', '/dev/ttyXRUSB0', '/dev/customDriver0', '/dev/another0'])
14
- assert.deepEqual(names(ports, 'linux', true), [...ports.slice(0, 7), ...ports.slice(11)].map(port => port.path))
15
- })
16
-
17
- test('Windows COM10+ and extended handles are accepted; native and virtual COM ports require all-ports', () => {
18
- const ports = [{ path: 'COM3', vendorId: 0x303a }, { path: 'COM10', vendorId: 0x9999 }, { path: '\\\\.\\COM256', hwid: 'USB VID:PID=9999:0001' }, { path: 'com10', vendorId: 0x9999 }, { path: 'COM1' }, { path: 'COM7', hwid: 'BTHENUM' }, ...['COM0', 'COM-1', 'LPT1', '/dev/ttyUSB0', 'COM3\n'].map(path => ({ path, vendorId: 0x303a }))]
19
- assert.deepEqual(names(ports, 'win32'), ['COM3', 'COM10', '\\\\.\\COM256'])
20
- assert.deepEqual(names(ports, 'win32', true), ['COM3', 'COM10', '\\\\.\\COM256', 'COM1', 'COM7'])
21
- })
22
-
23
- test('macOS prefers callout endpoints without merging separate USB interfaces', () => {
24
- const paths = ['/dev/tty.usbmodem101', '/dev/cu.usbmodem101', '/dev/cu.usbmodem102', '/dev/cu.usbserial-A', '/dev/cu.wchusbserial42', '/dev/cu.SLAB_USBtoUART', '/dev/cu.Bluetooth-Incoming-Port', '/dev/cu.debug-console', '/dev/tty.usbserial-B']
25
- const ports = paths.map(path => ({ path }))
26
- ports.push({ path: '/dev/cu.vendor device', vendorId: 0x9999 }, { path: '/dev/ttyACM0', vendorId: 0x303a })
27
- const expected = [...paths.slice(1, 6), paths[8], '/dev/cu.vendor device']
28
- assert.deepEqual(names(ports, 'darwin'), expected)
29
- assert.deepEqual(names(ports, 'darwin', true), [...paths.slice(1), '/dev/cu.vendor device'])
30
- })
31
-
32
- test('inspect all-ports reaches passive enumeration and default query remains inspect', async () => {
33
- assert.equal(parseCli([]).command, 'inspect')
34
- assert.equal(parseCli(['inspect']).allPorts, false)
35
- const options = parseCli(['inspect', '--all-ports', '--interface', 'serial'])
36
- const result = await runCli(options, { platform: 'linux', inventory, async listSerial() { return [{ path: '/dev/ttyS0' }, { path: '/dev/ttyUSB0' }] }, createDevice() { throw new Error('Opened passive port') } })
37
- assert.equal(result.devices.length, 2)
38
- assert.throws(() => parseCli(['configure', '--port', 'COM3', '--forget-wifi', '--all-ports']))
39
- })
40
-
41
- test('explicit ports bypass enumeration policy, including UARTs and stable aliases', async () => {
42
- for (const path of ['/dev/ttyS0', '/dev/serial/by-id/usb-board', '/dev/tty.vendor', 'COM1']) {
43
- let opened
44
- await runCli(parseCli(['inspect', '--port', path]), {
45
- inventory() { throw new Error('Explicit target invoked discovery') }, transportFor: row => row,
46
- createDevice({ transport }) { return { async connect() { opened = transport.address }, async inspect() { return {} }, async disconnect() {} } },
47
- })
48
- assert.equal(opened, path)
49
- }
50
- })
@@ -1,98 +0,0 @@
1
- // SPDX-FileCopyrightText: 2026 ESP-VISION-IDE
2
- // SPDX-License-Identifier: Apache-2.0
3
- import { test } from 'node:test'
4
- import assert from 'node:assert/strict'
5
- import { inventory, serialEntry } from '../src/node_inventory.js'
6
- import { parseCli, runCli, formatResult } from '../src/cli.js'
7
- const identity = { deviceId: 'abcdef123456', board: 'BOARD', firmwareVersion: 'v1', hostname: 'esp-vision-board' }
8
- const ports = [{ path: '/dev/ttyACM0', vendorId: 0x303a, productId: 0x1001, serialNumber: 'abc' }, { path: '/dev/ttyUSB0', vendorId: 0x10c4, productId: 0xea60 }, { path: '/dev/ttyACM1', vendorId: 0x303a, productId: 0x4003, product: 'ESP-VISION BOARD' }, { path: '/dev/ttyS0' }]
9
- const advertisement = { ...identity, addresses: ['192.168.1.7'], port: 3232, evMuxVersion: 4 }
10
- const deps = { platform: 'linux', async listSerial() { return ports }, async listNetwork() { return [advertisement] } }
11
- test('one inspect command supports Windows, Linux, macOS paths and strict command options', () => {
12
- for (const serial of ['COM3', '/dev/ttyACM0', '/dev/cu.usbserial']) assert.equal(parseCli(['inspect', '--port', serial]).endpoint.address, serial)
13
- assert.equal(parseCli(['inspect', '--tcp', '[::1]:3232']).endpoint.host, '::1')
14
- for (const args of [['discover'], ['info'], ['inspect', '--tcp', 'tcp://user:pass@host'], ['inspect', '--port', 'a', '--tcp', 'b'], ['inspect', '--fields', 'network'], ['control', '--port', 'a', '--action', 'reset'], ['configure', '--port', 'a', '--ssid', 'x', '--forget-wifi'], ['firmware','update','--catalog','x','--board','BOARD','--current-version','v1']]) assert.throws(() => parseCli(args))
15
- })
16
- test('passive inventory never creates a session, keeps unidentified ports and numbers all results', async () => {
17
- const result = await inventory({}, { ...deps, createDevice() { throw new Error('Passive enumeration opened a device') } })
18
- assert.equal(result.devices.length, 4)
19
- assert.equal(result.devices[0].status, 'advertised')
20
- assert.deepEqual(result.devices.map(row => row.number), [1, 2, 3, 4])
21
- const serial = result.devices.filter(row => row.interface !== 'tcp')
22
- assert.ok(serial.every(row => row.identity === null && row.status === 'unprobed'))
23
- assert.deepEqual(new Set(serial.map(row => row.interface)), new Set(['usj', 'usb', 'uart']))
24
- })
25
- test('USB product names are descriptor hints, never board identity or proof of firmware', () => {
26
- const row = serialEntry(ports[2]); assert.equal(row.interface, 'usb'); assert.equal(row.identity, null)
27
- assert.equal(serialEntry({ path: 'COM7', vendorId: 0x9999, product: 'Unknown ROM' }).interface, 'serial')
28
- })
29
- test('interface filters avoid unrelated discovery providers', async () => {
30
- const serial = await inventory({ interfaces: ['uart'] }, { ...deps, listNetwork() { throw new Error('Unexpected LAN access') } })
31
- assert.equal(serial.devices.length, 1); assert.deepEqual(serial.warnings, [])
32
- const tcp = await inventory({ interfaces: ['tcp'] }, { ...deps, listSerial() { throw new Error('Unexpected serial access') } })
33
- assert.equal(tcp.devices.length, 1); assert.deepEqual(tcp.warnings, [])
34
- })
35
- test('active probe groups one board across USB/TCP and retains failed ports at the end', async () => {
36
- let disconnects = 0
37
- const result = await inventory({ probe: true }, { ...deps, transportFor: row => row,
38
- createDevice({ transport }) { return { async connect() { if (transport.address === '/dev/ttyACM0') throw Object.assign(new Error('busy'), { code: 'PORT_BUSY' }); return { identity } }, async inspect() { return { identity } }, async disconnect() { disconnects++ } } } })
39
- assert.equal(disconnects, 4)
40
- assert.ok(result.devices.slice(0, 3).every(row => row.group === 1 && row.status === 'identified'))
41
- assert.equal(result.devices.at(-1).address, '/dev/ttyACM0'); assert.equal(result.devices.at(-1).error.code, 'PORT_BUSY')
42
- })
43
- test('partial discovery failure preserves valid results and empty successful discovery is valid', async () => {
44
- const result = await inventory({}, { ...deps, async listNetwork() { throw new Error('missing mDNS') } })
45
- assert.equal(result.sourcesAvailable, 1); assert.equal(result.devices.length, 3); assert.equal(result.warnings[0].source, 'tcp')
46
- const empty = await runCli(parseCli(['inspect']), { inventory: async () => ({ devices: [], warnings: [{ source: 'tcp' }], sourcesAvailable: 1 }) })
47
- assert.deepEqual(empty.devices, [])
48
- })
49
- test('targeted inspection calls only Core facade with selected fields and always disconnects', async () => {
50
- const calls = []
51
- const result = await runCli(parseCli(['inspect','--port','COM3','--fields','identity,capabilities']), { transportFor: row => row, createDevice() { return { async connect(options) { calls.push(options.role) }, async inspect(request) { calls.push(request.fields); return { identity } }, async disconnect() { calls.push('disconnect') } } } })
52
- assert.deepEqual(result.identity, identity); assert.deepEqual(calls, ['unknown', ['identity','capabilities'], 'disconnect'])
53
- })
54
- test('selection resolves the same numbered enumeration snapshot, with discovered identity check', async () => {
55
- let expected
56
- await runCli(parseCli(['inspect','--select','1']), { inventory: async () => ({ devices: [{ number: 1, interface: 'tcp', advertisement }], warnings: [], sourcesAvailable: 1 }), transportFor: row => row, createDevice() { return { async connect(request) { expected = request.expectedDevice }, async inspect() { return {} }, async disconnect() {} } } })
57
- assert.equal(expected, advertisement)
58
- })
59
- test('configure keeps Wi-Fi password out of results and forwards it only to configure', async () => {
60
- let patch, closed = false
61
- const result = await runCli(parseCli(['configure','--port','COM3','--ssid','WIFI','--password-stdin']), { transportFor: row => row, readPassword: async () => 'secret', createDevice() { return { async connect() {}, async configure(request) { patch = request.network }, async disconnect() { closed = true } } } })
62
- assert.equal(patch.credentials.password, 'secret'); assert.deepEqual(result, { ok: true }); assert.equal(closed, true)
63
- })
64
- test('failed device operation still releases transport and session', async () => {
65
- let closed = false
66
- await assert.rejects(runCli(parseCli(['inspect','--tcp','localhost']), { transportFor: row => row, createDevice() { return { async connect() {}, async inspect() { throw new Error('failed') }, async disconnect() { closed = true } } } }), /failed/)
67
- assert.equal(closed, true)
68
- })
69
- test('firmware CLI checks catalog/version then updates through the existing facade', async () => {
70
- const calls = [], selected = { board: 'BOARD', version: 'v2' }
71
- const result = await runCli(parseCli(['firmware','update','--catalog','catalog.json','--board','BOARD','--current-version','v1','--version','v2','--port','COM3']), { readFile: async () => '{"schemaVersion":1,"releases":[]}', openLoader() {}, createDevice() { return { firmware: { async check(request) { calls.push(request.version); return { release: selected } }, async update(request) { assert.equal(request.release, selected); return { verified: true } } }, async disconnect() { calls.push('disconnect') } } } })
72
- assert.equal(result.verified, true); assert.deepEqual(calls, ['v2', 'disconnect'])
73
- })
74
- test('terminal output escapes control characters in device-provided data', () => {
75
- const text = formatResult({ devices: [{ number: 1, address: 'COM3\x1b[31m', identity: { board: 'BOARD\nBAD' } }] })
76
- assert.ok(!text.includes('\x1b')); assert.ok(text.includes('BOARD?BAD'))
77
- })
78
-
79
- test('cancelled active enumeration still releases the probed session', async () => {
80
- const controller = new AbortController(); let closed = false
81
- await assert.rejects(inventory({ probe: true, signal: controller.signal, interfaces: ['usj'] }, { ...deps, transportFor: row => row, createDevice() { return { async connect() { controller.abort(new Error('cancelled')); throw controller.signal.reason }, async disconnect() { closed = true } } } }), /cancelled/)
82
- assert.equal(closed, true)
83
- })
84
-
85
- test('control passes the requested action and reset mode to Core without alias commands', async () => {
86
- let request
87
- const result = await runCli(parseCli(['control', '--port', 'COM3', '--action', 'reset', '--mode', 'soft']), { transportFor: row => row, createDevice() { return { async connect() {}, async control(value) { request = value; return { ok: true } }, async disconnect() {} } } })
88
- assert.equal(result.ok, true); assert.equal(request.action, 'reset'); assert.equal(request.mode, 'soft')
89
- })
90
-
91
- test('normal firmware commands need neither a catalog nor a user loader module', async () => {
92
- const options = parseCli(['firmware', 'update', '--port', 'COM3', '--version', 'new', '--board', 'BOARD'])
93
- let loaderPort, request
94
- await runCli(options, { transportFor: row => row, openLoader: ({ port }) => { loaderPort = port; return {} }, createDevice() { return { firmware: { async check(value) { request = value; return { release: { version: 'new' } } }, async update(value) { await value.openLoader({}); return { verified: true } } }, async disconnect() {} } } })
95
- assert.equal(request.catalog, undefined); assert.equal(loaderPort, 'COM3')
96
- assert.throws(() => parseCli(['firmware', 'update', '--port', 'COM3', '--version', 'new', '--loader', 'user.mjs']))
97
- assert.throws(() => parseCli(['inspect', '--serial', 'COM3']))
98
- })