@esp-vision/device-core 0.1.0 → 0.1.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.
- package/CHANGELOG.md +19 -17
- package/README.md +47 -12
- package/package.json +8 -4
- package/schemas/device-actions-v1.json +1 -1
- package/scripts/generate-action-schema.mjs +36 -0
- package/scripts/release-npm.mjs +67 -0
- package/scripts/run-tests.mjs +16 -0
- package/tests/check-package.mjs +84 -0
- package/tests/device-checks.mjs +155 -0
- package/tests/fixtures/device-checks.py +317 -0
- package/tests/hardware-check.mjs +140 -0
- package/tests/test-cli-output.mjs +31 -0
- package/tests/test-cli-ports.mjs +50 -0
- package/tests/test-cli.mjs +98 -0
- package/tests/test-device-actions.mjs +381 -0
- package/tests/test-device-api.mjs +184 -0
- package/tests/test-device-execution.mjs +284 -0
- package/tests/test-device-files-api.mjs +202 -0
- package/tests/test-device-firmware.mjs +133 -0
- package/tests/test-device-functional-checks.mjs +98 -0
- package/tests/test-device-hardware-cli.mjs +103 -0
- package/tests/test-device-operations-api.mjs +210 -0
- package/tests/test-device-python-api.mjs +194 -0
- package/tests/test-device-v4-alignment.mjs +189 -0
- package/tests/test-firmware-flash.mjs +89 -0
- package/tests/test-network-discovery.mjs +153 -0
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Device-side camera/LCD fixtures migrated from tools/ci/device_checks.py.
|
|
4
|
+
|
|
5
|
+
All transport, ownership and cleanup are supplied by the public JS SDK.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def framesizes(opts):
|
|
10
|
+
import sensor
|
|
11
|
+
sensor.reset()
|
|
12
|
+
sensor.set_pixformat(sensor.RGB565)
|
|
13
|
+
results = []
|
|
14
|
+
for name in ('QQVGA', 'QVGA', 'VGA'):
|
|
15
|
+
try:
|
|
16
|
+
sensor.set_framesize(getattr(sensor, name))
|
|
17
|
+
sensor.skip_frames(n=2)
|
|
18
|
+
img = sensor.snapshot()
|
|
19
|
+
results.append(dict(name=name, supported=True, width=img.width(), height=img.height(),
|
|
20
|
+
reportedWidth=sensor.width(), reportedHeight=sensor.height()))
|
|
21
|
+
except Exception:
|
|
22
|
+
results.append(dict(name=name, supported=False))
|
|
23
|
+
working = [item for item in results if item['supported']]
|
|
24
|
+
assert working, 'no framesize produced a frame'
|
|
25
|
+
for item in working:
|
|
26
|
+
assert item['width'] == item['reportedWidth'] and item['height'] == item['reportedHeight'], 'frame size differs from sensor-reported size: ' + item['name']
|
|
27
|
+
return dict(results=results)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def pixformats(opts):
|
|
31
|
+
import sensor
|
|
32
|
+
sensor.reset()
|
|
33
|
+
sensor.set_framesize(getattr(sensor, opts['framesize']))
|
|
34
|
+
results = []
|
|
35
|
+
for name, depth in (('RGB565', 2), ('GRAYSCALE', 1)):
|
|
36
|
+
try:
|
|
37
|
+
sensor.set_pixformat(getattr(sensor, name))
|
|
38
|
+
sensor.skip_frames(n=2)
|
|
39
|
+
img = sensor.snapshot()
|
|
40
|
+
results.append(dict(name=name, supported=True, width=img.width(), height=img.height(),
|
|
41
|
+
actualBytes=img.size(), expectedBytes=img.width() * img.height() * depth))
|
|
42
|
+
except Exception:
|
|
43
|
+
results.append(dict(name=name, supported=False))
|
|
44
|
+
working = [item for item in results if item['supported']]
|
|
45
|
+
assert working, 'no pixel format produced a frame'
|
|
46
|
+
for item in working:
|
|
47
|
+
assert item['actualBytes'] == item['expectedBytes'], 'pixel buffer size mismatch: ' + item['name']
|
|
48
|
+
return dict(results=results)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def orientation(opts):
|
|
52
|
+
import sensor
|
|
53
|
+
sensor.reset()
|
|
54
|
+
sensor.set_pixformat(getattr(sensor, opts['pixformat']))
|
|
55
|
+
sensor.set_framesize(getattr(sensor, opts['framesize']))
|
|
56
|
+
sensor.skip_frames(n=3)
|
|
57
|
+
sensor.set_hmirror(True)
|
|
58
|
+
sensor.set_vflip(True)
|
|
59
|
+
sensor.skip_frames(n=2)
|
|
60
|
+
img = sensor.snapshot()
|
|
61
|
+
mirrored = (int(sensor.get_hmirror()), int(sensor.get_vflip()))
|
|
62
|
+
sensor.set_hmirror(False)
|
|
63
|
+
sensor.set_vflip(False)
|
|
64
|
+
sensor.skip_frames(n=2)
|
|
65
|
+
sensor.snapshot()
|
|
66
|
+
assert mirrored == (1, 1), 'mirror or flip did not turn on'
|
|
67
|
+
assert not sensor.get_hmirror() and not sensor.get_vflip(), 'mirror or flip did not turn off'
|
|
68
|
+
return dict(width=img.width(), height=img.height(), mirrored=list(mirrored))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def ipa_stats(opts):
|
|
72
|
+
import sensor
|
|
73
|
+
sensor.reset()
|
|
74
|
+
sensor.set_pixformat(getattr(sensor, opts['pixformat']))
|
|
75
|
+
sensor.set_framesize(getattr(sensor, opts['framesize']))
|
|
76
|
+
sensor.skip_frames(n=3)
|
|
77
|
+
import json
|
|
78
|
+
if not hasattr(sensor, 'get_ipa_stats'):
|
|
79
|
+
raise RuntimeError('ESP_ERR_NOT_SUPPORTED: IPA statistics API absent')
|
|
80
|
+
original = sensor.get_ipa()
|
|
81
|
+
if original is None:
|
|
82
|
+
raise RuntimeError('ESP_ERR_NOT_SUPPORTED: runtime IPA unavailable')
|
|
83
|
+
|
|
84
|
+
def uint(value, bits):
|
|
85
|
+
assert type(value) is int and 0 <= value < 1 << bits, 'invalid unsigned statistic'
|
|
86
|
+
|
|
87
|
+
def awb(value):
|
|
88
|
+
assert isinstance(value, dict), 'AWB must be a dictionary'
|
|
89
|
+
assert set(value) == set(('counted', 'sum_r', 'sum_g', 'sum_b')), 'AWB fields'
|
|
90
|
+
for number in value.values():
|
|
91
|
+
uint(number, 32)
|
|
92
|
+
|
|
93
|
+
def grid(value, validate):
|
|
94
|
+
assert isinstance(value, list) and len(value) == 5, 'grid X size'
|
|
95
|
+
for column in value:
|
|
96
|
+
assert isinstance(column, list) and len(column) == 5, 'grid Y size'
|
|
97
|
+
for cell in column:
|
|
98
|
+
validate(cell)
|
|
99
|
+
|
|
100
|
+
def validate(stats):
|
|
101
|
+
assert isinstance(stats, dict), 'statistics must be a dictionary'
|
|
102
|
+
keys = ('seq', 'flags', 'ae', 'awb', 'awb_subwin', 'hist', 'sharpen', 'af')
|
|
103
|
+
assert set(stats) == set(keys), 'statistics fields'
|
|
104
|
+
uint(stats['seq'], 64)
|
|
105
|
+
uint(stats['flags'], 6)
|
|
106
|
+
assert stats['flags'] != 0, 'no valid statistics blocks'
|
|
107
|
+
for name, flag in (('awb', 1), ('ae', 2), ('hist', 4), ('sharpen', 8), ('af', 16), ('awb_subwin', 32)):
|
|
108
|
+
assert (stats[name] is not None) == bool(stats['flags'] & flag), 'validity mismatch: ' + name
|
|
109
|
+
if stats['ae'] is not None:
|
|
110
|
+
grid(stats['ae'], lambda value: uint(value, 32))
|
|
111
|
+
if stats['awb'] is not None:
|
|
112
|
+
awb(stats['awb'])
|
|
113
|
+
if stats['awb_subwin'] is not None:
|
|
114
|
+
assert stats['awb'] is not None, 'subwindows without global AWB'
|
|
115
|
+
grid(stats['awb_subwin'], awb)
|
|
116
|
+
if stats['hist'] is not None:
|
|
117
|
+
assert isinstance(stats['hist'], list) and len(stats['hist']) == 16, 'histogram size'
|
|
118
|
+
for value in stats['hist']:
|
|
119
|
+
uint(value, 32)
|
|
120
|
+
if stats['sharpen'] is not None:
|
|
121
|
+
uint(stats['sharpen'], 8)
|
|
122
|
+
if stats['af'] is not None:
|
|
123
|
+
assert isinstance(stats['af'], list) and len(stats['af']) == 3, 'AF window count'
|
|
124
|
+
for window in stats['af']:
|
|
125
|
+
assert isinstance(window, dict), 'AF must be a dictionary'
|
|
126
|
+
assert set(window) == set(('definition', 'luminance')), 'AF fields'
|
|
127
|
+
uint(window['definition'], 32)
|
|
128
|
+
uint(window['luminance'], 32)
|
|
129
|
+
|
|
130
|
+
def sample():
|
|
131
|
+
for _ in range(5):
|
|
132
|
+
sensor.skip_frames(n=2)
|
|
133
|
+
stats = sensor.get_ipa_stats(timeout=200)
|
|
134
|
+
if stats is not None:
|
|
135
|
+
validate(stats)
|
|
136
|
+
return stats
|
|
137
|
+
raise RuntimeError('no IPA statistics after five reads')
|
|
138
|
+
sequences = []
|
|
139
|
+
flags = []
|
|
140
|
+
try:
|
|
141
|
+
sensor.set_ipa(True)
|
|
142
|
+
assert sensor.get_ipa() is True, 'IPA did not start'
|
|
143
|
+
for timeout in (-1, 0, 1001):
|
|
144
|
+
try:
|
|
145
|
+
sensor.get_ipa_stats(timeout=timeout)
|
|
146
|
+
except ValueError:
|
|
147
|
+
pass
|
|
148
|
+
else:
|
|
149
|
+
raise RuntimeError('invalid timeout accepted')
|
|
150
|
+
for _ in range(3):
|
|
151
|
+
stats = sample()
|
|
152
|
+
if sequences:
|
|
153
|
+
assert stats['seq'] > sequences[-1], 'stale statistics sequence'
|
|
154
|
+
sequences.append(stats['seq'])
|
|
155
|
+
flags.append(stats['flags'])
|
|
156
|
+
sensor.set_ipa(False)
|
|
157
|
+
assert sensor.get_ipa() is False, 'IPA did not stop'
|
|
158
|
+
sensor.snapshot()
|
|
159
|
+
try:
|
|
160
|
+
sensor.get_ipa_stats(timeout=200)
|
|
161
|
+
except OSError as exc:
|
|
162
|
+
assert 'ESP_ERR_INVALID_STATE' in str(exc), 'wrong disabled-IPA error'
|
|
163
|
+
else:
|
|
164
|
+
raise RuntimeError('statistics read accepted while IPA disabled')
|
|
165
|
+
sensor.set_ipa(True)
|
|
166
|
+
assert sensor.get_ipa() is True, 'IPA did not restart'
|
|
167
|
+
stats = sample()
|
|
168
|
+
restarted_seq = stats['seq']
|
|
169
|
+
sensor.snapshot()
|
|
170
|
+
finally:
|
|
171
|
+
sensor.set_ipa(original)
|
|
172
|
+
assert sensor.get_ipa() is original, 'IPA state was not restored'
|
|
173
|
+
return dict(seq=sequences, flags=flags, restarted_seq=restarted_seq)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def snapshot_rate(opts):
|
|
177
|
+
import sensor
|
|
178
|
+
sensor.reset()
|
|
179
|
+
sensor.set_pixformat(getattr(sensor, opts['pixformat']))
|
|
180
|
+
sensor.set_framesize(getattr(sensor, opts['framesize']))
|
|
181
|
+
sensor.skip_frames(n=3)
|
|
182
|
+
import time
|
|
183
|
+
img = sensor.snapshot()
|
|
184
|
+
start = time.ticks_ms()
|
|
185
|
+
for _ in range(opts['frames']):
|
|
186
|
+
img = sensor.snapshot()
|
|
187
|
+
elapsed = time.ticks_diff(time.ticks_ms(), start)
|
|
188
|
+
assert elapsed > 0, 'implausible capture time'
|
|
189
|
+
return dict(width=img.width(), height=img.height(), elapsedMs=elapsed, frames=opts['frames'])
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def preview(opts):
|
|
193
|
+
import sensor
|
|
194
|
+
sensor.reset()
|
|
195
|
+
sensor.set_pixformat(getattr(sensor, opts['pixformat']))
|
|
196
|
+
sensor.set_framesize(getattr(sensor, opts['framesize']))
|
|
197
|
+
sensor.skip_frames(n=3)
|
|
198
|
+
for _ in range(opts['frames']):
|
|
199
|
+
sensor.snapshot().flush()
|
|
200
|
+
return dict(flushed=opts['frames'])
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def image_ops(opts):
|
|
204
|
+
import sensor
|
|
205
|
+
sensor.reset()
|
|
206
|
+
sensor.set_pixformat(getattr(sensor, opts['pixformat']))
|
|
207
|
+
sensor.set_framesize(getattr(sensor, opts['framesize']))
|
|
208
|
+
sensor.skip_frames(n=3)
|
|
209
|
+
img = sensor.snapshot()
|
|
210
|
+
width, height = (img.width(), img.height())
|
|
211
|
+
img.draw_rectangle(4, 4, 40, 30, color=(255, 0, 0), thickness=2)
|
|
212
|
+
img.draw_line(0, 0, width - 1, height - 1, color=(0, 255, 0), thickness=2)
|
|
213
|
+
img.draw_string(4, 40, 'CI', color=(255, 255, 255), scale=2)
|
|
214
|
+
half = img.copy(x_scale=0.5, y_scale=0.5)
|
|
215
|
+
mean = img.get_statistics().mean()
|
|
216
|
+
jpeg = img.compress(quality=60)
|
|
217
|
+
assert half.width() == width // 2 and half.height() == height // 2, 'incorrect half-scale image'
|
|
218
|
+
assert jpeg.size() > 0, 'empty compressed JPEG'
|
|
219
|
+
return dict(width=width, height=height, halfWidth=half.width(), halfHeight=half.height(), mean=mean, jpegBytes=jpeg.size())
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
_lcd = None
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def close_display():
|
|
226
|
+
global _lcd
|
|
227
|
+
lcd, _lcd = _lcd, None
|
|
228
|
+
if lcd is not None:
|
|
229
|
+
lcd.deinit()
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def display(opts):
|
|
233
|
+
global _lcd
|
|
234
|
+
import display, image, time
|
|
235
|
+
close_display()
|
|
236
|
+
lcd = _lcd = display.Display(backlight=100)
|
|
237
|
+
try:
|
|
238
|
+
frame = image.Image(lcd.width(), lcd.height(), image.RGB565)
|
|
239
|
+
colors = ((255, 0, 0), (0, 255, 0), (0, 0, 255), (255, 255, 255))
|
|
240
|
+
bar = frame.width() // len(colors)
|
|
241
|
+
for index, color in enumerate(colors):
|
|
242
|
+
frame.draw_rectangle(index * bar, 0, bar, frame.height(), color=color, fill=True)
|
|
243
|
+
frame.draw_string(8, 8, 'ESP-VISION CI', color=(0, 0, 0), scale=2)
|
|
244
|
+
lcd.write(frame, fit=False)
|
|
245
|
+
time.sleep(opts['displayHold'])
|
|
246
|
+
lcd.backlight(40)
|
|
247
|
+
dimmed = lcd.backlight()
|
|
248
|
+
time.sleep(opts['displayHold'])
|
|
249
|
+
lcd.backlight(100)
|
|
250
|
+
time.sleep(opts['displayHold'])
|
|
251
|
+
assert dimmed == 40 and lcd.backlight() == 100, 'backlight did not follow requested levels'
|
|
252
|
+
return dict(width=lcd.width(), height=lcd.height(), dimmed=dimmed, restored=lcd.backlight())
|
|
253
|
+
finally:
|
|
254
|
+
if not opts.get('screenCapture'):
|
|
255
|
+
close_display()
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def display_stream(opts):
|
|
259
|
+
import sensor
|
|
260
|
+
sensor.reset()
|
|
261
|
+
sensor.set_pixformat(getattr(sensor, opts['pixformat']))
|
|
262
|
+
sensor.set_framesize(getattr(sensor, opts['framesize']))
|
|
263
|
+
sensor.skip_frames(n=3)
|
|
264
|
+
import display, time
|
|
265
|
+
lcd = display.Display(backlight=100)
|
|
266
|
+
try:
|
|
267
|
+
img = sensor.snapshot()
|
|
268
|
+
lcd.write(img)
|
|
269
|
+
start = time.ticks_ms()
|
|
270
|
+
for _ in range(max(10, opts['frames'] // 2)):
|
|
271
|
+
lcd.write(sensor.snapshot())
|
|
272
|
+
elapsed = time.ticks_diff(time.ticks_ms(), start)
|
|
273
|
+
lcd.clear()
|
|
274
|
+
assert elapsed > 0, 'implausible display stream time'
|
|
275
|
+
return dict(width=lcd.width(), height=lcd.height(), cameraWidth=img.width(), cameraHeight=img.height(), elapsedMs=elapsed, frames=max(10, opts['frames'] // 2))
|
|
276
|
+
finally:
|
|
277
|
+
lcd.deinit()
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def memory(opts):
|
|
282
|
+
import esp32
|
|
283
|
+
heaps = esp32.idf_heap_info(esp32.HEAP_DATA)
|
|
284
|
+
psram = esp32.idf_heap_info(1 << 10) # ESP-IDF MALLOC_CAP_SPIRAM.
|
|
285
|
+
free = sum(item[1] for item in heaps)
|
|
286
|
+
assert free > 0, 'no heap left'
|
|
287
|
+
return dict(heapFree=free, heapLargest=max([item[2] for item in heaps] or [0]),
|
|
288
|
+
psramFree=sum(item[1] for item in psram),
|
|
289
|
+
psramLargest=max([item[2] for item in psram] or [0]))
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def sensor_state(opts):
|
|
293
|
+
import sensor
|
|
294
|
+
width, height = sensor.width(), sensor.height()
|
|
295
|
+
assert width > 0 and height > 0, 'sensor not ready after capture'
|
|
296
|
+
return dict(width=width, height=height, id=sensor.get_id(),
|
|
297
|
+
pixformat=sensor.get_pixformat(), hmirror=sensor.get_hmirror(),
|
|
298
|
+
vflip=sensor.get_vflip())
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def run_check(name, opts):
|
|
302
|
+
import json
|
|
303
|
+
if isinstance(opts, str):
|
|
304
|
+
opts = json.loads(opts)
|
|
305
|
+
functions = dict(memory=memory, sensor_state=sensor_state, framesizes=framesizes,
|
|
306
|
+
pixformats=pixformats, orientation=orientation, ipa_stats=ipa_stats,
|
|
307
|
+
snapshot_rate=snapshot_rate, preview=preview, image_ops=image_ops,
|
|
308
|
+
display=display, display_stream=display_stream)
|
|
309
|
+
try:
|
|
310
|
+
data = functions[name](opts)
|
|
311
|
+
result = dict(name=name, status='PASS', data=data)
|
|
312
|
+
except Exception as exc:
|
|
313
|
+
message = str(exc)
|
|
314
|
+
unsupported = ('ESP_ERR_NOT_SUPPORTED', 'ESP_ERR_NOT_FOUND', 'no module named', 'ENODEV')
|
|
315
|
+
status = 'SKIP' if any(token.lower() in message.lower() for token in unsupported) else 'FAIL'
|
|
316
|
+
result = dict(name=name, status=status, error=dict(type=type(exc).__name__, message=message))
|
|
317
|
+
print('__EVDEVICECHECK__' + json.dumps(result))
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
import { randomBytes } from 'node:crypto'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
4
|
+
import { resolve } from 'node:path'
|
|
5
|
+
import { tcpTransport, serialTransport } from '../src/node_transport.js'
|
|
6
|
+
export { tcpTransport, serialTransport } from '../src/node_transport.js'
|
|
7
|
+
import { createDevice } from '../src/index.js'
|
|
8
|
+
import { DEVICE_CHECKS, runDeviceChecks, saveJpeg } from './device-checks.mjs'
|
|
9
|
+
|
|
10
|
+
export const usage = 'Usage: npm run test:hardware -- (--tcp HOST[:PORT] | --serial PATH) [--role usj|usb|uart] [--python PYTHON] [--write] [--capture] [--checks all|CSV] [--frames N] [--framesize QVGA] [--pixformat RGB565] [--quality 60] [--frame-timeout SECONDS] [--display-hold SECONDS] [--output CAMERA.jpg] [--screen-output LCD.jpg] [--timeout MILLISECONDS]'
|
|
11
|
+
const fail = (code, message) => Object.assign(new Error(message), { code })
|
|
12
|
+
|
|
13
|
+
export function parseArgs(args) {
|
|
14
|
+
const options = { timeout: 60000, write: false, capture: false, python: 'python3', frames: 30, framesize: 'QVGA', pixformat: 'RGB565', quality: 60, frameTimeout: 25, displayHold: 0 }
|
|
15
|
+
let explicitTimeout = false
|
|
16
|
+
for (let index = 0; index < args.length; index++) {
|
|
17
|
+
const option = args[index]
|
|
18
|
+
if (option === '--help') return { help: true }
|
|
19
|
+
if (option === '--write' || option === '--capture') { options[option.slice(2)] = true; continue }
|
|
20
|
+
if (!['--tcp', '--serial', '--role', '--python', '--timeout', '--checks', '--screen-output', '--output', '--frames', '--display-hold', '--framesize', '--pixformat', '--quality', '--frame-timeout'].includes(option)) throw fail('ARGUMENT', 'Unknown argument')
|
|
21
|
+
const value = args[++index]
|
|
22
|
+
if (!value || value.startsWith('--')) throw fail('ARGUMENT', 'Missing argument value')
|
|
23
|
+
const key = option.slice(2).replace(/-([a-z])/g, (_match, letter) => letter.toUpperCase())
|
|
24
|
+
options[key] = value
|
|
25
|
+
if (key === 'timeout') explicitTimeout = true
|
|
26
|
+
}
|
|
27
|
+
if (Boolean(options.tcp) === Boolean(options.serial)) throw fail('ARGUMENT', 'Select exactly one transport')
|
|
28
|
+
if (options.checks) {
|
|
29
|
+
options.checks = options.checks === 'all' ? [...DEVICE_CHECKS] : [...new Set(options.checks.split(',').map(name => name.trim()))]
|
|
30
|
+
if (options.checks.some(name => !DEVICE_CHECKS.includes(name))) throw fail('ARGUMENT', 'Unknown device check')
|
|
31
|
+
} else options.checks = []
|
|
32
|
+
if (options.screenOutput && !options.checks.includes('display')) options.checks.push('display')
|
|
33
|
+
if (options.checks.length && !explicitTimeout) options.timeout = 600000
|
|
34
|
+
for (const key of ['frames', 'quality', 'frameTimeout', 'displayHold']) options[key] = Number(options[key])
|
|
35
|
+
if (!Number.isInteger(options.frames) || options.frames < 1 || options.frames > 1000) throw fail('ARGUMENT', 'Frames must be 1 to 1000')
|
|
36
|
+
if (!Number.isInteger(options.quality) || options.quality < 1 || options.quality > 100) throw fail('ARGUMENT', 'Quality must be 1 to 100')
|
|
37
|
+
if (!Number.isFinite(options.frameTimeout) || options.frameTimeout <= 0 || options.frameTimeout > 120) throw fail('ARGUMENT', 'Frame timeout must be positive and at most 120 seconds')
|
|
38
|
+
if (!Number.isFinite(options.displayHold) || options.displayHold < 0 || options.displayHold > 60) throw fail('ARGUMENT', 'Display hold must be 0 to 60 seconds')
|
|
39
|
+
if (![options.framesize, options.pixformat].every(value => /^[A-Z][A-Z0-9_]{0,30}$/.test(value))) throw fail('ARGUMENT', 'Sensor settings must be uppercase constant names')
|
|
40
|
+
if (options.output && !options.checks.includes('camera')) options.capture = true
|
|
41
|
+
options.timeout = Number(options.timeout)
|
|
42
|
+
if (!Number.isSafeInteger(options.timeout) || options.timeout < 1000 || options.timeout > 600000) throw fail('ARGUMENT', 'Timeout must be 1000 to 600000 milliseconds')
|
|
43
|
+
if (options.tcp) {
|
|
44
|
+
const match = /^(?:\[([0-9a-fA-F:]+)\]|([^\s:/]+))(?::([0-9]+))?$/.exec(options.tcp)
|
|
45
|
+
if (!match || /[@?#]/.test(options.tcp)) throw fail('ARGUMENT', 'Expected TCP host[:port] without credentials or URL')
|
|
46
|
+
options.host = match[1] ?? match[2]
|
|
47
|
+
options.port = Number(match[3] ?? 3232)
|
|
48
|
+
if (options.port < 1 || options.port > 65535) throw fail('ARGUMENT', 'Invalid TCP port')
|
|
49
|
+
if (options.role) throw fail('ARGUMENT', '--role only applies to serial')
|
|
50
|
+
options.role = 'tcp'
|
|
51
|
+
} else {
|
|
52
|
+
options.role ??= 'usj'
|
|
53
|
+
if (!['usj', 'usb', 'uart'].includes(options.role)) throw fail('ARGUMENT', 'Serial role must be usj, usb or uart')
|
|
54
|
+
}
|
|
55
|
+
return options
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function bounded(operation, milliseconds) {
|
|
59
|
+
let timer
|
|
60
|
+
try { return await Promise.race([operation(), new Promise((_resolve, reject) => { timer = setTimeout(() => reject(fail('CLEANUP_TIMEOUT', 'Cleanup timed out')), milliseconds) })]) }
|
|
61
|
+
finally { clearTimeout(timer) }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Uses only the public facade; injectable for deterministic non-hardware checks. */
|
|
65
|
+
export async function checkHardware(options, { device = createDevice({ transport: options.tcp ? tcpTransport(options) : serialTransport(options) }), signal, report = value => console.log(JSON.stringify(value)), nonce = randomBytes(8).toString('hex') } = {}) {
|
|
66
|
+
const controller = new AbortController()
|
|
67
|
+
const abort = () => controller.abort(fail('CANCELLED', 'Hardware check cancelled'))
|
|
68
|
+
signal?.addEventListener('abort', abort, { once: true })
|
|
69
|
+
if (signal?.aborted) abort()
|
|
70
|
+
const timer = setTimeout(() => controller.abort(fail('TIMEOUT', 'Hardware check timed out')), options.timeout)
|
|
71
|
+
let error, ownedPath = null, writeStarted = false
|
|
72
|
+
const path = `/evhc_${nonce}.bin`
|
|
73
|
+
const cancelled = () => { if (controller.signal.aborted) throw controller.signal.reason }
|
|
74
|
+
try {
|
|
75
|
+
await device.connect({ role: options.role, timeout: Math.min(options.timeout, 30000), signal: controller.signal })
|
|
76
|
+
cancelled()
|
|
77
|
+
const status = await device.inspect({ fields: ['identity', 'capabilities', 'runtime'], signal: controller.signal })
|
|
78
|
+
// Only selected public identity fields are logged; never network credentials or Python output.
|
|
79
|
+
report({ check: 'inspect', ok: true, identity: { deviceId: status.identity?.deviceId ?? null, board: status.identity?.board ?? null, hostname: status.identity?.hostname ?? null } })
|
|
80
|
+
const marker = `EV_HARDWARE_${nonce}`
|
|
81
|
+
const execution = await device.python.execute({ source: `print('${marker}')`, timeout: 10000, maxOutputChars: 1024, signal: controller.signal })
|
|
82
|
+
if (!execution.ok || !execution.output.split(/\r?\n/).some(line => line.trim() === marker)) throw fail('PYTHON_CHECK', 'Python marker was not received')
|
|
83
|
+
report({ check: 'python', ok: true })
|
|
84
|
+
if (options.write) {
|
|
85
|
+
const entries = await device.files.list({ path: '/', signal: controller.signal })
|
|
86
|
+
if (entries.some(entry => entry.path === path || entry.path === `${path}.tmp`)) throw fail('TEST_PATH_EXISTS', 'Test path already exists; refusing to modify it')
|
|
87
|
+
ownedPath = path
|
|
88
|
+
const bytes = Uint8Array.from({ length: 1024 }, (_value, index) => (index * 73 + 29) & 255)
|
|
89
|
+
writeStarted = true
|
|
90
|
+
await device.files.write({ path, data: bytes, overwrite: false, signal: controller.signal })
|
|
91
|
+
const read = await device.files.read({ path, maxBytes: bytes.length, timeout: 15000, signal: controller.signal })
|
|
92
|
+
if (read.length !== bytes.length || !bytes.every((byte, index) => byte === read[index])) throw fail('READBACK_MISMATCH', 'File readback mismatch')
|
|
93
|
+
report({ check: 'write-read', ok: true, bytes: bytes.length })
|
|
94
|
+
}
|
|
95
|
+
if (options.capture && !options.checks.includes('camera')) {
|
|
96
|
+
const image = await device.capture({ source: 'camera', format: 'jpeg', quality: options.quality, timeout: options.frameTimeout * 1000, signal: controller.signal })
|
|
97
|
+
const captured = await saveJpeg(image, options.output)
|
|
98
|
+
report({ check: 'capture', ok: true, ...captured })
|
|
99
|
+
}
|
|
100
|
+
if (options.checks.length) await runDeviceChecks(device, options, { signal: controller.signal, nonce, report })
|
|
101
|
+
cancelled()
|
|
102
|
+
} catch (caught) { error = caught }
|
|
103
|
+
finally {
|
|
104
|
+
clearTimeout(timer)
|
|
105
|
+
signal?.removeEventListener('abort', abort)
|
|
106
|
+
if (ownedPath && writeStarted) {
|
|
107
|
+
try {
|
|
108
|
+
await bounded(async () => {
|
|
109
|
+
const cleanupSignal = AbortSignal.timeout(5000)
|
|
110
|
+
const entries = await device.files.list({ path: '/', signal: cleanupSignal })
|
|
111
|
+
for (const entry of entries) if ([ownedPath, `${ownedPath}.tmp`].includes(entry.path)) await device.files.remove({ path: entry.path, signal: cleanupSignal })
|
|
112
|
+
}, 6000)
|
|
113
|
+
report({ check: 'file-cleanup', ok: true })
|
|
114
|
+
} catch (caught) { report({ check: 'file-cleanup', ok: false, path: ownedPath }); error ??= caught }
|
|
115
|
+
}
|
|
116
|
+
try { await bounded(() => device.disconnect(), 10000) }
|
|
117
|
+
catch (caught) { error ??= caught }
|
|
118
|
+
}
|
|
119
|
+
if (error) throw error
|
|
120
|
+
return { ok: true }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
124
|
+
const controller = new AbortController()
|
|
125
|
+
const cancel = () => controller.abort()
|
|
126
|
+
process.once('SIGINT', cancel)
|
|
127
|
+
process.once('SIGTERM', cancel)
|
|
128
|
+
try {
|
|
129
|
+
const options = parseArgs(process.argv.slice(2))
|
|
130
|
+
if (options.help) console.log(usage)
|
|
131
|
+
else await checkHardware(options, { signal: controller.signal })
|
|
132
|
+
} catch (error) {
|
|
133
|
+
const code = typeof error?.code === 'string' && /^[A-Z0-9_]+$/.test(error.code) ? error.code : 'HARDWARE_CHECK_FAILED'
|
|
134
|
+
console.error(JSON.stringify({ ok: false, code }))
|
|
135
|
+
process.exitCode = 1
|
|
136
|
+
} finally {
|
|
137
|
+
process.removeListener('SIGINT', cancel)
|
|
138
|
+
process.removeListener('SIGTERM', cancel)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
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
|
+
})
|
|
@@ -0,0 +1,50 @@
|
|
|
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
|
+
})
|
|
@@ -0,0 +1,98 @@
|
|
|
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
|
+
})
|