@@ -381,32 +381,115 @@ def _make(buffers):
381381# -----------------------------------------------------------------------------
382382
383383
384+ class FakePylonTimeoutException (RuntimeError ):
385+ pass
386+
387+
384388class FakePylon :
385- """Minimal fake for 'from pypylon import pylon' usage in basler_backend ."""
389+ """Fake for 'from pypylon import pylon' used by BaslerCameraBackend ."""
386390
387- # Constants used by Basler backend
388391 GrabStrategy_LatestImageOnly = 1
389392 TimeoutHandling_ThrowException = 1
390- PixelType_BGR8packed = 0x02180014 # arbitrary token
393+ PixelType_BGR8packed = 0x02180014
391394 OutputBitAlignment_MsbAligned = 1
392395
396+ class _EnumEntry :
397+ def __init__ (self , symbolic : str ):
398+ self ._symbolic = symbolic
399+
400+ def GetSymbolic (self ):
401+ return self ._symbolic
402+
393403 class _Feature :
394- def __init__ (self , value = 0 ):
404+ def __init__ (
405+ self ,
406+ value = 0 ,
407+ * ,
408+ symbolics : list [str ] | None = None ,
409+ minimum = None ,
410+ maximum = None ,
411+ increment = 1 ,
412+ writable = True ,
413+ readable = True ,
414+ ):
395415 self ._value = value
416+ self ._symbolics = list (symbolics or [])
417+ self ._min = minimum
418+ self ._max = maximum
419+ self ._inc = increment
420+ self ._writable = writable
421+ self ._readable = readable
422+ self .set_calls : list [object ] = []
396423
397424 def SetValue (self , v ):
425+ if not self ._writable :
426+ raise RuntimeError ("feature is not writable" )
427+ if self ._symbolics and v not in self ._symbolics :
428+ raise RuntimeError (f"unsupported symbolic { v !r} ; available={ self ._symbolics } " )
398429 self ._value = v
430+ self .set_calls .append (v )
399431
400432 def GetValue (self ):
433+ if not self ._readable :
434+ raise RuntimeError ("feature is not readable" )
401435 return self ._value
402436
437+ def GetSymbolics (self ):
438+ return list (self ._symbolics )
439+
440+ def GetEntries (self ):
441+ return [FakePylon ._EnumEntry (s ) for s in self ._symbolics ]
442+
443+ def IsWritable (self ):
444+ return bool (self ._writable )
445+
446+ def IsReadable (self ):
447+ return bool (self ._readable )
448+
449+ def GetMin (self ):
450+ if self ._min is None :
451+ raise RuntimeError ("no min" )
452+ return self ._min
453+
454+ def GetMax (self ):
455+ if self ._max is None :
456+ raise RuntimeError ("no max" )
457+ return self ._max
458+
459+ def GetInc (self ):
460+ return self ._inc
461+
403462 class _DeviceInfo :
404- def __init__ (self , serial : str ):
463+ def __init__ (
464+ self ,
465+ serial : str ,
466+ * ,
467+ vendor : str = "Basler" ,
468+ model : str = "FakeBasler" ,
469+ friendly : str | None = None ,
470+ full_name : str | None = None ,
471+ ):
405472 self ._serial = serial
473+ self ._vendor = vendor
474+ self ._model = model
475+ self ._friendly = friendly or f"{ vendor } { model } ({ serial } )"
476+ self ._full_name = full_name or f"FakeFullName-{ serial } "
406477
407478 def GetSerialNumber (self ):
408479 return self ._serial
409480
481+ def GetVendorName (self ):
482+ return self ._vendor
483+
484+ def GetModelName (self ):
485+ return self ._model
486+
487+ def GetFriendlyName (self ):
488+ return self ._friendly
489+
490+ def GetFullName (self ):
491+ return self ._full_name
492+
410493 class _Device :
411494 def __init__ (self , info ):
412495 self .info = info
@@ -433,49 +516,120 @@ class _GrabResult:
433516 def __init__ (self , ok = True , array = None ):
434517 self ._ok = ok
435518 self ._array = array
519+ self .released = False
436520
437521 def GrabSucceeded (self ):
438522 return bool (self ._ok )
439523
440524 def Release (self ):
441- return None
525+ self . released = True
442526
443527 class InstantCamera :
444528 def __init__ (self , device ):
445529 self ._device = device
446530 self ._open = False
447531 self ._grabbing = False
448532
449- # Feature nodes the backend uses
533+ self .retrieve_calls : list [int ] = []
534+ self .start_calls = 0
535+ self .stop_calls = 0
536+ self .close_calls = 0
537+ self .software_trigger_calls = 0
538+ self ._software_trigger_pending = 0
539+
540+ # General camera controls.
541+ self .ExposureAuto = FakePylon ._Feature ("Off" , symbolics = ["Off" , "Once" , "Continuous" ])
450542 self .ExposureTime = FakePylon ._Feature (1000.0 )
543+ self .GainAuto = FakePylon ._Feature ("Off" , symbolics = ["Off" , "Once" , "Continuous" ])
451544 self .Gain = FakePylon ._Feature (0.0 )
452- self .Width = FakePylon ._Feature (1920 )
453- self .Height = FakePylon ._Feature (1080 )
545+
546+ self .Width = FakePylon ._Feature (1920 , minimum = 64 , maximum = 4096 , increment = 2 )
547+ self .Height = FakePylon ._Feature (1080 , minimum = 64 , maximum = 4096 , increment = 2 )
454548
455549 self .AcquisitionFrameRateEnable = FakePylon ._Feature (False )
456550 self .AcquisitionFrameRate = FakePylon ._Feature (30.0 )
457551
552+ self .MaxNumBuffer = FakePylon ._Feature (10 )
553+
554+ # Basler/pypylon trigger features.
555+ self .AcquisitionMode = FakePylon ._Feature ("Continuous" , symbolics = ["Continuous" , "SingleFrame" ])
556+ self .TriggerSelector = FakePylon ._Feature ("FrameStart" , symbolics = ["FrameStart" ])
557+ self .TriggerMode = FakePylon ._Feature ("Off" , symbolics = ["Off" , "On" ])
558+ self .TriggerSource = FakePylon ._Feature (
559+ "Software" ,
560+ symbolics = [
561+ "Software" ,
562+ "Line1" ,
563+ "Line2" ,
564+ "Line3" ,
565+ "PeriodicSignal1" ,
566+ "Action1" ,
567+ ],
568+ )
569+ self .TriggerActivation = FakePylon ._Feature (
570+ "RisingEdge" ,
571+ symbolics = ["RisingEdge" , "FallingEdge" , "AnyEdge" , "LevelHigh" , "LevelLow" ],
572+ )
573+ self .TriggerDelay = FakePylon ._Feature (0.0 )
574+
575+ # Generic output line features.
576+ self .LineSelector = FakePylon ._Feature ("Line1" , symbolics = ["Line1" , "Line2" , "Line3" ])
577+ self .LineMode = FakePylon ._Feature ("Input" , symbolics = ["Input" , "Output" ])
578+ self .LineSource = FakePylon ._Feature (
579+ "Off" ,
580+ symbolics = ["Off" , "ExposureActive" , "AcquisitionActive" ],
581+ )
582+ self .LineInverter = FakePylon ._Feature (False )
583+
584+ # Test knobs.
585+ self .allow_hardware_trigger_frame = False
586+ self .force_failed_grab = False
587+
458588 def Open (self ):
459589 self ._open = True
460590
461591 def Close (self ):
592+ self .close_calls += 1
462593 self ._open = False
463594
464595 def IsOpen (self ):
465596 return bool (self ._open )
466597
467598 def StartGrabbing (self , * _args , ** _kwargs ):
599+ self .start_calls += 1
468600 self ._grabbing = True
469601
470602 def StopGrabbing (self ):
603+ self .stop_calls += 1
471604 self ._grabbing = False
472605
473606 def IsGrabbing (self ):
474607 return bool (self ._grabbing )
475608
476- def RetrieveResult (self , * _args , ** _kwargs ):
477- # Always succeed with a small dummy image (BGR)
478- import numpy as np
609+ def ExecuteSoftwareTrigger (self ):
610+ self .software_trigger_calls += 1
611+ self ._software_trigger_pending += 1
612+
613+ def RetrieveResult (self , timeout_ms , * _args , ** _kwargs ):
614+ self .retrieve_calls .append (int (timeout_ms ))
615+
616+ if not self ._grabbing :
617+ raise FakePylonTimeoutException ("Grab timed out: acquisition not started" )
618+
619+ if self .force_failed_grab :
620+ return FakePylon ._GrabResult (ok = False , array = None )
621+
622+ trigger_on = self .TriggerMode .GetValue () == "On"
623+ source = self .TriggerSource .GetValue ()
624+
625+ if trigger_on :
626+ if source == "Software" :
627+ if self ._software_trigger_pending <= 0 :
628+ raise FakePylonTimeoutException ("Grab timed out: waiting for software trigger" )
629+ self ._software_trigger_pending -= 1
630+ else :
631+ if not self .allow_hardware_trigger_frame :
632+ raise FakePylonTimeoutException ("Grab timed out: waiting for hardware trigger" )
479633
480634 frame = np .zeros ((10 , 10 , 3 ), dtype = np .uint8 )
481635 return FakePylon ._GrabResult (ok = True , array = frame )
@@ -498,25 +652,61 @@ def Convert(self, grab_result):
498652
499653@pytest .fixture ()
500654def fake_pylon_module ():
501- """
502- Returns the FakePylon 'module' and resets singleton devices for isolation.
503- """
504- # reset singleton factory so devices list resets per test
655+ """Returns fake pylon module and resets fake device inventory."""
505656 FakePylon .TlFactory ._instance = None
657+ factory = FakePylon .TlFactory .GetInstance ()
658+ factory ._devices = [
659+ FakePylon ._DeviceInfo ("FAKE-BASLER-0" ),
660+ FakePylon ._DeviceInfo ("FAKE-BASLER-1" ),
661+ ]
506662 return FakePylon
507663
508664
509665@pytest .fixture ()
510666def patch_basler_sdk (monkeypatch , fake_pylon_module ):
511- """
512- Patch Basler backend to behave as if pypylon is installed, using FakePylon.
513- """
667+ """Patch Basler backend to use FakePylon."""
514668 import dlclivegui .cameras .backends .basler_backend as bb
515669
516670 monkeypatch .setattr (bb , "pylon" , fake_pylon_module , raising = False )
517671 return fake_pylon_module
518672
519673
674+ @pytest .fixture ()
675+ def basler_settings_factory ():
676+ from dlclivegui .config import CameraSettings
677+
678+ def _make (
679+ * ,
680+ index = 0 ,
681+ name = "BaslerTestCam" ,
682+ width = 0 ,
683+ height = 0 ,
684+ fps = 0.0 ,
685+ exposure = 0 ,
686+ gain = 0.0 ,
687+ enabled = True ,
688+ properties = None ,
689+ ):
690+ props = properties if isinstance (properties , dict ) else {}
691+ props .setdefault ("basler" , {})
692+ props ["basler" ] = dict (props ["basler" ])
693+
694+ return CameraSettings (
695+ name = name ,
696+ index = index ,
697+ backend = "basler" ,
698+ width = width ,
699+ height = height ,
700+ fps = fps ,
701+ exposure = exposure ,
702+ gain = gain ,
703+ enabled = enabled ,
704+ properties = props ,
705+ )
706+
707+ return _make
708+
709+
520710# -----------------------------------------------------------------------------
521711# Fake GenTL / harvesters SDK (SDK-free) + fixtures for strict lifecycle tests
522712# -----------------------------------------------------------------------------
0 commit comments