@esp-vision/device-core 0.6.1 → 0.7.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.
@@ -496,6 +496,361 @@ def memory_policy(opts):
496
496
 
497
497
 
498
498
 
499
+ _model_bytes = None
500
+ _model_offset = 0
501
+ _model_path = None
502
+ _model_vfs = None
503
+ _model_nonce = None
504
+
505
+
506
+ class ModelVfs:
507
+ """A single read-only RAM file; no Flash/SD volume or writer is touched."""
508
+ def mount(self, readonly, mkfs):
509
+ pass
510
+
511
+ def umount(self):
512
+ pass
513
+
514
+ def stat(self, path):
515
+ if path not in ('/', '/model.espdl'):
516
+ raise OSError(2)
517
+ return (0x4000 if path == '/' else 0x8000, 0, 0, 0, 0, 0, len(_model_bytes), 0, 0, 0)
518
+
519
+ def open(self, path, mode):
520
+ import io
521
+ if path != '/model.espdl' or mode not in ('r', 'rb'):
522
+ raise OSError(13)
523
+ return io.BytesIO(_model_bytes)
524
+
525
+
526
+ def begin_model(nonce, size, path):
527
+ global _model_bytes, _model_offset, _model_path, _model_nonce
528
+ assert _model_bytes is None and _model_path is None, 'model already active'
529
+ assert isinstance(nonce, str) and nonce, 'invalid model nonce'
530
+ assert 0 < size <= 2 * 1024 * 1024, 'model size out of bounds'
531
+ _model_nonce = nonce
532
+ _model_bytes = bytearray(size)
533
+ _model_offset = 0
534
+ _model_path = path
535
+ return dict(size=len(_model_bytes), offset=_model_offset, path=_model_path)
536
+
537
+
538
+ def append_model(offset, encoded):
539
+ import binascii
540
+ global _model_offset
541
+ chunk = binascii.a2b_base64(encoded)
542
+ end = offset + len(chunk)
543
+ assert isinstance(offset, int) and offset >= 0 and chunk, 'invalid model chunk'
544
+ assert _model_bytes is not None and end <= len(_model_bytes), 'model upload overflow'
545
+ if offset == _model_offset:
546
+ # An interrupt between copying and committing leaves the same offset;
547
+ # replay safely overwrites the same slice before committing it.
548
+ _model_bytes[offset:end] = chunk
549
+ _model_offset = end
550
+ else:
551
+ assert _model_offset == end, 'model chunk out of order'
552
+ assert _model_bytes[offset:end] == chunk, 'model chunk conflict'
553
+ return dict(offset=offset, end=end, bytes=len(chunk), current=_model_offset)
554
+
555
+
556
+ def mount_model(expected):
557
+ import binascii, hashlib, vfs
558
+ global _model_vfs
559
+ assert _model_bytes is not None and _model_offset == len(_model_bytes), 'incomplete model upload'
560
+ actual = binascii.hexlify(hashlib.sha256(_model_bytes).digest()).decode()
561
+ assert actual == expected, 'model SHA-256 mismatch'
562
+ # MicroPython refuses an already mounted path; never unmount another VFS.
563
+ _model_vfs = ModelVfs()
564
+ vfs.mount(_model_vfs, _model_path, readonly=True)
565
+ assert any(filesystem is _model_vfs and path == _model_path for filesystem, path in vfs.mount()), 'model mount missing'
566
+ return dict(path=_model_path, bytes=len(_model_bytes), sha256=actual)
567
+
568
+
569
+ def close_model():
570
+ import gc, vfs
571
+ global _model_bytes, _model_offset, _model_path, _model_vfs, _model_nonce
572
+ # A cancelled mount may have completed before the host observed its result.
573
+ # Match object identity, so an uncertain call never unmounts another VFS.
574
+ if _model_vfs is not None:
575
+ for filesystem, _ in vfs.mount():
576
+ if filesystem is _model_vfs:
577
+ vfs.umount(_model_vfs)
578
+ assert not any(filesystem is _model_vfs for filesystem, _ in vfs.mount()), 'model still mounted'
579
+ _model_vfs = None
580
+ _model_bytes = None
581
+ _model_offset = 0
582
+ _model_path = None
583
+ _model_nonce = None
584
+ gc.collect()
585
+ return dict(bytes=0, offset=_model_offset, mounted=False)
586
+
587
+
588
+ def model_command(operation, *args):
589
+ import json
590
+ assert operation in ('begin', 'append', 'mount', 'close'), 'invalid model operation'
591
+ nonce = globals()['_fixture_nonce']
592
+ assert _model_nonce is None or _model_nonce == nonce, 'model nonce conflict'
593
+ if operation == 'begin':
594
+ assert args[0] == nonce, 'model nonce conflict'
595
+ result = globals()[operation + '_model'](*args)
596
+ # A caught/printed Python exception must never look like a semantic ACK.
597
+ print('__EVHILACK__' + json.dumps([nonce, operation, result]))
598
+
599
+
600
+ def espdl_inference(opts):
601
+ """Load the SHA-256-verified RAM model and run real camera inference."""
602
+ import espdl, sensor, time
603
+ det = None
604
+ frame = None
605
+ try:
606
+ sensor.reset()
607
+ sensor.set_pixformat(sensor.RGB565)
608
+ sensor.set_framesize(getattr(sensor, opts['framesize']))
609
+ sensor.skip_frames(n=3)
610
+ started = time.ticks_ms()
611
+ det = espdl.ESPDet(opts['modelPath'], score=0.3, nms=0.7)
612
+ load_ms = time.ticks_diff(time.ticks_ms(), started)
613
+ frame = sensor.snapshot().copy()
614
+ width, height = frame.width(), frame.height()
615
+
616
+ def checked(results):
617
+ assert isinstance(results, list), 'detect() did not return a list'
618
+ for item in results:
619
+ assert len(item) == 6, 'malformed detection tuple'
620
+ x, y, w, h, score, category = item
621
+ assert w > 0 and h > 0, 'empty detection box'
622
+ assert x < width and y < height and x + w > 0 and y + h > 0, 'detection box outside the frame'
623
+ assert 0.0 <= score <= 1.0, 'detection score outside 0..1'
624
+ assert category >= 0, 'negative detection category'
625
+ return results
626
+
627
+ first = checked(det.detect(frame))
628
+ assert first == checked(det.detect(frame)), 'inference differs for the same input frame'
629
+ detections, total_us, worst_us = 0, 0, 0
630
+ for _ in range(opts['frames']):
631
+ img = sensor.snapshot()
632
+ start = time.ticks_us()
633
+ results = det.detect(img)
634
+ elapsed = time.ticks_diff(time.ticks_us(), start)
635
+ detections += len(checked(results))
636
+ total_us += elapsed
637
+ worst_us = max(worst_us, elapsed)
638
+ assert total_us > 0, 'implausible inference time'
639
+ return dict(width=width, height=height, loadMs=load_ms, frames=opts['frames'], detections=detections,
640
+ meanMs=total_us // opts['frames'] / 1000, worstMs=worst_us / 1000)
641
+ finally:
642
+ try:
643
+ if det is not None:
644
+ det.deinit()
645
+ finally:
646
+ frame = None
647
+ sensor.shutdown()
648
+
649
+
650
+ def isp_frame_sample(frame):
651
+ """Measure rendered RGB565 pixels, independently of ISP configuration."""
652
+ saturation = 0.0
653
+ bright = 0
654
+ rgb = [0, 0, 0]
655
+ for y in range(8, frame.height(), 16):
656
+ for x in range(8, frame.width(), 16):
657
+ pixel = frame.get_pixel(x, y)
658
+ maximum = max(pixel)
659
+ if maximum >= 16:
660
+ saturation += (maximum - min(pixel)) / maximum
661
+ bright += 1
662
+ for channel in range(3):
663
+ rgb[channel] += pixel[channel]
664
+ if bright < 16:
665
+ raise RuntimeError('ISP image effect unobservable: illuminate a stationary target (at least 16 bright samples required)')
666
+ return dict(saturation=saturation / bright, brightSamples=bright, rgb=rgb)
667
+
668
+
669
+ def isp_ccm_frames(opts):
670
+ """One write per stage; consume old frames instead of repeating the write."""
671
+ import sensor, time
672
+ stages = []
673
+ identity = [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]
674
+ grey = [0.33] * 9
675
+ red = [1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]
676
+
677
+ def stage(name, matrix, delay=0):
678
+ sensor.set_isp('ccm', enable=True, matrix=matrix)
679
+ # Deliberately leave completed buffers queued; elapsed time is no flush.
680
+ if delay:
681
+ time.sleep_ms(delay)
682
+ started = time.ticks_ms()
683
+ frames = []
684
+ for index in range(8):
685
+ sample = isp_frame_sample(sensor.snapshot())
686
+ sample.update(frame=index + 1, ms=time.ticks_diff(time.ticks_ms(), started))
687
+ frames.append(sample)
688
+ result = dict(name=name, writes=1, frames=frames, readback=sensor.get_isp('ccm'))
689
+ stages.append(result)
690
+ return frames
691
+
692
+ try:
693
+ sensor.reset()
694
+ sensor.set_pixformat(sensor.RGB565)
695
+ sensor.set_framesize(sensor.QVGA)
696
+ sensor.skip_frames(time=1000)
697
+ if sensor.get_ipa() is None or sensor.get_isp('ccm') is None:
698
+ raise RuntimeError('ESP_ERR_NOT_SUPPORTED: manual ISP CCM unavailable')
699
+ # Automatic controller ownership must reject manual writes explicitly.
700
+ sensor.set_ipa(True)
701
+ try:
702
+ sensor.set_isp('ccm', enable=True, matrix=grey)
703
+ except OSError as exc:
704
+ ownership_error = str(exc)
705
+ assert 'disable IPA before setting ISP parameters' in ownership_error, 'unexpected manual-write ownership error: ' + ownership_error
706
+ else:
707
+ raise RuntimeError('manual CCM write accepted while IPA owns tuning')
708
+ sensor.set_ipa(False)
709
+ sensor.set_isp('wb', enable=False)
710
+ sensor.skip_frames(n=3)
711
+ for name, matrix, delay, expect_grey in (('red', red, 0, False), ('grey_after_sleep', grey, 2000, True), ('red_again', red, 0, False), ('grey_again', grey, 0, True)):
712
+ frames = stage(name, matrix, delay)
713
+ # Never require the first captured frame to be from the new tuning.
714
+ for sample in frames[-3:]:
715
+ if expect_grey:
716
+ assert sample['saturation'] < 0.15, 'single CCM write did not produce grey frames after consumption'
717
+ else:
718
+ assert sample['saturation'] > 0.8, 'single CCM write did not produce red frames after consumption'
719
+ assert sample['rgb'][0] > 4 * max(1, sample['rgb'][1], sample['rgb'][2]), 'CCM red output has wrong channels'
720
+ sensor.set_isp('wb', enable=True, rg=1.5, bg=0.75)
721
+ before = stage('explicit_wb', identity)
722
+ wb_before = sensor.get_isp('wb')
723
+ after = stage('ccm_preserves_explicit_wb', identity)
724
+ wb_after = sensor.get_isp('wb')
725
+ assert wb_before == wb_after, 'CCM write changed explicit WB driver configuration'
726
+ for channel in (0, 2):
727
+ ratios = [sum(s['rgb'][channel] for s in samples[-3:]) / max(1, sum(s['rgb'][1] for s in samples[-3:])) for samples in (before, after)]
728
+ assert abs(ratios[0] - ratios[1]) < 0.2, 'CCM write changed rendered explicit WB on stationary target'
729
+ return dict(sensorId=sensor.get_id(), stages=stages, wbBefore=wb_before, wbAfter=wb_after,
730
+ frameBudget=8, ownershipError=ownership_error,
731
+ note='Driver readback is not a frame acknowledgement; sensor-internal AWB is not measured')
732
+ finally:
733
+ sensor.shutdown()
734
+
735
+
736
+ def isp_heap_sample():
737
+ import gc, esp32
738
+ gc.collect()
739
+ result = {}
740
+ for name, caps in (('dma', 1 << 3), ('psram', 1 << 10), ('internal', (1 << 11) | (1 << 2))):
741
+ heaps = esp32.idf_heap_info(caps)
742
+ result[name] = dict(free=sum(item[1] for item in heaps), largest=max([item[2] for item in heaps] or [0]))
743
+ return result
744
+
745
+
746
+ def isp_release_camera_lcd(lcd):
747
+ import sensor, gc
748
+ # A display cleanup failure must not strand the camera's DMA/LSC resources.
749
+ try:
750
+ if lcd is not None:
751
+ lcd.deinit()
752
+ finally:
753
+ try:
754
+ sensor.shutdown()
755
+ finally:
756
+ gc.collect()
757
+
758
+
759
+ def isp_lsc_display(opts):
760
+ """LSC changes and CCM bursts during the original canvas/LCD preview path."""
761
+ import sensor, display, image, time, gc
762
+ lcd = None
763
+ canvas = None
764
+ frame = None
765
+ sensor.shutdown()
766
+ before = isp_heap_sample()
767
+ result = {}
768
+ try:
769
+ sensor.reset()
770
+ sensor.set_pixformat(sensor.RGB565)
771
+ sensor.set_framesize(sensor.QVGA)
772
+ sensor.skip_frames(n=3)
773
+ if sensor.get_ipa() is None or sensor.get_isp('lsc') is None:
774
+ raise RuntimeError('ESP_ERR_NOT_SUPPORTED: manual ISP LSC unavailable')
775
+ sensor.set_ipa(False)
776
+ lsc = sensor.get_isp('lsc')
777
+ count = lsc['lsc_gain_size']
778
+ assert count > 1, 'invalid LSC table size'
779
+ ramp = [1.0 - 0.5 * (i / (count - 1)) ** 2 for i in range(count)]
780
+ matrix = sensor.get_isp('ccm')['matrix']
781
+ lcd = display.Display()
782
+ canvas = image.Image(lcd.width(), lcd.height(), image.RGB565)
783
+ scale = min(lcd.width() / 320, lcd.height() / 240)
784
+ x = int((lcd.width() - 320 * scale) / 2)
785
+ y = int((lcd.height() - 240 * scale) / 2)
786
+
787
+ def write_frame():
788
+ frame = sensor.snapshot()
789
+ canvas.draw_image(frame, x, y, x_scale=scale, y_scale=scale)
790
+ lcd.write(canvas)
791
+
792
+ # Warm both persistent LSC banks and the display's reusable DMA staging.
793
+ for _ in range(2):
794
+ sensor.set_isp('lsc', enable=True, gain_r=ramp, gain_gr=ramp, gain_gb=ramp, gain_b=ramp)
795
+ write_frame()
796
+ sensor.set_isp('lsc', enable=False)
797
+ write_frame()
798
+ active = isp_heap_sample()
799
+ samples = []
800
+ frames = max(80, opts['frames'])
801
+ sample_every = max(40, (frames + 9) // 10)
802
+ started = time.ticks_ms()
803
+ switches = 0
804
+ for index in range(frames):
805
+ if index % 20 == 0:
806
+ sensor.set_isp('lsc', enable=(switches % 2 == 0), gain_r=ramp, gain_gr=ramp, gain_gb=ramp, gain_b=ramp)
807
+ switches += 1
808
+ sensor.set_isp('ccm', enable=True, matrix=matrix)
809
+ sensor.set_isp('ccm', enable=True, matrix=matrix)
810
+ write_frame()
811
+ if (index + 1) % sample_every == 0 or index + 1 == frames:
812
+ samples.append(dict(frame=index + 1, heap=isp_heap_sample()))
813
+ after = isp_heap_sample()
814
+ for name in active:
815
+ assert after[name]['free'] >= active[name]['free'] - 4096, 'ISP/LCD pressure lost ' + name + ' heap'
816
+ assert after[name]['largest'] >= active[name]['largest'] - 4096, 'ISP/LCD pressure fragmented ' + name + ' heap'
817
+ # Rejected parameters must leave preview usable; do not induce OOM.
818
+ try:
819
+ sensor.set_isp('lsc', enable=True, gain_r=[1.0], gain_gr=ramp, gain_gb=ramp, gain_b=ramp)
820
+ except ValueError:
821
+ pass
822
+ else:
823
+ raise RuntimeError('invalid LSC table accepted')
824
+ write_frame()
825
+ result = dict(sensorId=sensor.get_id(), frames=frames, ccmWrites=2 * frames, lscSwitches=switches,
826
+ gainCount=count, elapsedMs=time.ticks_diff(time.ticks_ms(), started),
827
+ before=before, active=active, samples=samples, after=after,
828
+ rejectedWriteRecovered=True, note='Software preview and allocation evidence; physical LCD requires observation')
829
+ finally:
830
+ try:
831
+ isp_release_camera_lcd(lcd)
832
+ finally:
833
+ lcd = None
834
+ canvas = None
835
+ frame = None
836
+ gc.collect()
837
+ result['released'] = isp_heap_sample()
838
+ for name in before:
839
+ assert result['released'][name]['free'] >= before[name]['free'] - 4096, 'ISP/LCD cleanup leaked ' + name + ' heap'
840
+ # Prove that normal cleanup permits another camera/LCD lifetime.
841
+ try:
842
+ sensor.reset()
843
+ sensor.set_pixformat(sensor.RGB565)
844
+ sensor.set_framesize(sensor.QVGA)
845
+ lcd = display.Display()
846
+ lcd.write(sensor.snapshot(), fit=True)
847
+ result['restartRecovered'] = True
848
+ finally:
849
+ isp_release_camera_lcd(lcd)
850
+ result['afterRestartRelease'] = isp_heap_sample()
851
+ return result
852
+
853
+
499
854
  def memory(opts):
500
855
  import esp32
501
856
  heaps = esp32.idf_heap_info(esp32.HEAP_DATA)
@@ -528,7 +883,8 @@ def run_check(name, opts):
528
883
  image_copy_to_fb=image_copy_to_fb,
529
884
  image_transform_argument=image_transform_argument,
530
885
  sensor_snapshot_buffer=sensor_snapshot_buffer,
531
- memory_policy=memory_policy)
886
+ memory_policy=memory_policy, espdl_inference=espdl_inference,
887
+ isp_ccm_frames=isp_ccm_frames, isp_lsc_display=isp_lsc_display)
532
888
  try:
