@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.
@@ -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
- requests.push({ metadata, payload })
22
- if (metadata.channel === 'repl.stdin') { stdout(`${payload}\r\n${payload.includes('EVIDE_BOOT_OK') ? 'EVIDE_BOOT_OK' : 'result'}\r\n>>> `); return false }
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: true, session: { id, vmEpoch: 1, leaseMs: 15000, keepaliveMs: 5000 } }
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
  })