Skip to content

Commit 646ac99

Browse files
committed
Cap hardware-trigger fetch timeout and update tests
Introduce a MAX_HARDWARE_TRIGGER_FETCH_TIMEOUT and cap Harvester.fetch() timeouts when the trigger role waits for external hardware (roles: external, follower) to keep individual fetch calls short and allow prompt shutdown. Preserve legacy behavior for non-waiting roles (e.g. master). Remove a noisy Trigger input LOG.info call. Update tests to expect the capped fetch timeout, verify the original requested timeout is still persisted in trigger_actual, add a test that master mode is not capped, and make test resource cleanup more robust (use try/finally around open/close).
1 parent 65b33a3 commit 646ac99

2 files changed

Lines changed: 93 additions & 37 deletions

File tree

dlclivegui/cameras/backends/gentl_backend.py

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,11 @@ class GenTLCameraBackend(CameraBackend):
7676
_CTI_FILES_SOURCE_AUTO: ClassVar[str] = "auto"
7777
_CTI_FILES_SOURCE_USER: ClassVar[str] = "user"
7878

79+
# Keep individual Harvester.fetch() calls short enough that controller
80+
# shutdown can stop worker threads promptly. Hardware-trigger waits are
81+
# handled by repeated polling in SingleCameraWorker.
82+
_MAX_HARDWARE_TRIGGER_FETCH_TIMEOUT: ClassVar[float] = 1.0
83+
7984
def __init__(self, settings):
8085
super().__init__(settings)
8186

@@ -124,7 +129,16 @@ def __init__(self, settings):
124129

125130
trigger_timeout = self._positive_float(self._trigger_attr(self._trigger, "timeout", None))
126131
if trigger_timeout is not None:
127-
self._timeout = float(trigger_timeout)
132+
role = str(self._trigger_attr(self._trigger, "role", "off") or "off").strip().lower()
133+
134+
if role in {"external", "follower"}:
135+
# Do not let a long hardware-trigger wait block shutdown.
136+
# SingleCameraWorker treats these fetch timeouts as expected
137+
# polling misses while waits_for_hardware_trigger is true.
138+
self._timeout = min(float(trigger_timeout), self._MAX_HARDWARE_TRIGGER_FETCH_TIMEOUT)
139+
else:
140+
# For non-trigger-waiting modes, preserve legacy behavior.
141+
self._timeout = float(trigger_timeout)
128142

129143
self._requested_resolution: tuple[int, int] | None = self._get_requested_resolution_or_none()
130144

@@ -1176,14 +1190,6 @@ def _configure_trigger_input(self, node_map, cfg, *, strict: bool = False) -> No
11761190
activation_ok,
11771191
)
11781192

1179-
LOG.info(
1180-
"GenTL trigger input configured: role=%s selector=%s source=%s activation=%s",
1181-
self._trigger_attr(cfg, "role", "external"),
1182-
selector,
1183-
source,
1184-
activation,
1185-
)
1186-
11871193
def _configure_trigger_master(self, node_map, cfg, *, strict: bool = False) -> None:
11881194
output_line = str(self._trigger_attr(cfg, "output_line", "Line2") or "Line2")
11891195
output_source = str(self._trigger_attr(cfg, "output_source", "ExposureActive") or "ExposureActive")

tests/cameras/backends/test_gentl_trigger.py