533
889
  data = functions[name](opts)
534
890
  result = dict(name=name, status='PASS', data=data)
@@ -7,7 +7,7 @@ export { tcpTransport, serialTransport } from '../src/node_transport.js'
7
7
  import { createDevice } from '../src/index.js'
8
8
  import { DEVICE_CHECKS, runDeviceChecks, saveJpeg } from './device-checks.mjs'
9
9
 
10
- export const usage = 'Usage: npm run test:hardware -- (--tcp HOST[:PORT] | --serial PATH) [--role usj|usb|uart] [--python PYTHON] [--expect-board BOARD] [--expect-firmware-version VERSION] [--write] [--capture] [--checks all|CSV] [--require-all] [--frames N] [--framesize QVGA] [--pixformat RGB565] [--quality 60] [--frame-timeout SECONDS] [--display-hold SECONDS] [--output CAMERA.jpg] [--screen-output LCD.jpg] [--timeout MILLISECONDS]'
10
+ export const usage = 'Usage: npm run test:hardware -- (--tcp HOST[:PORT] | --serial PATH) [--role usj|usb|uart] [--python PYTHON] [--expect-board BOARD] [--expect-firmware-version VERSION] [--write] [--capture] [--checks all|CSV] [--require-all] [--frames N] [--framesize QVGA] [--pixformat RGB565] [--quality 60] [--frame-timeout SECONDS] [--display-hold SECONDS] [--output CAMERA.jpg] [--screen-output LCD.jpg] [--model MODEL.espdl] [--model-sha256 HEX] [--timeout MILLISECONDS]'
11
11
  const fail = (code, message) => Object.assign(new Error(message), { code })
