@esp-vision/device-core 0.6.0 → 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.
- package/CHANGELOG.md +29 -0
- package/README.md +8 -5
- package/package.json +1 -1
- package/schemas/device-actions-v1.json +1 -1
- package/scripts/generate-action-schema.mjs +2 -1
- package/scripts/npm-command.mjs +17 -0
- package/scripts/release-npm.mjs +2 -1
- package/src/browser.js +5 -2
- package/src/browser_rom.js +55 -5
- package/src/device_session.js +85 -9
- package/src/evmux.js +15 -0
- package/src/firmware_flash.js +84 -26
- package/src/firmware_partitions.js +57 -0
- package/src/firmware_updates.js +5 -2
- package/src/node_rom.js +49 -7
- package/src/node_rom.py +92 -5
- package/src/node_rom_port.js +27 -19
- package/src/node_serial.py +75 -12
- package/src/node_transport.js +63 -14
- package/src/rom_identity.js +9 -0
- package/tests/check-browser.mjs +23 -4
- package/tests/check-package.mjs +15 -7
- package/tests/device-checks.mjs +89 -7
- package/tests/firmware-fixtures.mjs +60 -0
- package/tests/fixtures/device-checks.py +357 -1
- package/tests/hardware-check.mjs +3 -2
- package/tests/test-browser-rom.mjs +169 -3
- package/tests/test-ci-check-selection.mjs +31 -0
- package/tests/test-device-functional-checks.mjs +275 -3
- package/tests/test-device-v4-alignment.mjs +261 -7
- package/tests/test-firmware-flash.mjs +22 -20
- package/tests/test-firmware-install.mjs +8 -35
- package/tests/test-firmware-source.mjs +10 -7
- package/tests/test-host-checks.mjs +47 -0
- package/tests/test-install-erase.mjs +153 -0
- package/tests/test-node-rom-port.mjs +81 -1
- package/tests/test-node-rom.mjs +159 -4
- package/tests/test-node-serial.mjs +231 -2
- package/tests/test-npm-release-policy.mjs +3 -9
|
@@ -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)
|
package/tests/hardware-check.mjs
CHANGED
|
@@ -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
|
+
})
|