Lines changed: 78 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ def test_trigger_external_configures_input_line_and_timeout(patch_gentl_sdk, gen
6363
"selector": "FrameStart",
6464
"source": "Line0",
6565
"activation": "RisingEdge",
66-
"timeout": 10.0,
66+
"timeout": gb.GenTLCameraBackend._MAX_HARDWARE_TRIGGER_FETCH_TIMEOUT,
6767
},
6868
)
6969
be = gb.GenTLCameraBackend(settings)
@@ -76,7 +76,7 @@ def test_trigger_external_configures_input_line_and_timeout(patch_gentl_sdk, gen
7676
assert nm.TriggerActivation.value == "RisingEdge"
7777
assert nm.TriggerMode.value == "On"
7878
assert be.waits_for_hardware_trigger is True
79-
assert be._timeout == pytest.approx(10.0)
79+
assert be._timeout == pytest.approx(gb.GenTLCameraBackend._MAX_HARDWARE_TRIGGER_FETCH_TIMEOUT)
8080

8181
ns = settings.properties["gentl"]
8282
assert ns["trigger_actual"]["role"] == "external"
@@ -265,8 +265,12 @@ def test_trigger_alias_on_maps_to_external(patch_gentl_sdk, gentl_settings_facto
265265
be.close()
266266

267267

268-
def test_trigger_timeout_overrides_default_fetch_timeout(patch_gentl_sdk, gentl_settings_factory):
268+
def test_trigger_timeout_is_capped_for_hardware_trigger_fetch_polling(
269+
patch_gentl_sdk,
270+
gentl_settings_factory,
271+
):
269272
gb = patch_gentl_sdk
273+
expected_fetch_timeout = gb.GenTLCameraBackend._MAX_HARDWARE_TRIGGER_FETCH_TIMEOUT
270274

271275
settings = _gentl_trigger_settings(
272276
gentl_settings_factory,
@@ -277,18 +281,30 @@ def test_trigger_timeout_overrides_default_fetch_timeout(patch_gentl_sdk, gentl_
277281
)
278282
be = gb.GenTLCameraBackend(settings)
279283

280-
be.open()
281-
assert be._timeout == pytest.approx(7.5)
284+
try:
285+
be.open()
282286

283-
# Fake acquisition is started, so read should pass and record the timeout.
284-
frame, _ = be.read()
285-
assert frame is not None
286-
assert be._acquirer.fetch_calls[-1] == pytest.approx(7.5)
287+
# Hardware-trigger fetch calls are intentionally capped so stop(wait=True)
288+
# is not blocked by a long user trigger timeout.
289+
assert be._timeout == pytest.approx(expected_fetch_timeout)
287290

288-
be.close()
291+
# Fake acquisition is started, so read should pass and record the capped timeout.
292+
frame, _ = be.read()
293+
assert frame is not None
294+
assert be._acquirer.fetch_calls[-1] == pytest.approx(expected_fetch_timeout)
295+
296+
# The requested trigger timeout is still preserved in persisted trigger_actual.
297+
actual = settings.properties["gentl"]["trigger_actual"]
298+
assert actual["timeout"] == pytest.approx(7.5)
289299

300+
finally:
301+
be.close()
290302

291-
def test_trigger_timeout_error_mentions_hardware_trigger_when_waiting(patch_gentl_sdk, gentl_settings_factory):
303+
304+
def test_trigger_timeout_error_mentions_hardware_trigger_when_waiting(
305+
patch_gentl_sdk,
306+
gentl_settings_factory,
307+
):
292308
gb = patch_gentl_sdk
293309

294310
settings = _gentl_trigger_settings(
@@ -297,22 +313,27 @@ def test_trigger_timeout_error_mentions_hardware_trigger_when_waiting(patch_gent
297313
"role": "external",
298314
"timeout": 3.0,
299315
},
300-
# fast_start keeps acquisition stopped; fake fetch then raises timeout.
301-
# This lets us assert the backend timeout message without hardware.
302316
)
317+
# fast_start keeps acquisition stopped; fake fetch then raises timeout.
318+
# This lets us assert the backend timeout message without hardware.
303319
settings.properties["gentl"]["fast_start"] = True
320+
304321
be = gb.GenTLCameraBackend(settings)
305322

306-
be.open()
323+
try:
324+
be.open()
307325

308-
with pytest.raises(TimeoutError) as ei:
309-
be.read()
326+
assert be._timeout == pytest.approx(gb.GenTLCameraBackend._MAX_HARDWARE_TRIGGER_FETCH_TIMEOUT)
310327

311-
msg = str(ei.value).lower()
312-
assert "gentl timeout" in msg
313-
assert "hardware trigger" in msg or "trigger" in msg
328+
with pytest.raises(TimeoutError) as ei:
329+
be.read()
314330

315-
be.close()
331+
msg = str(ei.value).lower()
332+
assert "gentl timeout" in msg
333+
assert "hardware trigger" in msg or "trigger" in msg
334+
335+
finally:
336+
be.close()
316337

317338

318339
def test_trigger_actual_is_persisted_for_debugging(patch_gentl_sdk, gentl_settings_factory):
@@ -330,16 +351,22 @@ def test_trigger_actual_is_persisted_for_debugging(patch_gentl_sdk, gentl_settin
330351
)
331352
be = gb.GenTLCameraBackend(settings)
332353

333-
be.open()
354+
try:
355+
be.open()
334356

335-
actual = settings.properties["gentl"].get("trigger_actual")
336-
assert isinstance(actual, dict)
337-
assert actual["role"] == "follower"
338-
assert actual["source"] == "Line1"
339-
assert actual["activation"] == "FallingEdge"
340-
assert actual["timeout"] == pytest.approx(9.0)
357+
# Requested timeout remains in trigger_actual for debugging/config visibility.
358+
actual = settings.properties["gentl"].get("trigger_actual")
359+
assert isinstance(actual, dict)
360+
assert actual["role"] == "follower"
361+
assert actual["source"] == "Line1"
362+
assert actual["activation"] == "FallingEdge"
363+
assert actual["timeout"] == pytest.approx(9.0)
341364

342-
be.close()
365+
# But each blocking Harvester.fetch() call is capped for responsive shutdown.
366+
assert be._timeout == pytest.approx(gb.GenTLCameraBackend._MAX_HARDWARE_TRIGGER_FETCH_TIMEOUT)
367+
368+
finally:
369+
be.close()
343370

344371

345372
def test_trigger_invalid_selector_non_strict_disables_trigger(patch_gentl_sdk, gentl_settings_factory):
@@ -372,3 +399,26 @@ def test_trigger_invalid_selector_non_strict_disables_trigger(patch_gentl_sdk, g
372399
assert actual["role"] == "off"
373400

374401
be.close()
402+
403+
404+
def test_trigger_timeout_not_capped_for_master_mode(patch_gentl_sdk, gentl_settings_factory):
405+
gb = patch_gentl_sdk
406+
407+
settings = _gentl_trigger_settings(
408+
gentl_settings_factory,
409+
{
410+
"role": "master",
411+
"timeout": 7.5,
412+
},
413+
)
414+
be = gb.GenTLCameraBackend(settings)
415+
416+
try:
417+
be.open()
418+
419+
# Master is free-running / trigger-generating, not waiting for hardware input.
420+
assert be.waits_for_hardware_trigger is False
421+
assert be._timeout == pytest.approx(7.5)
422+
423+
finally:
424+
be.close()

0 commit comments

Comments
 (0)