1818)
1919
2020from ...cameras .factory import CameraFactory , DetectedCamera , apply_detected_identity , camera_identity_key
21- from ...config import CameraSettings , MultiCameraSettings
21+ from ...config import CameraSettings , CameraTriggerSettings , MultiCameraSettings
2222from .loaders import CameraLoadWorker , CameraProbeWorker , CameraScanState , DetectCamerasWorker
2323from .preview import PreviewSession , PreviewState , apply_crop , apply_rotation , resize_to_fit , to_display_pixmap
24+ from .trigger_config_dialog import TriggerConfigDialog
2425from .ui_blocks import setup_camera_config_dialog_ui
2526
2627LOGGER = logging .getLogger (__name__ )
@@ -328,6 +329,7 @@ def _connect_signals(self) -> None:
328329 self .active_cameras_list .currentRowChanged .connect (self ._on_active_camera_selected )
329330 self .available_cameras_list .currentRowChanged .connect (self ._on_available_camera_selected )
330331 self .available_cameras_list .itemDoubleClicked .connect (self ._on_available_camera_double_clicked )
332+ self .trigger_settings_btn .clicked .connect (self ._open_trigger_settings_dialog )
331333 self .apply_settings_btn .clicked .connect (self ._apply_camera_settings )
332334 self .reset_settings_btn .clicked .connect (self ._reset_selected_camera )
333335 self .preview_btn .clicked .connect (self ._toggle_preview )
@@ -451,11 +453,24 @@ def _refresh_camera_labels(self) -> None:
451453 finally :
452454 cam_list .blockSignals (False )
453455
456+ def _trigger_role_for_label (self , cam : CameraSettings ) -> str :
457+ backend = (cam .backend or "" ).lower ()
458+ props = cam .properties if isinstance (cam .properties , dict ) else {}
459+ ns = props .get (backend , {}) if isinstance (props .get (backend ), dict ) else {}
460+ trigger = ns .get ("trigger" , {})
461+ if not isinstance (trigger , dict ):
462+ return "off"
463+ return str (trigger .get ("role" , "off" ) or "off" ).lower ()
464+
454465 def _format_camera_label (self , cam : CameraSettings , index : int = - 1 ) -> str :
455466 status = "✓" if cam .enabled else "○"
456467 this_id = f"{ (cam .backend or '' ).lower ()} :{ cam .index } "
457468 dlc_indicator = " [DLC]" if this_id == self ._dlc_camera_id and cam .enabled else ""
458- return f"{ status } { cam .name } [{ cam .backend } :{ cam .index } ]{ dlc_indicator } "
469+
470+ trigger_role = self ._trigger_role_for_label (cam )
471+ trigger_indicator = "" if trigger_role in {"off" , "disabled" } else f" [{ trigger_role } ]"
472+
473+ return f"{ status } { cam .name } [{ cam .backend } :{ cam .index } ]{ trigger_indicator } { dlc_indicator } "
459474
460475 def _selected_detected_camera (self ) -> DetectedCamera | None :
461476 row = self .available_cameras_list .currentRow ()
@@ -514,6 +529,9 @@ def apply(widget, feature: str, label: str, *, allow_best_effort: bool = True):
514529 apply (self .cam_exposure , "set_exposure" , "Exposure" )
515530 apply (self .cam_gain , "set_gain" , "Gain" )
516531
532+ # Hardware trigger / sync
533+ apply (self .trigger_settings_btn , "hardware_trigger" , "Hardware trigger" )
534+
517535 def _set_preview_button_loading (self , loading : bool ) -> None :
518536 if loading :
519537 self .preview_btn .setText ("Cancel Loading" )
@@ -800,6 +818,21 @@ def _on_active_camera_selected(self, row: int) -> None:
800818 self ._load_camera_to_form (cam )
801819 self ._start_probe_for_camera (cam , apply_to_requested = False )
802820
821+ def _ensure_default_trigger_config (self , cam : CameraSettings ) -> None :
822+ backend = (cam .backend or "" ).lower ()
823+ if backend != "gentl" :
824+ return
825+
826+ if not isinstance (cam .properties , dict ):
827+ cam .properties = {}
828+
829+ ns = cam .properties .setdefault ("gentl" , {})
830+ if not isinstance (ns , dict ):
831+ ns = {}
832+ cam .properties ["gentl" ] = ns
833+
834+ ns .setdefault ("trigger" , CameraTriggerSettings ().model_dump (exclude_none = True ))
835+
803836 def _add_selected_camera (self ) -> None :
804837 if not self ._commit_pending_edits (reason = "before adding a new camera" ):
805838 return
@@ -850,6 +883,7 @@ def _add_selected_camera(self) -> None:
850883 properties = {},
851884 )
852885 apply_detected_identity (new_cam , detected , backend )
886+ self ._ensure_default_trigger_config (new_cam )
853887 self ._working_settings .cameras .append (new_cam )
854888 new_index = len (self ._working_settings .cameras ) - 1
855889 new_item = QListWidgetItem (self ._format_camera_label (new_cam , new_index ))
@@ -969,6 +1003,7 @@ def _load_camera_to_form(self, cam: CameraSettings) -> None:
9691003 self .cam_crop_y0 .setValue (cam .crop_y0 )
9701004 self .cam_crop_x1 .setValue (cam .crop_x1 )
9711005 self .cam_crop_y1 .setValue (cam .crop_y1 )
1006+ self ._ensure_default_trigger_config (cam )
9721007 self .apply_settings_btn .setEnabled (True )
9731008 self ._set_detected_labels (cam )
9741009 finally :
@@ -1029,6 +1064,39 @@ def _enabled_count_with(self, row: int, new_enabled: bool) -> int:
10291064 count += 1
10301065 return count
10311066
1067+ def _open_trigger_settings_dialog (self ) -> None :
1068+ """Open per-camera hardware trigger settings dialog."""
1069+ if self ._current_edit_index is None :
1070+ return
1071+
1072+ row = self ._current_edit_index
1073+ if row < 0 or row >= len (self ._working_settings .cameras ):
1074+ return
1075+
1076+ # Commit normal camera edits first so we do not lose pending UI changes.
1077+ if not self ._commit_pending_edits (reason = "before opening trigger settings" ):
1078+ return
1079+
1080+ cam = self ._working_settings .cameras [row ]
1081+
1082+ dlg = TriggerConfigDialog (cam , self )
1083+ if dlg .exec () != QDialog .Accepted :
1084+ return
1085+
1086+ updated = dlg .camera_settings
1087+
1088+ self ._working_settings .cameras [row ] = updated
1089+ self ._update_active_list_item (row , updated )
1090+ self ._load_camera_to_form (updated )
1091+
1092+ # Trigger changes require reopening the camera preview/backend.
1093+ if self ._preview .state == PreviewState .ACTIVE :
1094+ self ._append_status ("[Trigger] Restarting preview to apply trigger settings." )
1095+ self ._request_preview_restart (updated , reason = "trigger-settings" )
1096+
1097+ self .apply_settings_btn .setEnabled (False )
1098+ self ._set_apply_dirty (False )
1099+
10321100 def _apply_camera_settings (self ) -> bool :
10331101 try :
10341102 for sb in (
@@ -1597,6 +1665,13 @@ def _bump_epoch(self) -> int:
15971665 self ._preview .epoch += 1
15981666 return self ._preview .epoch
15991667
1668+ def _trigger_dict_for_cam (self , cam : CameraSettings ) -> dict :
1669+ backend = (cam .backend or "" ).lower ()
1670+ props = cam .properties if isinstance (cam .properties , dict ) else {}
1671+ ns = props .get (backend , {}) if isinstance (props .get (backend ), dict ) else {}
1672+ trigger = ns .get ("trigger" , {})
1673+ return trigger if isinstance (trigger , dict ) else {}
1674+
16001675 def _should_restart_preview (self , old : CameraSettings , new : CameraSettings ) -> bool :
16011676 """
16021677 Fast UX policy:
@@ -1612,6 +1687,9 @@ def _should_restart_preview(self, old: CameraSettings, new: CameraSettings) -> b
16121687 except Exception :
16131688 return True # safest: restart
16141689
1690+ if self ._trigger_dict_for_cam (old ) != self ._trigger_dict_for_cam (new ):
1691+ return True
1692+
16151693 # No restart needed if only rotation/crop/enabled changed
16161694 return False
16171695
0 commit comments