77import threading
88import time
99from pathlib import Path
10- from typing import ClassVar
10+ from typing import Any , ClassVar
1111
1212import cv2
1313import numpy as np
1414
15+ from ...config import CameraTriggerSettings
1516from ..base import CameraBackend , SupportLevel , register_backend
1617from ..factory import DetectedCamera
1718from .utils import gentl_discovery as cti_finder
@@ -105,6 +106,16 @@ def __init__(self, settings):
105106 self ._gain = self ._positive_float (ns .get ("gain" , props .get ("gain" )))
106107
107108 self ._timeout : float = float (ns .get ("timeout" , props .get ("timeout" , 2.0 )))
109+ try :
110+ self ._trigger = CameraTriggerSettings .from_any (ns .get ("trigger" , props .get ("trigger" )))
111+ except Exception as exc :
112+ LOG .warning ("Invalid GenTL trigger config; falling back to trigger role=off: %s" , exc )
113+ self ._trigger = CameraTriggerSettings ()
114+
115+ trigger_timeout = self ._positive_float (self ._trigger_attr (self ._trigger , "timeout" , None ))
116+ if trigger_timeout is not None :
117+ self ._timeout = float (trigger_timeout )
118+
108119 self ._requested_resolution : tuple [int , int ] | None = self ._get_requested_resolution_or_none ()
109120
110121 self ._actual_width : int | None = None
@@ -154,6 +165,7 @@ def static_capabilities(cls) -> dict[str, SupportLevel]:
154165 "set_gain" : SupportLevel .SUPPORTED ,
155166 "device_discovery" : SupportLevel .SUPPORTED ,
156167 "stable_identity" : SupportLevel .SUPPORTED ,
168+ "hardware_trigger" : SupportLevel .BEST_EFFORT ,
157169 }
158170
159171 # ------------------------------------------------------------------
@@ -420,11 +432,12 @@ def open(self) -> None:
420432 self ._device_label = self ._resolve_device_label (node_map )
421433
422434 self ._configure_pixel_format (node_map )
423- self ._configure_trigger (node_map )
424435 self ._configure_resolution (node_map )
425436 self ._configure_exposure (node_map )
426437 self ._configure_gain (node_map )
427438 self ._configure_frame_rate (node_map )
439+ self ._configure_trigger (node_map ) # keep low in the list
440+ self ._ensure_settings_ns ()["trigger_actual" ] = self ._trigger_to_dict (self ._trigger )
428441 self ._read_telemetry (node_map )
429442 self ._persist_device_metadata (selected_info , selected_serial )
430443
@@ -449,6 +462,11 @@ def open(self) -> None:
449462 f"Failed to open GenTL camera.\n \n Loaded CTIs: { loaded } \n Failed CTIs: { failed } \n Reason: { exc } "
450463 ) from exc
451464
465+ @property
466+ def waits_for_hardware_trigger (self ) -> bool :
467+ role = str (self ._trigger_attr (getattr (self , "_trigger" , None ), "role" , "off" ) or "off" ).lower ()
468+ return role in {"external" , "follower" }
469+
452470 def read (self ) -> tuple [np .ndarray , float ]:
453471 if self ._acquirer is None :
454472 raise RuntimeError ("GenTL image acquirer not initialised" )
@@ -470,6 +488,9 @@ def read(self) -> tuple[np.ndarray, float]:
470488 except ValueError :
471489 frame = array .copy ()
472490 except HarvesterTimeoutError as exc :
491+ role = str (self ._trigger_attr (getattr (self , "_trigger" , None ), "role" , "off" ) or "off" ).lower ()
492+ if role in {"external" , "follower" }:
493+ raise TimeoutError (str (exc ) + " (GenTL timeout; waiting for hardware trigger?)" ) from exc
473494 raise TimeoutError (str (exc ) + " (GenTL timeout)" ) from exc
474495
475496 frame = self ._convert_frame (frame )
@@ -502,6 +523,12 @@ def close(self) -> None:
502523 except Exception :
503524 pass
504525
526+ try :
527+ node_map = self ._acquirer .remote_device .node_map
528+ self ._call_with_optional_lock (self ._restore_trigger_idle , node_map )
529+ except Exception :
530+ pass
531+
505532 try :
506533 destroy = getattr (self ._acquirer , "destroy" , None )
507534 if destroy is not None :
@@ -949,6 +976,62 @@ def _create_acquirer(self, serial: str | None, index: int):
949976 # ------------------------------------------------------------------
950977 # Camera configuration helpers
951978 # ------------------------------------------------------------------
979+ @staticmethod
980+ def _node (node_map , name : str ):
981+ try :
982+ return getattr (node_map , name )
983+ except Exception :
984+ return None
985+
986+ @staticmethod
987+ def _node_symbolics (node ) -> list [str ]:
988+ try :
989+ return list (getattr (node , "symbolics" , []) or [])
990+ except Exception :
991+ return []
992+
993+ def _set_enum_node (self , node_map , name : str , value : str , * , strict : bool = False ) -> bool :
994+ node = self ._node (node_map , name )
995+ if node is None :
996+ if strict :
997+ raise RuntimeError (f"GenICam node '{ name } ' is not available" )
998+ LOG .debug ("GenICam node '%s' is not available; skipping" , name )
999+ return False
1000+
1001+ symbolics = self ._node_symbolics (node )
1002+ if symbolics and value not in symbolics :
1003+ if strict :
1004+ raise RuntimeError (f"GenICam node '{ name } ' does not support '{ value } '. Available: { symbolics } " )
1005+ LOG .warning ("GenICam node '%s' does not support '%s'. Available: %s" , name , value , symbolics )
1006+ return False
1007+
1008+ try :
1009+ node .value = value
1010+ return True
1011+ except Exception as exc :
1012+ if strict :
1013+ raise RuntimeError (f"Failed to set GenICam node '{ name } ' to '{ value } ': { exc } " ) from exc
1014+ LOG .warning ("Failed to set GenICam node '%s' to '%s': %s" , name , value , exc )
1015+ return False
1016+
1017+ @staticmethod
1018+ def _trigger_attr (trigger , name : str , default = None ):
1019+ if isinstance (trigger , dict ):
1020+ return trigger .get (name , default )
1021+ return getattr (trigger , name , default )
1022+
1023+ @staticmethod
1024+ def _trigger_to_dict (trigger ) -> dict [str , Any ]:
1025+ if trigger is None :
1026+ return {}
1027+ if isinstance (trigger , dict ):
1028+ return dict (trigger )
1029+ if hasattr (trigger , "model_dump" ):
1030+ try :
1031+ return trigger .model_dump (exclude_none = True )
1032+ except Exception :
1033+ pass
1034+ return {}
9521035
9531036 def _configure_pixel_format (self , node_map ) -> None :
9541037 try :
@@ -1004,12 +1087,95 @@ def _configure_pixel_format(self, node_map) -> None:
10041087 LOG .warning ("Failed to configure pixel format '%s': %s" , self ._pixel_format , e )
10051088
10061089 def _configure_trigger (self , node_map ) -> None :
1090+ cfg = self ._trigger
1091+ role = str (self ._trigger_attr (cfg , "role" , "off" ) or "off" ).strip ().lower ()
1092+ strict = bool (self ._trigger_attr (cfg , "strict" , False ))
1093+
1094+ if role in {"off" , "disabled" }:
1095+ self ._configure_trigger_off (node_map , strict = strict )
1096+ return
1097+
1098+ if role in {"external" , "follower" }:
1099+ self ._configure_trigger_input (node_map , cfg , strict = strict )
1100+ return
1101+
1102+ if role == "master" :
1103+ self ._configure_trigger_master (node_map , cfg , strict = strict )
1104+ return
1105+
1106+ if strict :
1107+ raise RuntimeError (f"Unsupported GenTL trigger role: { role !r} " )
1108+
1109+ LOG .warning ("Unsupported GenTL trigger role '%s'; disabling trigger." , role )
1110+ self ._configure_trigger_off (node_map , strict = False )
1111+
1112+ def _configure_trigger_off (self , node_map , * , strict : bool = False ) -> None :
1113+ self ._set_enum_node (node_map , "TriggerMode" , "Off" , strict = strict )
1114+
1115+ def _configure_trigger_input (self , node_map , cfg , * , strict : bool = False ) -> None :
1116+ selector = str (self ._trigger_attr (cfg , "selector" , "FrameStart" ) or "FrameStart" )
1117+ source = str (self ._trigger_attr (cfg , "source" , "Line0" ) or "Line0" )
1118+ activation = str (self ._trigger_attr (cfg , "activation" , "RisingEdge" ) or "RisingEdge" )
1119+
1120+ # Disable trigger while changing trigger-related nodes.
1121+ self ._set_enum_node (node_map , "TriggerMode" , "Off" , strict = False )
1122+
1123+ self ._set_enum_node (node_map , "TriggerSelector" , selector , strict = strict )
1124+ self ._set_enum_node (node_map , "TriggerSource" , source , strict = strict )
1125+ self ._set_enum_node (node_map , "TriggerActivation" , activation , strict = strict )
1126+
1127+ self ._set_enum_node (node_map , "AcquisitionMode" , "Continuous" , strict = False )
1128+
1129+ if not self ._set_enum_node (node_map , "TriggerMode" , "On" , strict = strict ):
1130+ if strict :
1131+ raise RuntimeError ("Could not enable GenTL TriggerMode=On" )
1132+
1133+ LOG .info (
1134+ "GenTL trigger input configured: role=%s selector=%s source=%s activation=%s" ,
1135+ self ._trigger_attr (cfg , "role" , "external" ),
1136+ selector ,
1137+ source ,
1138+ activation ,
1139+ )
1140+
1141+ def _configure_trigger_master (self , node_map , cfg , * , strict : bool = False ) -> None :
1142+ output_line = str (self ._trigger_attr (cfg , "output_line" , "Line2" ) or "Line2" )
1143+ output_source = str (self ._trigger_attr (cfg , "output_source" , "ExposureActive" ) or "ExposureActive" )
1144+
1145+ # Master camera runs freerun and exposes an output signal.
1146+ self ._configure_trigger_off (node_map , strict = False )
1147+
1148+ self ._set_enum_node (node_map , "LineSelector" , output_line , strict = strict )
1149+ self ._set_enum_node (node_map , "LineMode" , "Output" , strict = strict )
1150+ self ._set_enum_node (node_map , "LineSource" , output_source , strict = strict )
1151+
1152+ LOG .info (
1153+ "GenTL trigger master configured: output_line=%s output_source=%s" ,
1154+ output_line ,
1155+ output_source ,
1156+ )
1157+
1158+ def _restore_trigger_idle (self , node_map ) -> None :
1159+ """Best-effort restore to a safe non-triggering state after acquisition stops.
1160+
1161+ Important:
1162+ - This should be called after acquirer.stop(), not while acquisition is active.
1163+ - It is intentionally non-strict because shutdown should not fail if a node
1164+ is missing or read-only.
1165+ """
1166+ role = str (self ._trigger_attr (getattr (self , "_trigger" , None ), "role" , "off" ) or "off" ).lower ()
1167+
10071168 try :
1008- trigger_mode = getattr (node_map , "TriggerMode" , None )
1009- if trigger_mode is not None and "Off" in getattr (trigger_mode , "symbolics" , []):
1010- trigger_mode .value = "Off"
1011- except Exception as e :
1012- LOG .warning ("Failed to disable trigger mode: %s" , e )
1169+ if role in {"external" , "follower" }:
1170+ self ._set_enum_node (node_map , "TriggerMode" , "Off" , strict = False )
1171+
1172+ elif role == "master" :
1173+ # Stop driving output if the camera exposes these nodes.
1174+ self ._set_enum_node (node_map , "LineSource" , "Off" , strict = False )
1175+ self ._set_enum_node (node_map , "LineMode" , "Input" , strict = False )
1176+
1177+ except Exception :
1178+ LOG .debug ("Best-effort GenTL trigger restore failed" , exc_info = True )
10131179
10141180 def _configure_resolution (self , node_map ) -> None :
10151181 if self ._requested_resolution is None :
0 commit comments