12
12
 
13
13
  export function parseArgs(args) {
@@ -17,7 +17,7 @@ export function parseArgs(args) {
17
17
  const option = args[index]
18
18
  if (option === '--help') return { help: true }
19
19
  if (['--write', '--capture', '--require-all'].includes(option)) { options[option === '--require-all' ? 'requireAll' : option.slice(2)] = true; continue }
20
- if (!['--tcp', '--serial', '--role', '--python', '--timeout', '--checks', '--screen-output', '--output', '--frames', '--display-hold', '--framesize', '--pixformat', '--quality', '--frame-timeout', '--expect-board', '--expect-firmware-version'].includes(option)) throw fail('ARGUMENT', 'Unknown argument')
20
+ if (!['--tcp', '--serial', '--role', '--python', '--timeout', '--checks', '--screen-output', '--output', '--frames', '--display-hold', '--framesize', '--pixformat', '--quality', '--frame-timeout', '--expect-board', '--expect-firmware-version', '--model', '--model-sha256'].includes(option)) throw fail('ARGUMENT', 'Unknown argument')
21
21
  const value = args[++index]
22
22
  if (!value || value.startsWith('--')) throw fail('ARGUMENT', 'Missing argument value')
23
23
  const key = option.slice(2).replace(/-([a-z])/g, (_match, letter) => letter.toUpperCase())
@@ -37,6 +37,7 @@ export function parseArgs(args) {
37
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
38
  if (!Number.isFinite(options.displayHold) || options.displayHold < 0 || options.displayHold > 60) throw fail('ARGUMENT', 'Display hold must be 0 to 60 seconds')
39
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.modelSha256 && !/^[a-f0-9]{64}$/.test(options.modelSha256)) throw fail('ARGUMENT', 'Model SHA-256 must be 64 lowercase hexadecimal characters')
40
41
  if (options.output && !options.checks.includes('camera')) options.capture = true
41
42
  options.timeout = Number(options.timeout)
42
43
  if (!Number.isSafeInteger(options.timeout) || options.timeout < 1000 || options.timeout > 600000) throw fail('ARGUMENT', 'Timeout must be 1000 to 600000 milliseconds')
@@ -7,6 +7,10 @@ import { openBrowserRom } from '../src/browser.js'
7
7
  import { createOfficialBrowserLoader, browserRomInterface, ROM_UART_BAUD_RATES } from '../src/browser_rom.js'
8
8
  import { browserTransportCompatibility, browserRomCompatibility, readBrowserFlash, romWords } from '../src/browser_rom_compat.js'
9
9
  import { romMd5 } from '../src/rom_md5.js'
10
+ import { ESPLoader } from 'esptool-js'
11
+ import { ESP32S3ROM } from 'esptool-js/lib/targets/esp32s3.js'
12
+ import { ESP32P4ROM } from 'esptool-js/lib/targets/esp32p4.js'
13
+ import { ESP32S31ROM } from 'esptool-js/lib/targets/esp32s31.js'
10
14
 
11
15
  const passivePort = () => ({ open() { throw new Error('Port must remain passive') }, close() {} })
12
16
  test('browser ROM construction and disconnect remain passive without browser globals', async () => {
@@ -14,6 +18,7 @@ test('browser ROM construction and disconnect remain passive without browser glo
14
18
  assert.equal(adapter.chipFamily, null)
15
19
  assert.equal(adapter.chipRevision, null)
16
20
  assert.equal(adapter.chipName, null)
21
+ assert.equal(adapter.chipMac, null)
17
22
  await adapter.disconnect(); await adapter.disconnect()
18
23
  await assert.rejects(adapter.initialize(), { code: 'ROM_CLOSED' })
19
24
  })
@@ -24,6 +29,22 @@ test('browser ROM validates its port and cancellation before loading the depende
24
29
  controller.abort(new Error('cancelled before open'))
25
30
  await assert.rejects(openBrowserRom({ port: passivePort(), signal: controller.signal }).initialize(), /cancelled before open/)
26
31
  })
32
+ test('expectedMac is validated synchronously and construction does not mutate options or ports', async () => {
33
+ for (const expectedMac of [null, true, 1, {}, [], '', '30eda0eda1b', '30eda0eda1b00', '30:ed:a0:ed:a1:b0', '30eda0eda1bg', ' 30eda0eda1b0', '30eda0eda1b0\n']) {
34
+ let touched = 0
35
+ const port = Object.freeze({ getInfo() { touched++; return {} }, open() { touched++ }, close() { touched++ } })
36
+ const options = Object.freeze({ port, expectedMac })
37
+ assert.throws(() => openBrowserRom(options), /expectedMac/)
38
+ assert.throws(() => createOfficialBrowserLoader({}, port, {}, { expectedMac }), /expectedMac/)
39
+ assert.equal(touched, 0)
40
+ assert.equal(options.expectedMac, expectedMac)
41
+ }
42
+ const port = Object.freeze(passivePort()), options = Object.freeze({ port, expectedMac: '30EDA0EDA1B0' })
43
+ const adapter = openBrowserRom(options)
44
+ assert.equal(adapter.chipMac, null)
45
+ assert.equal(options.expectedMac, '30EDA0EDA1B0')
46
+ await adapter.disconnect()
47
+ })
27
48
  test('interface discovery separates known bridge, OTG and JTAG; custom descriptors need evidence', () => {
28
49
  const port = (usbVendorId, usbProductId) => ({ getInfo: () => ({ usbVendorId, usbProductId }) })
29
50
  assert.equal(browserRomInterface(port(0x10c4, 0xea60)), 'uart')
@@ -94,7 +115,7 @@ test('native USB MEM_END timeout exception never bypasses validated stub greetin
94
115
  const lost = browserRomCompatibility({ memFinish: async () => { throw new Error('Port disconnected') } }, { nativeUsb: true })
95
116
  await assert.rejects(lost.memFinish(), /disconnected/)
96
117
  })
97
- function officialFixture({ failRates = [], usb = false, security = { flags: 0, flashCryptCnt: 0 }, failWrite = false, recoverSecurity, recoverFamily, detach = false, reopenResets = false, flashId = 0x1840ef, stubStarts = true } = {}) {
118
+ function officialFixture({ failRates = [], usb = false, security = { flags: 0, flashCryptCnt: 0 }, failWrite = false, recoverSecurity, recoverFamily, detach = false, reopenResets = false, flashId = 0x1840ef, stubStarts = true, signal, expectedMac, mac = '30:ed:a0:ed:a1:b0', recoverMac } = {}) {
98
119
  const events = [], calls = [], loaders = [], opens = [], reads = []
99
120
  const port = { readable: null, writable: null,
100
121
  getInfo: () => ({ usbVendorId: usb ? 0x303a : 0x10c4, usbProductId: usb ? 32 : 0xea60 }),
@@ -112,15 +133,17 @@ function officialFixture({ failRates = [], usb = false, security = { flags: 0, f
112
133
  async setRTS(value) { calls.push(['rts', value]) }
113
134
  }
114
135
  class FakeLoader {
115
- constructor({ transport }) { this.transport = transport; this.CHIP_DETECT_MAGIC_REG_ADDR = 0x40001000; this.ESP_CHANGE_BAUDRATE = 15; this.ESP_READ_FLASH = 0xd2; this.chip = { CHIP_NAME: loaders.length && recoverFamily ? recoverFamily : 'ESP32-S31', getChipRevision: async () => 0, postConnect: async () => calls.push('postConnect') }; loaders.push(this) }
136
+ constructor({ transport }) { this.transport = transport; this.CHIP_DETECT_MAGIC_REG_ADDR = 0x40001000; this.ESP_CHANGE_BAUDRATE = 15; this.ESP_READ_FLASH = 0xd2; this.ESP_ERASE_REGION = 0xd1; this.ERASE_REGION_TIMEOUT_PER_MB = 30000; this.chip = { CHIP_NAME: loaders.length && recoverFamily ? recoverFamily : 'ESP32-S31', getChipRevision: async () => 0, readMac: async reader => { assert.equal(reader, this); calls.push('mac'); return loaders.length > 1 && recoverMac !== undefined ? recoverMac : mac }, postConnect: async () => calls.push('postConnect') }; loaders.push(this) }
116
137
  async detectChip(mode) { calls.push(['detect', mode]); await port.open({ baudRate: 115200 }); this.transport.baudrate = 115200 }
117
138
  async getSecurityInfo() { return loaders.length > 1 && recoverSecurity ? recoverSecurity : security }
118
139
  async runStub() { calls.push('stub'); this.IS_STUB = stubStarts }
119
140
  async readReg() { if (!this.IS_STUB || failRates.includes(this.transport.baudrate)) throw new Error('speed probe timeout'); return 123 }
120
141
  async readFlashId() { return flashId }
121
142
  async writeReg(...args) { calls.push(['register-write', ...args]) }
143
+ timeoutPerMb(...args) { return ESPLoader.prototype.timeoutPerMb.apply(this, args) }
122
144
  async checkCommand(description, _opcode, request) {
123
145
  if (description === 'change baud rate') { calls.push(['change', new DataView(request.buffer).getUint32(0, true)]); return 0 }
146
+ if (description === 'erase region') { calls.push(['erase', ...new Uint32Array(request.buffer)]); return 0 }
124
147
  const length = new DataView(request.buffer).getUint32(4, true), data = new Uint8Array(length)
125
148
  const frame = bytes => new Uint8Array([0xc0, ...bytes, 0xc0])
126
149
  // All-zero bytes and their digest need no SLIP escaping in this fixture.
@@ -134,9 +157,73 @@ function officialFixture({ failRates = [], usb = false, security = { flags: 0, f
134
157
  }
135
158
  async writeFlash(options) { calls.push(['write', options.compress, options.eraseAll, options.fileArray[0].address]); if (failWrite) throw new Error('write interrupted'); options.reportProgress(0, 10, 10) }
136
159
  }
137
- const adapter = createOfficialBrowserLoader({ ESPLoader: FakeLoader, Transport: FakeTransport }, port, {}, { onStatus: event => events.push(event) })
160
+ const adapter = createOfficialBrowserLoader({ ESPLoader: FakeLoader, Transport: FakeTransport }, port, {}, { onStatus: event => events.push(event), signal, expectedMac })
138
161
  return { adapter, events, calls, port, loaders, opens, reads }
139
162
  }
163
+ test('the official P4/S3/S31 factory MAC readers supply the normalized public chipMac', async () => {
164
+ for (const Chip of [ESP32P4ROM, ESP32S3ROM, ESP32S31ROM]) {
165
+ const chip = new Chip(), registers = []
166
+ const mac = await chip.readMac({ async readReg(address) {
167
+ registers.push(address)
168
+ return address === chip.MAC_EFUSE_REG ? 0xa0eda1b0 : 0x30ed
169
+ } })
170
+ assert.equal(mac, '30:ed:a0:ed:a1:b0')
171
+ assert.deepEqual(registers, [chip.MAC_EFUSE_REG, chip.MAC_EFUSE_REG + 4])
172
+ const f = officialFixture({ usb: true, mac, expectedMac: '30EDA0EDA1B0' })
173
+ assert.equal(f.adapter.chipMac, null)
174
+ await f.adapter.initialize()
175
+ assert.equal(f.adapter.chipMac, '30eda0eda1b0')
176
+ assert.ok(!f.calls.includes('stub'))
177
+ await f.adapter.disconnect()
178
+ }
179
+ })
180
+ test('a first expected MAC mismatch keeps the identity error on USB and UART and permanently closes', async () => {
181
+ for (const usb of [true, false]) {
182
+ const f = officialFixture({ usb, expectedMac: '30eda0eda1b1' })
183
+ await assert.rejects(f.adapter.initialize(), { code: 'ROM_IDENTITY_CHANGED' })
184
+ assert.equal(f.adapter.chipMac, null)
185
+ assert.equal(f.port.readable, null)
186
+ assert.equal(f.calls.filter(call => call === 'close').length, 1)
187
+ assert.ok(!f.calls.includes('stub'))
188
+ assert.ok(!f.calls.some(call => Array.isArray(call) && ['write', 'erase', 'register-write'].includes(call[0])))
189
+ await assert.rejects(f.adapter.initialize(), { code: 'ROM_CLOSED' })
190
+ await assert.rejects(f.adapter.runStub(), { code: 'ROM_CLOSED' })
191
+ assert.equal(f.loaders.length, 1)
192
+ }
193
+ })
194
+ test('a recovered same-family/revision device with a different MAC never receives another stub or write', async () => {
195
+ for (const expectedMac of [undefined, '30eda0eda1b0']) {
196
+ const f = officialFixture({ expectedMac, failRates: [2000000], recoverMac: '30:ed:a0:ed:a1:b1' })
197
+ await f.adapter.initialize()
198
+ await assert.rejects(f.adapter.runStub(), { code: 'ROM_IDENTITY_CHANGED' })
199
+ assert.equal(f.adapter.chipMac, '30eda0eda1b0')
200
+ assert.equal(f.calls.filter(call => call === 'stub').length, 1)
201
+ assert.equal(f.loaders.length, 2)
202
+ assert.equal(f.port.readable, null)
203
+ assert.ok(!f.calls.some(call => Array.isArray(call) && ['write', 'erase'].includes(call[0])))
204
+ await assert.rejects(f.adapter.runStub(), { code: 'ROM_CLOSED' })
205
+ }
206
+ })
207
+ test('malformed official MAC replies fail closed before stub startup on native USB', async () => {
208
+ for (const mac of [null, false, [], new Uint8Array(6), '', '30eda0eda1b0', '30:ed:a0:ed:a1', '30:ed:a0:ed:a1:gg', '30:ed:a0:ed:a1:b0\n']) {
209
+ const f = officialFixture({ usb: true, mac })
210
+ await assert.rejects(f.adapter.initialize(), { code: 'ROM_IDENTITY_INVALID' })
211
+ assert.equal(f.adapter.chipMac, null)
212
+ assert.equal(f.port.readable, null)
213
+ assert.ok(!f.calls.includes('stub'))
214
+ await assert.rejects(f.adapter.runStub(), { code: 'ROM_CLOSED' })
215
+ }
216
+ })
217
+ test('an invalid recovery MAC stops baud fallback without opening another port or starting another stub', async () => {
218
+ const f = officialFixture({ failRates: [2000000], recoverMac: '' })
219
+ await f.adapter.initialize()
220
+ await assert.rejects(f.adapter.runStub(), { code: 'ROM_IDENTITY_INVALID' })
221
+ assert.equal(f.loaders.length, 2)
222
+ assert.equal(f.calls.filter(call => call === 'stub').length, 1)
223
+ assert.equal(f.port.readable, null)
224
+ assert.ok(!f.calls.some(call => Array.isArray(call) && ['write', 'erase'].includes(call[0])))
225
+ await assert.rejects(f.adapter.initialize(), { code: 'ROM_CLOSED' })
226
+ })
140
227
  test('ROM UART starts at 115200, checks candidates in order and stops at the first success', async () => {
141
228
  const f = officialFixture({ failRates: [2000000, 1500000] })
142
229
  await f.adapter.initialize(); assert.equal(f.adapter.chipFamily, 0x3231)
@@ -220,3 +307,82 @@ test('missing native stub handshake never creates a usable stub or starts a writ
220
307
  assert.ok(!f.calls.some(call => Array.isArray(call) && call[0] === 'write'))
221
308
  assert.equal(f.port.readable, null)
222
309
  })
310
+
311
+ test('region erase uses the real official checked command and size-scaled timeout without chip erase', async () => {
312
+ const f = officialFixture({ usb: true })
313
+ await f.adapter.initialize(); const stub = await f.adapter.runStub()
314
+ const requests = [], upstream = f.loaders[0]
315
+ upstream.debug = () => {}
316
+ upstream.checkCommand = ESPLoader.prototype.checkCommand
317
+ upstream.command = async (...args) => { requests.push(args); return [0, new Uint8Array([0, 0])] }
318
+ await stub.eraseRegion(0x710000, 0x8f0000)
319
+ await stub.eraseRegion(0xfffff000, 4096)
320
+ assert.deepEqual(requests, [[0xd1, romWords(0x710000, 0x8f0000), 0, undefined,
321
+ upstream.timeoutPerMb(30000, 0x8f0000)], [0xd1, romWords(0xfffff000, 4096), 0, undefined, 3000]])
322
+ assert.deepEqual(f.events.at(-1), { phase: 'erasing-region', address: 0xfffff000, size: 4096 })
323
+ assert.ok(!f.calls.some(call => Array.isArray(call) && call[0] === 'write'))
324
+ await f.adapter.disconnect()
325
+ })
326
+
327
+ test('region erase rejects malformed ranges before sending an erase command', async () => {
328
+ for (const [address, size] of [[-4096, 4096], [1, 4096], [0, 0], [0, 1], [0, 0x100000000],
329
+ [0xfffff000, 8192], [NaN, 4096], [true, 4096], [0, true], [0x100000000, 4096]]) {
330
+ const f = officialFixture({ usb: true })
331
+ await f.adapter.initialize(); const stub = await f.adapter.runStub()
332
+ await assert.rejects(stub.eraseRegion(address, size), RangeError)
333
+ assert.ok(!f.calls.some(call => Array.isArray(call) && call[0] === 'erase'))
334
+ await f.adapter.disconnect()
335
+ }
336
+ })
337
+
338
+ test('region erase requires a live validated stub and supported upstream command primitives', async () => {
339
+ for (const invalid of ['stub', 'command', 'timeout', 'budget']) {
340
+ const f = officialFixture({ usb: true })
341
+ await f.adapter.initialize(); const stub = await f.adapter.runStub()
342
+ if (invalid === 'stub') f.loaders[0].IS_STUB = false
343
+ if (invalid === 'command') f.loaders[0].ESP_ERASE_REGION = 0xd0
344
+ if (invalid === 'timeout') f.loaders[0].timeoutPerMb = null
345
+ if (invalid === 'budget') f.loaders[0].ERASE_REGION_TIMEOUT_PER_MB = NaN
346
+ await assert.rejects(stub.eraseRegion(0x710000, 4096), { code: invalid === 'stub' ? 'ROM_STUB_REQUIRED' : 'ROM_ERASE_UNSUPPORTED' })
347
+ assert.ok(!f.calls.some(call => Array.isArray(call) && call[0] === 'erase'))
348
+ await f.adapter.disconnect()
349
+ }
350
+ })
351
+
352
+ test('real upstream region erase status, truncated replies and operation errors invalidate the session without retry', async () => {
353
+ for (const reply of [[0, new Uint8Array([1, 0])], [0, new Uint8Array([0])], [1, new Uint8Array([0, 0])], new Error('erase interrupted')]) {
354
+ const f = officialFixture({ usb: true })
355
+ await f.adapter.initialize(); const stub = await f.adapter.runStub()
356
+ let commands = 0
357
+ f.loaders[0].debug = () => {}
358
+ f.loaders[0].checkCommand = ESPLoader.prototype.checkCommand
359
+ f.loaders[0].command = async () => { commands++; if (reply instanceof Error) throw reply; return reply }
360
+ await assert.rejects(stub.eraseRegion(0x710000, 4096))
361
+ await assert.rejects(stub.flashData(new ArrayBuffer(4), () => {}, 0))
362
+ await assert.rejects(stub.eraseRegion(0x710000, 4096))
363
+ assert.equal(commands, 1)
364
+ assert.equal(f.port.readable, null)
365
+ }
366
+ })
367
+
368
+ test('region erase cancellation closes a pending request and never reports completion or retries', async () => {
369
+ const controller = new AbortController(), f = officialFixture({ usb: true, signal: controller.signal })
370
+ await f.adapter.initialize(); const stub = await f.adapter.runStub()
371
+ let started, commands = 0
372
+ f.loaders[0].checkCommand = () => new Promise(resolve => { commands++; started = resolve })
373
+ const erasing = stub.eraseRegion(0x710000, 4096), failed = assert.rejects(erasing, { name: 'AbortError' })
374
+ controller.abort(); await failed
375
+ started(0)
376
+ await assert.rejects(stub.flashData(new ArrayBuffer(4), () => {}, 0))
377
+ assert.equal(commands, 1); assert.equal(f.port.readable, null)
378
+ })
379
+
380
+ test('a detached port cannot report region erase success even after a stale successful reply', async () => {
381
+ const f = officialFixture({ usb: true })
382
+ await f.adapter.initialize(); const stub = await f.adapter.runStub()
383
+ let commands = 0
384
+ f.loaders[0].checkCommand = async () => { commands++; await f.port.close(); return 0 }
385
+ await assert.rejects(stub.eraseRegion(0x710000, 4096), { code: 'ROM_PORT_LOST' })
386
+ await assert.rejects(stub.eraseRegion(0x710000, 4096))
387
+ assert.equal(commands, 1)
388
+ })
@@ -0,0 +1,31 @@
1
+ // SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import assert from 'node:assert/strict'
4
+ import { existsSync, readFileSync } from 'node:fs'
5
+ import test from 'node:test'
6
+ import { DEVICE_CHECKS } from './device-checks.mjs'
7
+ import { parseArgs } from './hardware-check.mjs'
8
+
9
+ const ciConfig = new URL('../../../.gitlab/ci/target_test.yml', import.meta.url)
10
+ test('CI keeps its qualified hardware suite explicit while manual ISP checks remain selectable', {
11
+ skip: !existsSync(ciConfig) && 'Repository CI configuration is not included in the npm package',
12
+ }, () => {
13
+ const yaml = readFileSync(ciConfig, 'utf8')
14
+ const commands = yaml.replace(/\\\r?\n\s*/g, ' ').split(/\r?\n/)
15
+ .map(line => line.trim()).filter(line => line.startsWith('python3 tools/ci/flash_and_test.py '))
16
+ assert.equal(commands.length, 1)
17
+ const selections = [...commands[0].matchAll(/(?:^|\s)--checks\s+(\S+)/g)]
18
+ assert.equal(selections.length, 1)
19
+ assert.match(commands[0], /(?:^|\s)--require-all(?:\s|$)/)
20
+ const expected = ['identity', 'memory', 'camera', 'framesizes', 'pixformats', 'orientation',
21
+ 'ipa_stats', 'snapshot_rate', 'preview', 'image_ops', 'display', 'display_stream',
22
+ 'camera_display_lifecycle', 'image_copy_to_fb', 'image_transform_argument',
23
+ 'sensor_snapshot_buffer', 'memory_policy', 'espdl_inference']
24
+ const selected = parseArgs(['--serial', 'policy-test', '--checks', selections[0][1], '--require-all'])
25
+ assert.deepEqual(selected.checks, expected)
26
+ assert.equal(selected.requireAll, true)
27
+ assert.deepEqual(parseArgs(['--serial', 'policy-test', '--checks', 'all']).checks, [...DEVICE_CHECKS])
28
+ const manual = ['isp_ccm_frames', 'isp_lsc_display']
29
+ assert.deepEqual(parseArgs(['--serial', 'policy-test', '--checks', manual.join(',')]).checks, manual)
30
+ for (const name of manual) assert.ok(DEVICE_CHECKS.includes(name))
31
+ })