@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.
- package/CHANGELOG.md +24 -1
- package/README.md +23 -13
- package/package.json +1 -5
- package/schemas/device-actions-v1.json +1 -1
- package/src/browser_rom_compat.js +8 -0
- package/src/device.js +17 -6
- package/src/device_files.js +5 -4
- package/src/device_python.js +4 -3
- package/src/device_session.js +127 -43
- package/src/evrepl.js +196 -22
- package/src/firmware_flash.js +4 -1
- package/src/node.js +10 -0
- package/src/node_inventory.js +7 -4
- package/src/node_serial.py +9 -1
- package/src/node_transport.js +36 -7
- package/tests/check-browser.mjs +25 -1
- package/tests/check-package.mjs +6 -9
- package/tests/device-checks.mjs +2 -2
- package/tests/fixtures/device-checks.py +205 -1
- package/tests/test-device-api.mjs +96 -6
- package/tests/test-device-execution.mjs +205 -13
- package/tests/test-device-files-api.mjs +85 -1
- package/tests/test-device-firmware.mjs +4 -1
- package/tests/test-device-functional-checks.mjs +9 -0
- package/tests/test-device-hardware-cli.mjs +8 -0
- package/tests/test-device-python-api.mjs +29 -2
- package/tests/test-firmware-install.mjs +0 -17
- package/tests/test-node-adapter-assets.mjs +16 -0
- package/tests/test-node-inventory.mjs +136 -0
- package/tests/test-node-serial.mjs +104 -0
- package/src/cli.js +0 -185
- package/tests/test-cli-output.mjs +0 -31
- package/tests/test-cli-ports.mjs +0 -50
- package/tests/test-cli.mjs +0 -98
|
@@ -286,6 +286,205 @@ def display_stream(opts):
|
|
|
286
286
|
lcd.deinit()
|
|
287
287
|
|
|
288
288
|
|
|
289
|
+
def camera_display_lifecycle(opts):
|
|
290
|
+
"""Exercise camera/LCD coexistence, camera shutdown recovery, and restart."""
|
|
291
|
+
import gc, esp32, sensor, display, image
|
|
292
|
+
|
|
293
|
+
def dma_heaps():
|
|
294
|
+
return esp32.idf_heap_info(1 << 3) # ESP-IDF MALLOC_CAP_DMA.
|
|
295
|
+
|
|
296
|
+
lcd = None
|
|
297
|
+
frame = None
|
|
298
|
+
canvas = None
|
|
299
|
+
try:
|
|
300
|
+
sensor.shutdown()
|
|
301
|
+
gc.collect()
|
|
302
|
+
lcd = display.Display(backlight=100)
|
|
303
|
+
panel_width, panel_height = lcd.width(), lcd.height()
|
|
304
|
+
lcd.deinit()
|
|
305
|
+
lcd = None
|
|
306
|
+
gc.collect()
|
|
307
|
+
|
|
308
|
+
sensor.reset()
|
|
309
|
+
sensor.set_pixformat(sensor.RGB565)
|
|
310
|
+
sensor.set_framesize(sensor.QVGA)
|
|
311
|
+
sensor.skip_frames(n=3)
|
|
312
|
+
frame = sensor.snapshot()
|
|
313
|
+
dma_before = dma_heaps()
|
|
314
|
+
lcd = display.Display(backlight=100)
|
|
315
|
+
lcd.write(frame, fit=True)
|
|
316
|
+
dma_active = dma_heaps()
|
|
317
|
+
lcd.deinit()
|
|
318
|
+
lcd = None
|
|
319
|
+
frame = None
|
|
320
|
+
gc.collect()
|
|
321
|
+
|
|
322
|
+
sensor.shutdown()
|
|
323
|
+
gc.collect()
|
|
324
|
+
dma_shutdown = dma_heaps()
|
|
325
|
+
lcd = display.Display(backlight=100)
|
|
326
|
+
canvas = image.Image(32, 32, image.RGB565)
|
|
327
|
+
lcd.write(canvas, fit=True)
|
|
328
|
+
lcd.deinit()
|
|
329
|
+
lcd = None
|
|
330
|
+
canvas = None
|
|
331
|
+
gc.collect()
|
|
332
|
+
|
|
333
|
+
sensor.reset()
|
|
334
|
+
sensor.set_pixformat(sensor.RGB565)
|
|
335
|
+
sensor.set_framesize(sensor.QVGA)
|
|
336
|
+
sensor.skip_frames(n=3)
|
|
337
|
+
frame = sensor.snapshot()
|
|
338
|
+
restarted = (frame.width(), frame.height())
|
|
339
|
+
dma_restarted = dma_heaps()
|
|
340
|
+
return dict(width=panel_width, height=panel_height,
|
|
341
|
+
cameraWidth=restarted[0], cameraHeight=restarted[1],
|
|
342
|
+
dmaBefore=dma_before, dmaActive=dma_active,
|
|
343
|
+
dmaAfterShutdown=dma_shutdown, dmaAfterRestart=dma_restarted)
|
|
344
|
+
finally:
|
|
345
|
+
if lcd is not None:
|
|
346
|
+
lcd.deinit()
|
|
347
|
+
frame = None
|
|
348
|
+
canvas = None
|
|
349
|
+
sensor.shutdown()
|
|
350
|
+
gc.collect()
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def image_copy_to_fb(opts):
|
|
354
|
+
"""The unsupported copy_to_fb keyword must be rejected consistently."""
|
|
355
|
+
import gc, image
|
|
356
|
+
|
|
357
|
+
canvas = None
|
|
358
|
+
try:
|
|
359
|
+
try:
|
|
360
|
+
image.Image(32, 32, image.RGB565, copy_to_fb=True)
|
|
361
|
+
except TypeError:
|
|
362
|
+
pass
|
|
363
|
+
else:
|
|
364
|
+
raise AssertionError('Image accepted unsupported copy_to_fb')
|
|
365
|
+
|
|
366
|
+
canvas = image.Image(32, 32, image.RGB565)
|
|
367
|
+
try:
|
|
368
|
+
canvas.to_rgb565(copy_to_fb=True)
|
|
369
|
+
except TypeError:
|
|
370
|
+
pass
|
|
371
|
+
else:
|
|
372
|
+
raise AssertionError('to_rgb565 accepted unsupported copy_to_fb')
|
|
373
|
+
canvas.draw_rectangle(0, 0, 32, 32, color=(255, 0, 0), fill=True)
|
|
374
|
+
assert canvas.size() == 32 * 32 * 2, 'unexpected RGB565 buffer size'
|
|
375
|
+
return dict(width=canvas.width(), height=canvas.height(), size=canvas.size())
|
|
376
|
+
finally:
|
|
377
|
+
canvas = None
|
|
378
|
+
gc.collect()
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def image_transform_argument(opts):
|
|
382
|
+
"""Transform is not part of the ESP-VISION Image API."""
|
|
383
|
+
import image
|
|
384
|
+
|
|
385
|
+
canvas = image.Image(32, 32, image.RGB565)
|
|
386
|
+
source = image.Image(8, 8, image.RGB565)
|
|
387
|
+
matrix = ((1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, 0.0, 1.0))
|
|
388
|
+
for operation in (
|
|
389
|
+
lambda: canvas.copy(transform=matrix),
|
|
390
|
+
lambda: canvas.draw_image(source, transform=matrix),
|
|
391
|
+
):
|
|
392
|
+
try:
|
|
393
|
+
operation()
|
|
394
|
+
except TypeError as exc:
|
|
395
|
+
assert 'keyword' in str(exc), 'transform is accepted but unsupported'
|
|
396
|
+
else:
|
|
397
|
+
raise AssertionError('Image accepted unsupported transform keyword')
|
|
398
|
+
return dict(operations=2)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def sensor_snapshot_buffer(opts):
|
|
402
|
+
"""Validate the caller-owned writable buffer contract for sensor.snapshot."""
|
|
403
|
+
import gc, sensor
|
|
404
|
+
|
|
405
|
+
buffer = None
|
|
406
|
+
frame = None
|
|
407
|
+
try:
|
|
408
|
+
sensor.shutdown()
|
|
409
|
+
sensor.reset()
|
|
410
|
+
sensor.set_pixformat(sensor.RGB565)
|
|
411
|
+
sensor.set_framesize(sensor.QVGA)
|
|
412
|
+
sensor.skip_frames(n=3)
|
|
413
|
+
try:
|
|
414
|
+
sensor.snapshot(buffer=bytearray(16))
|
|
415
|
+
except TypeError:
|
|
416
|
+
pass
|
|
417
|
+
else:
|
|
418
|
+
raise AssertionError('sensor.snapshot unexpectedly accepted buffer keyword')
|
|
419
|
+
try:
|
|
420
|
+
sensor.snapshot(bytearray(16))
|
|
421
|
+
except ValueError as exc:
|
|
422
|
+
assert 'snapshot buffer too small' in str(exc), 'unexpected undersized buffer error: ' + str(exc)
|
|
423
|
+
else:
|
|
424
|
+
raise AssertionError('sensor.snapshot accepted an undersized buffer')
|
|
425
|
+
frame_bytes = 320 * 240 * 2
|
|
426
|
+
buffer = bytearray(frame_bytes)
|
|
427
|
+
frame = sensor.snapshot(buffer)
|
|
428
|
+
assert (frame.width(), frame.height(), frame.size()) == (320, 240, frame_bytes), 'unexpected caller-buffer frame'
|
|
429
|
+
buffer = None
|
|
430
|
+
gc.collect()
|
|
431
|
+
assert (frame.width(), frame.height()) == (320, 240), 'Image did not retain caller buffer after GC'
|
|
432
|
+
return dict(width=frame.width(), height=frame.height(), size=frame.size())
|
|
433
|
+
finally:
|
|
434
|
+
frame = None
|
|
435
|
+
buffer = None
|
|
436
|
+
sensor.shutdown()
|
|
437
|
+
gc.collect()
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def memory_policy(opts):
|
|
441
|
+
"""Large Image storage follows MicroPython GC; camera snapshots use PSRAM."""
|
|
442
|
+
import gc, esp32, image, sensor
|
|
443
|
+
|
|
444
|
+
def free_bytes(caps):
|
|
445
|
+
return sum(heap[1] for heap in esp32.idf_heap_info(caps))
|
|
446
|
+
|
|
447
|
+
def measure_image(width, height):
|
|
448
|
+
gc.collect()
|
|
449
|
+
before = gc.mem_alloc()
|
|
450
|
+
canvas = image.Image(width, height, image.RGB565)
|
|
451
|
+
gc.collect()
|
|
452
|
+
allocated = gc.mem_alloc() - before
|
|
453
|
+
canvas = None
|
|
454
|
+
gc.collect()
|
|
455
|
+
return width * height * 2, allocated
|
|
456
|
+
|
|
457
|
+
frame = None
|
|
458
|
+
try:
|
|
459
|
+
small_bytes, small_allocated = measure_image(32, 32)
|
|
460
|
+
assert small_bytes - 512 <= small_allocated <= small_bytes + 8192, 'small Image allocation did not use GC heap'
|
|
461
|
+
large_bytes, large_allocated = measure_image(640, 480)
|
|
462
|
+
assert large_bytes - 4096 <= large_allocated <= large_bytes + 16384, 'large Image allocation did not use GC heap'
|
|
463
|
+
|
|
464
|
+
sensor.shutdown()
|
|
465
|
+
gc.collect()
|
|
466
|
+
sensor.reset()
|
|
467
|
+
sensor.set_pixformat(sensor.RGB565)
|
|
468
|
+
sensor.set_framesize(sensor.QVGA)
|
|
469
|
+
psram_before = free_bytes(1 << 10) # ESP-IDF MALLOC_CAP_SPIRAM.
|
|
470
|
+
if psram_before <= 0:
|
|
471
|
+
raise RuntimeError('ESP_ERR_NOT_SUPPORTED: PSRAM heap unavailable')
|
|
472
|
+
frame = sensor.snapshot()
|
|
473
|
+
gc.collect()
|
|
474
|
+
psram_after = free_bytes(1 << 10)
|
|
475
|
+
frame_bytes = frame.width() * frame.height() * 2
|
|
476
|
+
psram_used = psram_before - psram_after
|
|
477
|
+
assert psram_used >= frame_bytes - 4096, 'camera snapshot buffer was not allocated from PSRAM'
|
|
478
|
+
return dict(smallBytes=small_bytes, smallAllocated=small_allocated,
|
|
479
|
+
largeBytes=large_bytes, largeAllocated=large_allocated,
|
|
480
|
+
width=frame.width(), height=frame.height(), frameBytes=frame_bytes,
|
|
481
|
+
psramUsed=psram_used)
|
|
482
|
+
finally:
|
|
483
|
+
frame = None
|
|
484
|
+
sensor.shutdown()
|
|
485
|
+
gc.collect()
|
|
486
|
+
|
|
487
|
+
|
|
289
488
|
|
|
290
489
|
def memory(opts):
|
|
291
490
|
import esp32
|
|
@@ -314,7 +513,12 @@ def run_check(name, opts):
|
|
|
314
513
|
functions = dict(memory=memory, sensor_state=sensor_state, framesizes=framesizes,
|
|
315
514
|
pixformats=pixformats, orientation=orientation, ipa_stats=ipa_stats,
|
|
316
515
|
snapshot_rate=snapshot_rate, preview=preview, image_ops=image_ops,
|
|
317
|
-
display=display, display_stream=display_stream
|
|
516
|
+
display=display, display_stream=display_stream,
|
|
517
|
+
camera_display_lifecycle=camera_display_lifecycle,
|
|
518
|
+
image_copy_to_fb=image_copy_to_fb,
|
|
519
|
+
image_transform_argument=image_transform_argument,
|
|
520
|
+
sensor_snapshot_buffer=sensor_snapshot_buffer,
|
|
521
|
+
memory_policy=memory_policy)
|
|
318
522
|
try:
|
|
319
523
|
data = functions[name](opts)
|
|
320
524
|
result = dict(name=name, status='PASS', data=data)
|
|
@@ -11,18 +11,36 @@ const capabilities = { ok: true, protocol: { evMuxVersion: 4 },
|
|
|
11
11
|
features: [...EV_MUX_REQUIRED_FEATURES, 'debug.capture_frame', 'script.write', 'script.run', 'device.control'],
|
|
12
12
|
channels: EV_MUX_REQUIRED_CHANNELS, limits: { ...EV_MUX_DEFAULT_LIMITS, txLargePayloadMax: 262144 },
|
|
13
13
|
session: { policy: 'firstCome', leaseMs: 15000, keepaliveMs: 5000 } }
|
|
14
|
-
function peer({ silent = false, screen = false } = {}) {
|
|
14
|
+
function peer({ silent = false, screen = false, vmReady = true, interactiveScripts = false } = {}) {
|
|
15
15
|
let handlers, subscriptions = 0, disconnects = 0
|
|
16
16
|
const requests = [], order = [], id = 'a'.repeat(32)
|
|
17
|
+
const requestWaiters = []
|
|
17
18
|
const stdout = payload => handlers?.data(makeEvMuxFrame({ channel: 'repl.stdout', type: 'data', sessionId: id, vmEpoch: 1, payload }))
|
|
19
|
+
const recordRequest = request => {
|
|
20
|
+
requests.push(request)
|
|
21
|
+
for (let i = requestWaiters.length - 1; i >= 0; i -= 1) {
|
|
22
|
+
if (requestWaiters[i].predicate(request)) {
|
|
23
|
+
requestWaiters.splice(i, 1)[0].resolve(request)
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const waitForRequest = predicate => {
|
|
28
|
+
const existing = requests.find(predicate)
|
|
29
|
+
return existing ? Promise.resolve(existing) : new Promise(resolve => requestWaiters.push({ predicate, resolve }))
|
|
30
|
+
}
|
|
18
31
|
const parser = new EvMuxParser({ filterFrame(metadata, bytes) {
|
|
19
32
|
if (silent) return false
|
|
20
33
|
const payload = metadata.channel.endsWith('.rpc') ? JSON.parse(new TextDecoder().decode(bytes)) : new TextDecoder().decode(bytes)
|
|
21
|
-
|
|
22
|
-
if (metadata.channel === 'repl.stdin') {
|
|
34
|
+
recordRequest({ metadata, payload })
|
|
35
|
+
if (metadata.channel === 'repl.stdin') {
|
|
36
|
+
const startMarker = interactiveScripts && /~~EVSCRIPT-START:[A-Za-z0-9-]+~~/.exec(payload)?.[0]
|
|
37
|
+
if (startMarker) stdout(`${payload}\r\n${startMarker}\r\n`)
|
|
38
|
+
else stdout(`${payload}\r\n${payload.includes('EVIDE_BOOT_OK') ? 'EVIDE_BOOT_OK' : 'result'}\r\n>>> `)
|
|
39
|
+
return false
|
|
40
|
+
}
|
|
23
41
|
if (!metadata.channel.endsWith('.rpc')) return false
|
|
24
42
|
let result = { ok: true }
|
|
25
|
-
if (metadata.method === 'hello') result = { ok: true, evMuxVersion: 4, deviceId: 'e8f60ae75642', hostname: 'esp-vision-test', board: 'TEST', firmware: { version: 'test' }, vmReady
|
|
43
|
+
if (metadata.method === 'hello') result = { ok: true, evMuxVersion: 4, deviceId: 'e8f60ae75642', hostname: 'esp-vision-test', board: 'TEST', firmware: { version: 'test' }, vmReady, session: { id, vmEpoch: 1, leaseMs: 15000, keepaliveMs: 5000 } }
|
|
26
44
|
if (metadata.method === 'capabilities') result = { ...capabilities, features: [...capabilities.features, ...(screen ? ['debug.capture_screen'] : [])] }
|
|
27
45
|
if (metadata.method === 'session.keepalive') result = { ok: true, sessionId: id, vmEpoch: 1 }
|
|
28
46
|
if (metadata.method === 'debug.capture_frame' || (screen && metadata.method === 'debug.capture_screen')) {
|
|
@@ -37,7 +55,7 @@ function peer({ silent = false, screen = false } = {}) {
|
|
|
37
55
|
async disconnect() { disconnects++; order.push('disconnect') },
|
|
38
56
|
async writeBytes(bytes) { parser.feedBytes(bytes) },
|
|
39
57
|
}
|
|
40
|
-
return { transport, requests, order, stdout, close: () => handlers.close(), get subscriptions() { return subscriptions }, get disconnects() { return disconnects } }
|
|
58
|
+
return { transport, requests, order, stdout, waitForRequest, close: () => handlers.close(), get subscriptions() { return subscriptions }, get disconnects() { return disconnects } }
|
|
41
59
|
}
|
|
42
60
|
|
|
43
61
|
test('root exports only the application API; protocols remain explicit adapters', () => {
|
|
@@ -57,6 +75,8 @@ test('facade subscribes before connection, negotiates, streams input and dispose
|
|
|
57
75
|
assert.equal(ready.identity.deviceId, 'e8f60ae75642')
|
|
58
76
|
assert.deepEqual(p.order.slice(0, 2), ['subscribe','connect'])
|
|
59
77
|
assert.equal(p.subscriptions, 1)
|
|
78
|
+
assert.ok(p.requests.some(x => x.metadata.channel === 'repl.signal'), 'default connection retains the REPL readiness probe')
|
|
79
|
+
assert.ok(p.requests.some(x => x.metadata.channel === 'repl.stdin' && x.payload.includes('EVIDE_BOOT_OK')))
|
|
60
80
|
await d.python.input('print(2)\r')
|
|
61
81
|
assert.ok(output.some(text => text.includes('result')))
|
|
62
82
|
unsubscribe(); const count = output.length; p.stdout('ignored\r\n'); assert.equal(output.length,count)
|
|
@@ -66,6 +86,28 @@ test('facade subscribes before connection, negotiates, streams input and dispose
|
|
|
66
86
|
await assert.rejects(d.files.read({path:'/x'}), {code:'DEVICE_NOT_READY'})
|
|
67
87
|
})
|
|
68
88
|
|
|
89
|
+
test('connect can acquire and inspect a session without interrupting the REPL', async () => {
|
|
90
|
+
const p = peer({ vmReady: false }), d = api.createDevice({ transport: p.transport })
|
|
91
|
+
try {
|
|
92
|
+
const ready = await d.connect({ role: 'tcp', probeRepl: false })
|
|
93
|
+
assert.equal(ready.runtime.vmReady, false, 'passive negotiation preserves the device readiness report')
|
|
94
|
+
assert.ok(p.requests.some(x => x.metadata.method === 'hello' && x.payload.acquire === true))
|
|
95
|
+
assert.ok(p.requests.some(x => x.metadata.method === 'capabilities'))
|
|
96
|
+
assert.equal(p.requests.some(x => x.metadata.channel === 'repl.signal' || x.metadata.channel === 'repl.stdin'), false)
|
|
97
|
+
await assert.rejects(d.python.execute({ source: 'print(1)' }), /requires a ready VM/)
|
|
98
|
+
assert.equal(p.requests.some(x => x.metadata.channel === 'repl.signal' || x.metadata.channel === 'repl.stdin'), false)
|
|
99
|
+
} finally { await d.disconnect() }
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test('connect validates probeRepl before opening the transport', async () => {
|
|
103
|
+
const p = peer(), d = api.createDevice({ transport: p.transport })
|
|
104
|
+
for (const probeRepl of [null, 'false', 0]) {
|
|
105
|
+
await assert.rejects(d.connect({ role: 'tcp', probeRepl }), { name: 'TypeError' })
|
|
106
|
+
}
|
|
107
|
+
assert.deepEqual(p.order, [])
|
|
108
|
+
assert.deepEqual(p.requests, [])
|
|
109
|
+
})
|
|
110
|
+
|
|
69
111
|
test('capture returns binary bytes and unsupported LCD capture never captures the camera', async () => {
|
|
70
112
|
const p = peer(), d = api.createDevice({transport:p.transport})
|
|
71
113
|
try {
|
|
@@ -102,6 +144,53 @@ test('transport loss clears the connection and permits reconnection',async()=>{
|
|
|
102
144
|
await d.connect({role:'tcp'});await d.disconnect();assert.equal(p.disconnects,2)
|
|
103
145
|
})
|
|
104
146
|
|
|
147
|
+
test('disconnect releases the session without interrupting a publicly started run', async () => {
|
|
148
|
+
const p = peer({ interactiveScripts: true }), d = api.createDevice({ transport: p.transport })
|
|
149
|
+
try {
|
|
150
|
+
await d.connect({ role: 'tcp', probeRepl: false })
|
|
151
|
+
const run = await d.python.execute({ path: '/main.py', completion: 'start' })
|
|
152
|
+
const doneRejected = assert.rejects(run.done)
|
|
153
|
+
const beforeDisconnect = p.requests.length
|
|
154
|
+
await d.disconnect()
|
|
155
|
+
await doneRejected
|
|
156
|
+
const released = p.requests.slice(beforeDisconnect)
|
|
157
|
+
assert.ok(released.some(request => request.metadata.method === 'session.release'))
|
|
158
|
+
assert.equal(released.some(request => request.metadata.channel === 'repl.signal'
|
|
159
|
+
|| (request.metadata.method === 'device.control' && request.payload.action === 'stop')), false)
|
|
160
|
+
} finally { await d.disconnect() }
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
test('caller AbortSignal still interrupts a publicly started run', async () => {
|
|
164
|
+
const p = peer({ interactiveScripts: true }), d = api.createDevice({ transport: p.transport })
|
|
165
|
+
const controller = new AbortController()
|
|
166
|
+
try {
|
|
167
|
+
await d.connect({ role: 'tcp', probeRepl: false })
|
|
168
|
+
const run = await d.python.execute({ path: '/main.py', completion: 'start', signal: controller.signal })
|
|
169
|
+
const doneRejected = assert.rejects(run.done, { name: 'AbortError' })
|
|
170
|
+
const beforeAbort = p.requests.length
|
|
171
|
+
controller.abort()
|
|
172
|
+
await doneRejected
|
|
173
|
+
assert.ok(p.requests.slice(beforeAbort).some(request => request.metadata.method === 'device.control'
|
|
174
|
+
&& request.payload.action === 'stop'))
|
|
175
|
+
} finally { await d.disconnect() }
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
test('disconnect still interrupts Python execution in completion-wait mode', async () => {
|
|
179
|
+
const p = peer(), d = api.createDevice({ transport: p.transport })
|
|
180
|
+
try {
|
|
181
|
+
await d.connect({ role: 'tcp', probeRepl: false })
|
|
182
|
+
const execution = d.python.execute({ path: '/main.py' })
|
|
183
|
+
await p.waitForRequest(request => request.metadata.channel === 'repl.stdin'
|
|
184
|
+
&& String(request.payload).includes('~~EVSCRIPT-END:'))
|
|
185
|
+
const failed = assert.rejects(execution)
|
|
186
|
+
const beforeDisconnect = p.requests.length
|
|
187
|
+
await d.disconnect()
|
|
188
|
+
await failed
|
|
189
|
+
assert.ok(p.requests.slice(beforeDisconnect).some(request => request.metadata.method === 'device.control'
|
|
190
|
+
&& request.payload.action === 'stop'))
|
|
191
|
+
} finally { await d.disconnect() }
|
|
192
|
+
})
|
|
193
|
+
|
|
105
194
|
test('discovery works before any transport is connected',async()=>{
|
|
106
195
|
const d=api.createDevice({discovery:{async list(){return [{deviceId:'e8f60ae75642',hostname:'esp-vision-test',board:'TEST',firmwareVersion:'1',evMuxVersion:4,port:3232,addresses:['192.168.1.2']}]}}})
|
|
107
196
|
assert.equal((await d.discover({timeout:50}))[0].compatible,true)
|
|
@@ -126,8 +215,9 @@ test('long Unicode paths stay within REPL budget for directory reads and inline
|
|
|
126
215
|
repl.execPy=async source=>{commands.push(source);assert.ok(repl.execInputBytes(source)<=511);return source.includes('__EVLIST__')?'__EVLIST__[]\n':''}
|
|
127
216
|
assert.deepEqual(await repl.listFs(path),[])
|
|
128
217
|
assert.equal(commands.length,2,'Long escaped paths are assigned separately from directory code')
|
|
129
|
-
const command=repl.createScriptRunCommand(path)
|
|
218
|
+
const command=repl.createScriptRunCommand(path,{acknowledgeStart:true})
|
|
130
219
|
assert.ok(new TextEncoder().encode(command).length<=511)
|
|
220
|
+
assert.match(command,/EVSCRIPT-START:/)
|
|
131
221
|
if(repl.scriptRunPrelude)assert.ok(repl.execInputBytes(repl.scriptRunPrelude)<=511)
|
|
132
222
|
repl.cancelScriptEndSentinel()
|
|
133
223
|
})
|
|
@@ -1,29 +1,71 @@
|
|
|
1
1
|
// SPDX-License-Identifier: Apache-2.0
|
|
2
2
|
import assert from 'node:assert/strict'
|
|
3
3
|
import test from 'node:test'
|
|
4
|
+
import { createPythonApi } from '../src/device_python.js'
|
|
4
5
|
import { DeviceSession } from '../src/device_session.js'
|
|
5
6
|
import { EvMuxParser, makeEvMuxFrame } from '../src/evmux.js'
|
|
6
7
|
import { EvRepl } from '../src/evrepl.js'
|
|
7
8
|
|
|
8
|
-
function executionFixture({ short = false, failWrite = false, failStop = false, stalledReset = false } = {}) {
|
|
9
|
+
function executionFixture({ short = false, failWrite = false, failStop = false, stalledReset = false, deferExec = false, deferRun = false, deferStart = false, features = ['file.write.binary', 'script.run', 'device.control'] } = {}) {
|
|
9
10
|
const requests = []
|
|
10
11
|
let running = false
|
|
11
12
|
let sentinel = null
|
|
13
|
+
let startMarker = null
|
|
14
|
+
let startMarkerEmitted = false
|
|
15
|
+
let pendingExec = null
|
|
12
16
|
let terminal = ''
|
|
17
|
+
let startAndCompletionInSameFrame = false
|
|
13
18
|
let session
|
|
14
19
|
let resetRequested = false
|
|
15
20
|
let resetPolls = 0
|
|
16
21
|
let uploaded = 0
|
|
17
|
-
const stdout = (payload) =>
|
|
18
|
-
|
|
19
|
-
|
|
22
|
+
const stdout = (payload) => {
|
|
23
|
+
if (startMarker && sentinel?.marker && String(payload).includes(startMarker) && String(payload).includes(sentinel.marker)) {
|
|
24
|
+
startAndCompletionInSameFrame = true
|
|
25
|
+
}
|
|
26
|
+
session.feedBytes(makeEvMuxFrame({
|
|
27
|
+
channel: 'repl.stdout', type: 'data', sessionId: session.sessionId, vmEpoch: session.vmEpoch, payload,
|
|
28
|
+
}))
|
|
29
|
+
}
|
|
30
|
+
const finishFast = () => {
|
|
31
|
+
if (startMarker && !startMarkerEmitted && sentinel) {
|
|
32
|
+
startMarkerEmitted = true
|
|
33
|
+
running = true
|
|
34
|
+
const completionMarker = sentinel.marker
|
|
35
|
+
stdout(`${startMarker}\r\n${completionMarker}\r\n>>> `)
|
|
36
|
+
running = false
|
|
37
|
+
sentinel = null
|
|
38
|
+
startMarker = null
|
|
39
|
+
startMarkerEmitted = false
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
finish()
|
|
43
|
+
}
|
|
44
|
+
const enter = () => {
|
|
45
|
+
if (!startMarker || startMarkerEmitted) return
|
|
46
|
+
startMarkerEmitted = true
|
|
47
|
+
running = true
|
|
48
|
+
const split = Math.floor(startMarker.length / 2)
|
|
49
|
+
stdout(startMarker.slice(0, split))
|
|
50
|
+
stdout(`${startMarker.slice(split)}\r`)
|
|
51
|
+
stdout('\n')
|
|
52
|
+
}
|
|
20
53
|
const finish = () => {
|
|
54
|
+
if (startMarker && !startMarkerEmitted) enter()
|
|
21
55
|
running = false
|
|
22
56
|
if (sentinel) {
|
|
23
|
-
stdout(`${sentinel.line}${sentinel.marker}\r\n>>> `)
|
|
57
|
+
stdout(`${sentinel.startMarker ? '' : sentinel.line}${sentinel.marker}\r\n>>> `)
|
|
24
58
|
sentinel = null
|
|
59
|
+
startMarker = null
|
|
60
|
+
startMarkerEmitted = false
|
|
25
61
|
}
|
|
26
62
|
}
|
|
63
|
+
const finishExec = () => {
|
|
64
|
+
if (!pendingExec) return
|
|
65
|
+
const command = pendingExec
|
|
66
|
+
pendingExec = null
|
|
67
|
+
stdout(`${command}>>> `)
|
|
68
|
+
}
|
|
27
69
|
const parser = new EvMuxParser({
|
|
28
70
|
filterFrame: (metadata, bytes) => {
|
|
29
71
|
const text = new TextDecoder().decode(bytes)
|
|
@@ -35,10 +77,27 @@ function executionFixture({ short = false, failWrite = false, failStop = false,
|
|
|
35
77
|
resetRequested = true
|
|
36
78
|
return
|
|
37
79
|
}
|
|
38
|
-
const
|
|
39
|
-
if (
|
|
40
|
-
|
|
41
|
-
|
|
80
|
+
const marker = /~~EVSCRIPT-END:[A-Za-z0-9-]+~~/.exec(text)?.[0]
|
|
81
|
+
if (marker) {
|
|
82
|
+
startMarker = /~~EVSCRIPT-START:[A-Za-z0-9-]+~~/.exec(text)?.[0] ?? null
|
|
83
|
+
startMarkerEmitted = false
|
|
84
|
+
sentinel = { line: text.trimEnd(), marker, startMarker }
|
|
85
|
+
if (startMarker) {
|
|
86
|
+
const echoSplit = Math.floor(text.length / 2)
|
|
87
|
+
stdout(text.slice(0, echoSplit))
|
|
88
|
+
stdout(`${text.slice(echoSplit)}\r\n`)
|
|
89
|
+
}
|
|
90
|
+
if (deferRun) {
|
|
91
|
+
if (startMarker && !deferStart) enter()
|
|
92
|
+
running = true
|
|
93
|
+
stdout('')
|
|
94
|
+
} else if (short && startMarker && !deferStart) finishFast()
|
|
95
|
+
else {
|
|
96
|
+
if (startMarker && !deferStart) enter()
|
|
97
|
+
if (short) finish()
|
|
98
|
+
}
|
|
99
|
+
} else if (deferExec && text.startsWith('exec(')) {
|
|
100
|
+
pendingExec = text
|
|
42
101
|
} else {
|
|
43
102
|
stdout(`${text}\r\n>>> `)
|
|
44
103
|
}
|
|
@@ -85,10 +144,79 @@ function executionFixture({ short = false, failWrite = false, failStop = false,
|
|
|
85
144
|
session.vmEpoch = 1
|
|
86
145
|
session.vmReady = true
|
|
87
146
|
session.limits.fileWriteChunkMax = 16384
|
|
88
|
-
session.capabilities = { features
|
|
89
|
-
return { session, requests, finish, get terminal() { return terminal }, get running() { return running }
|
|
147
|
+
session.capabilities = { features }
|
|
148
|
+
return { session, requests, finish, enter, finishExec, get terminal() { return terminal }, get running() { return running },
|
|
149
|
+
get startAndCompletionInSameFrame() { return startAndCompletionInSameFrame } }
|
|
90
150
|
}
|
|
91
151
|
|
|
152
|
+
test('inline source assignment splits at the REPL limit without changing source text', () => {
|
|
153
|
+
const repl = new EvRepl({ writeEvmux: async () => {} })
|
|
154
|
+
const variableName = '__evcore_python_boundary'
|
|
155
|
+
const source = `${'x'.repeat(470)}😀\nprint("done")`
|
|
156
|
+
assert.ok(repl.execInputBytes(source) <= repl.inputMaxBytes)
|
|
157
|
+
const assignments = repl.createInlineSourceAssignments(source, variableName)
|
|
158
|
+
assert.ok(assignments.length > 1, 'the private assignment prefix requires safe chunks at this boundary')
|
|
159
|
+
assert.ok(assignments.every(statement => repl.execInputBytes(statement) <= repl.inputMaxBytes))
|
|
160
|
+
const rebuilt = assignments.map((statement, index) => JSON.parse(statement.slice(variableName.length + (index === 0 ? 1 : 2)))).join('')
|
|
161
|
+
assert.equal(rebuilt, source)
|
|
162
|
+
const command = repl.createInlineScriptRunCommand(variableName)
|
|
163
|
+
assert.ok(new TextEncoder().encode(command).length <= repl.inputMaxBytes)
|
|
164
|
+
assert.match(command, new RegExp(`exec\\(${variableName}\\)`))
|
|
165
|
+
assert.match(command, new RegExp(`del ${variableName}`))
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
test('python.execute small source waits past 30 seconds without requiring device storage', async (t) => {
|
|
169
|
+
t.mock.timers.enable({ apis: ['setTimeout'] })
|
|
170
|
+
const fixture = executionFixture({ deferRun: true, features: [] })
|
|
171
|
+
t.after(() => fixture.session.close())
|
|
172
|
+
let settled = false
|
|
173
|
+
const execution = fixture.session.executePython('while True: pass').then(output => {
|
|
174
|
+
settled = true
|
|
175
|
+
return output
|
|
176
|
+
})
|
|
177
|
+
await new Promise(resolve => setImmediate(resolve))
|
|
178
|
+
assert.equal(fixture.session.runCapture?.timer, null, 'omitted timeout leaves completion pending')
|
|
179
|
+
assert.equal(fixture.session.runCapture?.path, null, 'small source does not create a device file')
|
|
180
|
+
assert.ok(fixture.requests.some(({ metadata, payload }) => metadata.channel === 'repl.stdin' && String(payload).includes('__evcore_python_')))
|
|
181
|
+
assert.ok(!fixture.requests.some(({ metadata }) => ['file.write', 'script.run'].includes(metadata.method)))
|
|
182
|
+
t.mock.timers.tick(31000)
|
|
183
|
+
await Promise.resolve()
|
|
184
|
+
assert.equal(settled, false, 'silent Python still waits beyond the former fixed completion timeout')
|
|
185
|
+
fixture.finish()
|
|
186
|
+
assert.match(await execution, /^\s*$/)
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
test('Python deadlines include REPL setup and cancellation releases API ownership', async (t) => {
|
|
190
|
+
t.mock.timers.enable({ apis: ['setTimeout'] })
|
|
191
|
+
const timed = executionFixture({ deferExec: true })
|
|
192
|
+
t.after(() => timed.session.close())
|
|
193
|
+
const state = {}
|
|
194
|
+
const python = createPythonApi({ session: timed.session, state })
|
|
195
|
+
const execution = python.execute({ source: 'print(42)', timeout: 1000 })
|
|
196
|
+
const timedOut = assert.rejects(execution, { code: 'TIMEOUT' })
|
|
197
|
+
await new Promise(resolve => setImmediate(resolve))
|
|
198
|
+
t.mock.timers.tick(1000)
|
|
199
|
+
await timedOut
|
|
200
|
+
assert.equal(state.operation, null, 'deadline releases the operation lock even during setup')
|
|
201
|
+
|
|
202
|
+
const cancelled = executionFixture({ deferRun: true })
|
|
203
|
+
t.after(() => cancelled.session.close())
|
|
204
|
+
const cancelledState = {}
|
|
205
|
+
const cancelledPython = createPythonApi({ session: cancelled.session, state: cancelledState })
|
|
206
|
+
const controller = new AbortController()
|
|
207
|
+
const cancellation = cancelledPython.execute({ source: 'while True: pass', signal: controller.signal })
|
|
208
|
+
const aborted = assert.rejects(cancellation, { name: 'AbortError' })
|
|
209
|
+
await new Promise(resolve => setImmediate(resolve))
|
|
210
|
+
assert.ok(cancelledState.operation, 'the operation remains owned while Python is still running')
|
|
211
|
+
controller.abort()
|
|
212
|
+
await aborted
|
|
213
|
+
await new Promise(resolve => setImmediate(resolve))
|
|
214
|
+
assert.equal(cancelledState.operation, null, 'cancellation releases the operation lock')
|
|
215
|
+
assert.equal(cancelledState.run, null, 'cancellation does not leave a false active-run claim')
|
|
216
|
+
assert.ok(cancelled.requests.some(({ metadata, payload }) => metadata.method === 'device.control' && payload.action === 'stop'),
|
|
217
|
+
'cancellation requests a stop but the API still reports cancellation, not success')
|
|
218
|
+
})
|
|
219
|
+
|
|
92
220
|
test('interactive execution survives the bounded-run deadline, streams output, and completes on shared stop', async (t) => {
|
|
93
221
|
t.mock.timers.enable({ apis: ['setTimeout'] })
|
|
94
222
|
const fixture = executionFixture()
|
|
@@ -111,11 +239,75 @@ test('interactive execution survives the bounded-run deadline, streams output, a
|
|
|
111
239
|
assert.ok(!fixture.terminal.includes('~~EVSCRIPT-END'), 'shared completion markers never leak into terminal output')
|
|
112
240
|
})
|
|
113
241
|
|
|
114
|
-
test('
|
|
242
|
+
test('startScript waits for the VM-entry marker and ignores the echoed marker in its command', async () => {
|
|
243
|
+
const fixture = executionFixture({ deferStart: true })
|
|
244
|
+
let resolved = false
|
|
245
|
+
const starting = fixture.session.startScript('/main.py', undefined, { inlineCompletion: true }).then(run => {
|
|
246
|
+
resolved = true
|
|
247
|
+
return run
|
|
248
|
+
})
|
|
249
|
+
await new Promise(resolve => setImmediate(resolve))
|
|
250
|
+
assert.equal(resolved, false, 'a successful stdin write is not evidence that MicroPython entered the runner')
|
|
251
|
+
assert.equal(fixture.running, false)
|
|
252
|
+
assert.equal(fixture.session.runCapture?.timer, null, 'interactive start acknowledgement does not create an aggregate run deadline')
|
|
253
|
+
fixture.enter()
|
|
254
|
+
const run = await starting
|
|
255
|
+
assert.equal(fixture.running, true)
|
|
256
|
+
assert.doesNotMatch(fixture.terminal, /~~EVSCRIPT-START:/, 'the private entry marker is filtered from terminal output')
|
|
257
|
+
fixture.finish()
|
|
258
|
+
await run.done
|
|
259
|
+
fixture.session.close()
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
test('startScript entry acknowledgement has a bounded setup wait', async (t) => {
|
|
263
|
+
t.mock.timers.enable({ apis: ['setTimeout'] })
|
|
264
|
+
const fixture = executionFixture({ deferStart: true })
|
|
265
|
+
const starting = fixture.session.startScript('/main.py', undefined, { inlineCompletion: true })
|
|
266
|
+
const timedOut = assert.rejects(starting, { code: 'SCRIPT_START_TIMEOUT' })
|
|
267
|
+
await new Promise(resolve => setImmediate(resolve))
|
|
268
|
+
t.mock.timers.tick(5000)
|
|
269
|
+
await timedOut
|
|
270
|
+
assert.equal(fixture.session.scriptRunSnapshot(), null)
|
|
271
|
+
fixture.session.close()
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
test('cancellation while waiting for VM entry rejects start and releases the run capture', async () => {
|
|
275
|
+
const fixture = executionFixture({ deferStart: true })
|
|
276
|
+
const controller = new AbortController()
|
|
277
|
+
const starting = fixture.session.startScript('/main.py', undefined, {
|
|
278
|
+
inlineCompletion: true,
|
|
279
|
+
signal: controller.signal,
|
|
280
|
+
})
|
|
281
|
+
const cancelled = assert.rejects(starting, { name: 'AbortError' })
|
|
282
|
+
await new Promise(resolve => setImmediate(resolve))
|
|
283
|
+
controller.abort()
|
|
284
|
+
await cancelled
|
|
285
|
+
await new Promise(resolve => setImmediate(resolve))
|
|
286
|
+
assert.equal(fixture.session.scriptRunSnapshot(), null)
|
|
287
|
+
assert.ok(fixture.requests.some(({ metadata, payload }) => metadata.method === 'device.control' && payload.action === 'stop'))
|
|
288
|
+
fixture.session.close()
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
test('closing the transport while waiting for VM entry rejects start and releases the run capture', async () => {
|
|
292
|
+
const fixture = executionFixture({ deferStart: true })
|
|
293
|
+
const starting = fixture.session.startScript('/main.py', undefined, { inlineCompletion: true })
|
|
294
|
+
const disconnected = assert.rejects(starting, /device disconnected/)
|
|
295
|
+
await new Promise(resolve => setImmediate(resolve))
|
|
296
|
+
assert.ok(fixture.session.repl.scriptStartWaiter, 'startup is waiting for the VM-entry acknowledgement')
|
|
297
|
+
fixture.session.close(new Error('device disconnected'))
|
|
298
|
+
await disconnected
|
|
299
|
+
assert.equal(fixture.session.scriptRunSnapshot(), null)
|
|
300
|
+
assert.equal(fixture.session.repl.scriptStartWaiter, null)
|
|
301
|
+
assert.doesNotMatch(fixture.terminal, /~~EVSCRIPT-START:/)
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
test('short scripts can complete in the same frame while startScript is returning', async () => {
|
|
115
305
|
const fixture = executionFixture({ short: true })
|
|
116
|
-
const run = await fixture.session.startScript('/short.py',
|
|
306
|
+
const run = await fixture.session.startScript('/short.py', undefined, { inlineCompletion: true })
|
|
307
|
+
assert.equal(fixture.startAndCompletionInSameFrame, true)
|
|
117
308
|
assert.equal((await run.done).path, '/short.py')
|
|
118
309
|
assert.equal(fixture.session.scriptRunSnapshot(), null)
|
|
310
|
+
assert.doesNotMatch(fixture.terminal, /~~EVSCRIPT-START:/)
|
|
119
311
|
fixture.session.close()
|
|
120
312
|
})
|
|
121
313
|
|