pyobs-fli 2.0.0.dev10__tar.gz → 2.0.0.dev11__tar.gz

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.
Files changed (81) hide show
  1. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev11}/PKG-INFO +5 -2
  2. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev11}/pyobs_fli/flibase.py +7 -1
  3. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev11}/pyobs_fli/flicamera.py +8 -4
  4. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev11}/pyobs_fli/flidriver.pyx +14 -5
  5. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev11}/pyobs_fli/flifilterwheel.py +2 -4
  6. pyobs_fli-2.0.0.dev11/pyobs_fli/gui.py +229 -0
  7. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev11}/pyproject.toml +12 -2
  8. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev11}/tests/test_flicamera.py +15 -0
  9. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev11}/tests/test_flifilterwheel.py +16 -0
  10. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev11}/uv.lock +557 -15
  11. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev11}/.github/dependabot.yml +0 -0
  12. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev11}/.github/workflows/pypi.yml +0 -0
  13. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev11}/.github/workflows/pyrefly.yml +0 -0
  14. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev11}/.github/workflows/pytest.yml +0 -0
  15. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev11}/.github/workflows/ruff.yml +0 -0
  16. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev11}/.pre-commit-config.yaml +0 -0
  17. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev11}/.readthedocs.yml +0 -0
  18. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev11}/CHANGELOG.rst +0 -0
  19. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev11}/CMakeLists.txt +0 -0
  20. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev11}/LICENSE +0 -0
  21. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev11}/README.md +0 -0
  22. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev11}/docs/Makefile +0 -0
  23. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev11}/docs/make.bat +0 -0
  24. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev11}/docs/requirements.txt +0 -0
  25. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev11}/docs/source/_static/pyobs.gif +0 -0
  26. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev11}/docs/source/conf.py +0 -0
  27. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev11}/docs/source/index.rst +0 -0
  28. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev11}/lib/Makefile +0 -0
  29. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev11}/lib/docxx.sty +0 -0
  30. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev11}/lib/libfli-camera-parport.c +0 -0
  31. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev11}/lib/libfli-camera-parport.h +0 -0
  32. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev11}/lib/libfli-camera-usb.c +0 -0
  33. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev11}/lib/libfli-camera-usb.h +0 -0
  34. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev11}/lib/libfli-camera.c +0 -0
  35. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev11}/lib/libfli-camera.h +0 -0
  36. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev11}/lib/libfli-debug.h +0 -0
  37. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev11}/lib/libfli-filter-focuser.c +0 -0
  38. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev11}/lib/libfli-filter-focuser.h +0 -0
  39. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev11}/lib/libfli-libfli.h +0 -0
  40. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev11}/lib/libfli-mem.c +0 -0
  41. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev11}/lib/libfli-mem.h +0 -0
  42. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev11}/lib/libfli-raw.c +0 -0
  43. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev11}/lib/libfli-raw.h +0 -0
  44. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev11}/lib/libfli.c +0 -0
  45. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev11}/lib/libfli.dxx +0 -0
  46. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev11}/lib/libfli.h +0 -0
  47. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev11}/lib/unix/bsd/libfli-usb-sys.c +0 -0
  48. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev11}/lib/unix/libfli-debug.c +0 -0
  49. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev11}/lib/unix/libfli-parport.h +0 -0
  50. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev11}/lib/unix/libfli-serial.c +0 -0
  51. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev11}/lib/unix/libfli-serial.h +0 -0
  52. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev11}/lib/unix/libfli-sys.c +0 -0
  53. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev11}/lib/unix/libfli-sys.h +0 -0
  54. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev11}/lib/unix/libfli-usb.c +0 -0
  55. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev11}/lib/unix/libfli-usb.h +0 -0
  56. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev11}/lib/unix/linux/fli_ioctl.h +0 -0
  57. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev11}/lib/unix/linux/fliusb_ioctl.h +0 -0
  58. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev11}/lib/unix/linux/libfli-parport.c +0 -0
  59. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev11}/lib/unix/linux/libfli-usb-sys.c +0 -0
  60. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev11}/lib/unix/osx/libfli-usb-sys.c +0 -0
  61. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev11}/lib/unix/osx/libfli-usb-sys.h +0 -0
  62. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev11}/lib/windows/libfli-debug.c +0 -0
  63. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev11}/lib/windows/libfli-parport.h +0 -0
  64. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev11}/lib/windows/libfli-serial.c +0 -0
  65. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev11}/lib/windows/libfli-serial.h +0 -0
  66. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev11}/lib/windows/libfli-sys.h +0 -0
  67. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev11}/lib/windows/libfli-usb.c +0 -0
  68. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev11}/lib/windows/libfli-usb.h +0 -0
  69. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev11}/lib/windows/libfli-windows-parport.c +0 -0
  70. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev11}/lib/windows/libfli-windows.c +0 -0
  71. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev11}/lib/windows/libfli.def +0 -0
  72. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev11}/lib/windows/libfli.rc +0 -0
  73. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev11}/lib/windows/libfli.sln +0 -0
  74. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev11}/lib/windows/libfli.vcproj +0 -0
  75. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev11}/lib/windows/libfli64.def +0 -0
  76. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev11}/lib/windows/resource.h +0 -0
  77. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev11}/poetry.lock +0 -0
  78. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev11}/pyobs_fli/__init__.py +0 -0
  79. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev11}/pyobs_fli/libfli.pxd +0 -0
  80. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev11}/specs/plans/2026-08-16-nogil-fli-driver-calls.md +0 -0
  81. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev11}/tests/test_import.py +0 -0
@@ -1,11 +1,14 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyobs-fli
3
- Version: 2.0.0.dev10
3
+ Version: 2.0.0.dev11
4
4
  Summary: pyobs module for FLI cameras
5
5
  Author-Email: Tim-Oliver Husser <thusser@uni-goettingen.de>
6
6
  License-Expression: MIT
7
7
  Requires-Python: >=3.11
8
8
  Requires-Dist: astropy<9,>=7.0.1
9
9
  Requires-Dist: numpy<3,>=2.2.5
10
- Requires-Dist: pyobs-core<3,>=2.0.0.dev48
10
+ Requires-Dist: pyobs-core<3,>=2.0.0.dev82
11
+ Provides-Extra: gui
12
+ Requires-Dist: pyobs-core[gui]<3,>=2.0.0.dev48; extra == "gui"
13
+ Requires-Dist: qasync>=0.27; extra == "gui"
11
14
 
@@ -52,6 +52,11 @@ class FliBaseMixin:
52
52
  # keep alive
53
53
  self.add_background_task(self._keep_alive) # type: ignore[attr-defined]
54
54
 
55
+ # forward anything left to the next class in the MRO -- lets a class combining this mixin
56
+ # with Module/BaseCamera (which must run first, since add_background_task above needs it)
57
+ # claim its own kwargs cooperatively instead of this mixin silently absorbing them
58
+ super().__init__(**kwargs) # type: ignore[call-arg]
59
+
55
60
  @staticmethod
56
61
  async def _run_blocking(func: Callable[[], None], timeout: float = _SDK_CALL_TIMEOUT) -> bool:
57
62
  """Run a blocking FLI SDK call in a daemon thread, so a hung call can't freeze the module.
@@ -156,13 +161,14 @@ class FliBaseMixin:
156
161
  driver = self._driver
157
162
  try:
158
163
  await self._run_blocking_or_raise(driver.get_serial_string)
159
- except ValueError:
164
+ except (ValueError, OSError):
160
165
  # no? then reopen driver
161
166
  log.warning("Lost connection to camera, reopening it.")
162
167
 
163
168
  def _reopen() -> None:
164
169
  driver.close()
165
170
  self._driver = FliDriver(self._device)
171
+ self._driver.open()
166
172
 
167
173
  await self._run_blocking_or_raise(_reopen)
168
174
 
@@ -24,7 +24,7 @@ log = logging.getLogger(__name__)
24
24
  _READOUT_TIMEOUT = 60.0
25
25
 
26
26
 
27
- class FliCamera(FliBaseMixin, BaseCamera, ICamera, IWindow, IBinning, ICooling, ITemperatures, IAbortable):
27
+ class FliCamera(BaseCamera, FliBaseMixin, ICamera, IWindow, IBinning, ICooling, ITemperatures, IAbortable):
28
28
  """A pyobs module for FLI cameras."""
29
29
 
30
30
  __module__ = "pyobs_fli"
@@ -35,8 +35,7 @@ class FliCamera(FliBaseMixin, BaseCamera, ICamera, IWindow, IBinning, ICooling,
35
35
  Args:
36
36
  setpoint: Cooling temperature setpoint.
37
37
  """
38
- BaseCamera.__init__(self, **kwargs)
39
- FliBaseMixin.__init__(self, dev_type=DeviceType.CAMERA, **kwargs)
38
+ super().__init__(dev_type=DeviceType.CAMERA, **kwargs)
40
39
 
41
40
  self._temp_setpoint: float | None = setpoint
42
41
  self._cooling_enabled = False
@@ -159,7 +158,12 @@ class FliCamera(FliBaseMixin, BaseCamera, ICamera, IWindow, IBinning, ICooling,
159
158
  img[row, :] = driver.grab_row(width)
160
159
  return img
161
160
 
162
- img = await self._run_blocking_or_raise(_readout, timeout=_READOUT_TIMEOUT)
161
+ try:
162
+ img = await self._run_blocking_or_raise(_readout, timeout=_READOUT_TIMEOUT)
163
+ except Exception:
164
+ log.error("Readout failed, cancelling exposure.")
165
+ await self._abort_exposure()
166
+ raise
163
167
 
164
168
  def _get_headers() -> tuple[float, float, tuple[int, int, int, int]]:
165
169
  return (
@@ -8,6 +8,8 @@ import numpy as np
8
8
  cimport numpy as np
9
9
  np.import_array()
10
10
 
11
+ from libc.string cimport memset
12
+
11
13
  from .libfli cimport *
12
14
 
13
15
 
@@ -447,18 +449,22 @@ cdef class FliDriver:
447
449
  """
448
450
 
449
451
  # variables
450
- cdef char *model
451
- cdef size_t len
452
+ cdef char model[1024]
452
453
  cdef long res
453
454
 
455
+ # zero the buffer so FLIGetModel's NUL-terminated string is followed by known NULs, and
456
+ # bytes(model) (which uses the full 1024-byte static length, not the NUL) can be truncated
457
+ # safely below
458
+ memset(model, 0, sizeof(model))
459
+
454
460
  # get it
455
461
  with nogil:
456
- res = FLIGetModel(self._device, model, len)
462
+ res = FLIGetModel(self._device, <char*>model, 1024)
457
463
  if res != 0:
458
464
  raise ValueError('Could not fetch model.')
459
465
 
460
466
  # return it
461
- return str(model)
467
+ return bytes(model).split(b'\x00')[0].decode('utf-8')
462
468
 
463
469
  def get_serial_string(self) -> str:
464
470
  """Returns serial string for camera."""
@@ -467,6 +473,9 @@ cdef class FliDriver:
467
473
  cdef char serial[1024]
468
474
  cdef long res
469
475
 
476
+ # zero the buffer (same NUL-truncation reasoning as get_model above)
477
+ memset(serial, 0, sizeof(serial))
478
+
470
479
  # get it
471
480
  with nogil:
472
481
  res = FLIGetSerialString(self._device, <char*>serial, 1024)
@@ -474,7 +483,7 @@ cdef class FliDriver:
474
483
  raise ValueError('Could not fetch serial string.')
475
484
 
476
485
  # return it
477
- return bytes(serial).decode('utf-8')
486
+ return bytes(serial).split(b'\x00')[0].decode('utf-8')
478
487
 
479
488
  def get_filter_pos(self) -> int:
480
489
  """Returns current filter position.
@@ -17,7 +17,7 @@ from pyobs_fli.flidriver import DeviceType
17
17
  log = logging.getLogger(__name__)
18
18
 
19
19
 
20
- class FliFilterWheel(FliBaseMixin, Module, MotionStatusMixin, IFilters, IFitsHeaderBefore):
20
+ class FliFilterWheel(Module, FliBaseMixin, MotionStatusMixin, IFilters, IFitsHeaderBefore):
21
21
  """A pyobs module for FLI filter wheels."""
22
22
 
23
23
  __module__ = "pyobs_fli"
@@ -28,9 +28,7 @@ class FliFilterWheel(FliBaseMixin, Module, MotionStatusMixin, IFilters, IFitsHea
28
28
  Args:
29
29
  filter_names: Names of filters.
30
30
  """
31
- Module.__init__(self, **kwargs)
32
- FliBaseMixin.__init__(self, dev_type=DeviceType.FILTERWHEEL, **kwargs)
33
- MotionStatusMixin.__init__(self, motion_status_interfaces=["IFilters"])
31
+ super().__init__(dev_type=DeviceType.FILTERWHEEL, motion_status_interfaces=["IFilters"], **kwargs)
34
32
 
35
33
  self._filter_names: list[list[str]] = (
36
34
  [filter_names] if isinstance(filter_names[0], str) else filter_names # type: ignore[arg-type,list-item]
@@ -0,0 +1,229 @@
1
+ """Standalone PySide6 GUI driving FliDriver directly, for testing a FLI camera without a full
2
+ pyobs module.
3
+
4
+ Unverified against real FLI hardware.
5
+ """
6
+
7
+ import asyncio
8
+ import sys
9
+ import threading
10
+ import time
11
+
12
+ import numpy as np
13
+ import qasync # type: ignore[import-untyped]
14
+ from astropy.io import fits
15
+ from pyobs.utils.enums import ImageFormat
16
+ from pyobs.utils.gui.camera import (
17
+ BinningWidget,
18
+ DataDisplayWidget,
19
+ ExposeWidget,
20
+ ExposureTimeWidget,
21
+ ImageFormatWidget,
22
+ ListPickerDialog,
23
+ )
24
+ from pyobs.utils.gui.camera.windowingwidget import WindowingWidget
25
+ from PySide6 import QtCore, QtWidgets # type: ignore[import-untyped]
26
+
27
+ from .flidriver import FliDriver, FliTemperature # type: ignore[import-untyped]
28
+
29
+
30
+ class MainWindow(QtWidgets.QMainWindow):
31
+ def __init__(self, driver: FliDriver, binning, full_frame) -> None:
32
+ super().__init__()
33
+ self.setWindowTitle("FLI Camera")
34
+
35
+ # the driver is opened by async_main() before this window is built, so __init__ stays
36
+ # free of blocking SDK calls
37
+ self._driver = driver
38
+ self._sdk_lock = threading.Lock()
39
+ self._abort_event = asyncio.Event()
40
+ self._exposing = False
41
+ self._closing = False
42
+
43
+ central = QtWidgets.QWidget()
44
+ self.setCentralWidget(central)
45
+ global_layout = QtWidgets.QHBoxLayout(central)
46
+
47
+ controls = QtWidgets.QGroupBox("Controls")
48
+ global_layout.addWidget(controls)
49
+ layout = QtWidgets.QVBoxLayout(controls)
50
+
51
+ self._window_widget = WindowingWidget(full_frame[2], full_frame[3])
52
+ layout.addWidget(self._window_widget)
53
+
54
+ binnings = [(1, 1), (2, 2), (3, 3), (4, 4)]
55
+ self._binning_widget = BinningWidget(binnings)
56
+ self._binning_widget.binning_changed.connect(self._window_widget.set_binning)
57
+ if binning in binnings:
58
+ self._binning_widget.combo_binnings.setCurrentIndex(binnings.index(binning))
59
+ layout.addWidget(self._binning_widget)
60
+
61
+ self._format_widget = ImageFormatWidget([ImageFormat.INT16])
62
+ layout.addWidget(self._format_widget)
63
+
64
+ self._exposure_time = ExposureTimeWidget()
65
+ layout.addWidget(self._exposure_time)
66
+
67
+ self._expose_widget = ExposeWidget()
68
+ self._expose_widget.expose_clicked.connect(self._expose_clicked)
69
+ self._expose_widget.abort_clicked.connect(self._abort_clicked)
70
+ layout.addWidget(self._expose_widget)
71
+
72
+ temp_group = QtWidgets.QGroupBox("Temperature")
73
+ temp_layout = QtWidgets.QFormLayout(temp_group)
74
+ self._label_ccd = QtWidgets.QLabel("—")
75
+ self._label_base = QtWidgets.QLabel("—")
76
+ self._label_power = QtWidgets.QLabel("—")
77
+ temp_layout.addRow("CCD:", self._label_ccd)
78
+ temp_layout.addRow("Base:", self._label_base)
79
+ temp_layout.addRow("Cooler:", self._label_power)
80
+ layout.addWidget(temp_group)
81
+ layout.addStretch()
82
+
83
+ self._data_display = DataDisplayWidget()
84
+ global_layout.addWidget(self._data_display)
85
+
86
+ self._temp_timer = QtCore.QTimer()
87
+ self._temp_timer.timeout.connect(self._refresh_temp)
88
+ self._temp_timer.start(5000)
89
+ asyncio.ensure_future(self._refresh_temp())
90
+
91
+ def _run_blocking(self, func):
92
+ """Run a blocking driver call on the default executor, serialized behind self._sdk_lock."""
93
+ loop = asyncio.get_running_loop()
94
+
95
+ def _wrapper():
96
+ with self._sdk_lock:
97
+ return func()
98
+
99
+ return loop.run_in_executor(None, _wrapper)
100
+
101
+ @qasync.asyncSlot() # type: ignore[misc]
102
+ async def _refresh_temp(self) -> None:
103
+ if self._exposing or self._closing:
104
+ return
105
+ try:
106
+ ccd, base, power = await self._run_blocking(
107
+ lambda: (
108
+ self._driver.get_temp(FliTemperature.CCD),
109
+ self._driver.get_temp(FliTemperature.BASE),
110
+ self._driver.get_cooler_power(),
111
+ )
112
+ )
113
+ self._label_ccd.setText(f"{ccd:.1f} °C")
114
+ self._label_base.setText(f"{base:.1f} °C")
115
+ self._label_power.setText(f"{power:.0f} %")
116
+ except Exception:
117
+ pass
118
+
119
+ @qasync.asyncSlot(int) # type: ignore[misc]
120
+ async def _expose_clicked(self, count: int) -> None:
121
+ left, top, width, height = self._window_widget.values
122
+ idx = self._binning_widget.combo_binnings.currentIndex()
123
+ xbin, ybin = self._binning_widget._binnings[idx] # noqa: SLF001
124
+ exposure_time = self._exposure_time.value
125
+
126
+ def _prepare() -> None:
127
+ self._driver.set_binning(xbin, ybin)
128
+ self._driver.set_window(left * xbin, top * ybin, width, height)
129
+ self._driver.init_exposure(True)
130
+ self._driver.set_exposure_time(int(exposure_time * 1000.0))
131
+
132
+ def _readout() -> np.ndarray:
133
+ img = np.zeros((height, width), dtype=np.uint16)
134
+ for row in range(height):
135
+ img[row, :] = self._driver.grab_row(width)
136
+ return img
137
+
138
+ self._exposing = True
139
+ try:
140
+ for i in range(count):
141
+ if self._abort_event.is_set():
142
+ break
143
+
144
+ self._expose_widget.start_exposure(exposure_time)
145
+ await self._run_blocking(_prepare)
146
+ await self._run_blocking(self._driver.start_exposure)
147
+
148
+ if await self._wait_exposure():
149
+ await self._run_blocking(self._driver.cancel_exposure)
150
+ break
151
+
152
+ data = await self._run_blocking(_readout)
153
+
154
+ image = fits.PrimaryHDU(data)
155
+ image.header["EXPTIME"] = (exposure_time, "Exposure time [s]")
156
+ image.header["XBINNING"] = (xbin, "Binning factor used on X axis")
157
+ image.header["YBINNING"] = (ybin, "Binning factor used on Y axis")
158
+ self._data_display.set_data(image)
159
+ self._expose_widget.set_exposures_left(count - i - 1)
160
+
161
+ self._expose_widget.set_exposures_left(0)
162
+ self._abort_event.clear()
163
+ finally:
164
+ self._exposing = False
165
+
166
+ async def _wait_exposure(self) -> bool:
167
+ """Return True if the exposure was aborted, False if data is ready."""
168
+
169
+ def _wait() -> bool:
170
+ while True:
171
+ if self._abort_event.is_set():
172
+ return True
173
+ if self._driver.is_data_ready():
174
+ return False
175
+ time.sleep(0.01)
176
+
177
+ return await self._run_blocking(_wait)
178
+
179
+ def _abort_clicked(self) -> None:
180
+ self._abort_event.set()
181
+
182
+ def closeEvent(self, event) -> None: # type: ignore[override]
183
+ self._temp_timer.stop()
184
+ # signal an in-flight exposure to stop, then wait for any SDK call in progress and close
185
+ self._closing = True
186
+ self._abort_event.set()
187
+ with self._sdk_lock:
188
+ self._driver.close()
189
+ super().closeEvent(event)
190
+
191
+
192
+ async def async_main(app: QtWidgets.QApplication) -> None:
193
+ devices = FliDriver.list_devices()
194
+ if not devices:
195
+ QtWidgets.QMessageBox.critical(None, "Error", "No FLI camera found.")
196
+ return
197
+
198
+ if len(devices) > 1:
199
+ picker = ListPickerDialog([d.name.decode("utf-8") for d in devices])
200
+ if picker.exec() != QtWidgets.QDialog.DialogCode.Accepted:
201
+ return
202
+ device = devices[picker.comboBox().currentIndex()]
203
+ else:
204
+ device = devices[0]
205
+
206
+ # open the driver off the event loop before building the window, so MainWindow.__init__ stays
207
+ # free of blocking SDK calls
208
+ loop = asyncio.get_running_loop()
209
+ driver = await loop.run_in_executor(None, FliDriver, device)
210
+ await loop.run_in_executor(None, driver.open)
211
+ _, binning = await loop.run_in_executor(None, driver.get_window_binning)
212
+ full_frame = await loop.run_in_executor(None, driver.get_full_frame)
213
+
214
+ app_close_event = asyncio.Event()
215
+ app.aboutToQuit.connect(app_close_event.set)
216
+
217
+ window = MainWindow(driver, binning, full_frame)
218
+ window.show()
219
+
220
+ await app_close_event.wait()
221
+
222
+
223
+ def main() -> None:
224
+ app = QtWidgets.QApplication(sys.argv)
225
+ asyncio.run(async_main(app), loop_factory=qasync.QEventLoop) # type: ignore[arg-type]
226
+
227
+
228
+ if __name__ == "__main__":
229
+ main()
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pyobs-fli"
3
- version = "2.0.0.dev10"
3
+ version = "2.0.0.dev11"
4
4
  description = "pyobs module for FLI cameras"
5
5
  authors = [{ name = "Tim-Oliver Husser", email = "thusser@uni-goettingen.de" }]
6
6
  requires-python = ">=3.11"
@@ -8,7 +8,13 @@ license = "MIT"
8
8
  dependencies = [
9
9
  "astropy>=7.0.1,<9",
10
10
  "numpy>=2.2.5,<3",
11
- "pyobs-core>=2.0.0.dev48,<3",
11
+ "pyobs-core>=2.0.0.dev82,<3",
12
+ ]
13
+
14
+ [project.optional-dependencies]
15
+ gui = [
16
+ "pyobs-core[gui]>=2.0.0.dev48,<3",
17
+ "qasync>=0.27",
12
18
  ]
13
19
 
14
20
  [dependency-groups]
@@ -21,6 +27,9 @@ dev = [
21
27
  "pytest-asyncio>=0.24.0",
22
28
  ]
23
29
 
30
+ [project.scripts]
31
+ fli-gui = "pyobs_fli.gui:main"
32
+
24
33
  [tool.scikit-build]
25
34
  wheel.packages = ["pyobs_fli"]
26
35
 
@@ -51,6 +60,7 @@ select = ["E", "F", "W", "I", "UP", "G"]
51
60
  [tool.pyrefly]
52
61
  python-version = "3.11"
53
62
  ignore-missing-imports = []
63
+ project-excludes = ["pyobs_fli/gui.py"]
54
64
 
55
65
  [tool.pytest.ini_options]
56
66
  asyncio_mode = "strict"
@@ -23,6 +23,21 @@ def test_constructor_defaults() -> None:
23
23
  assert camera._driver is None
24
24
 
25
25
 
26
+ def test_constructor_threads_fli_kwargs_cooperatively() -> None:
27
+ """Regression test for the cooperative-super()-chain fix: FliCamera(BaseCamera, FliBaseMixin,
28
+ ...) used to call BaseCamera.__init__ and FliBaseMixin.__init__ explicitly with the same
29
+ unfiltered kwargs -- FliBaseMixin-only kwargs like dev_path must still reach FliBaseMixin
30
+ through the single super().__init__() call, not get lost or leak to object.__init__()."""
31
+ from pyobs_fli.flidriver import DeviceType
32
+
33
+ camera = FliCamera(dev_name="cam1", dev_path="/dev/fliusb0", keep_alive_ping=5)
34
+ assert camera._dev_type == DeviceType.CAMERA
35
+ assert camera._dev_name == "cam1"
36
+ assert camera._dev_path == "/dev/fliusb0"
37
+ assert camera._keep_alive_ping == 5
38
+ assert camera._driver is None
39
+
40
+
26
41
  @pytest.mark.asyncio
27
42
  async def test_set_window() -> None:
28
43
  camera = FliCamera()
@@ -19,6 +19,22 @@ def test_constructor_single_wheel() -> None:
19
19
  assert wheel._filter_names == [["A", "B", "C"]]
20
20
 
21
21
 
22
+ def test_constructor_threads_fli_kwargs_cooperatively() -> None:
23
+ """Regression test for the cooperative-super()-chain fix: FliFilterWheel(Module, FliBaseMixin,
24
+ MotionStatusMixin, ...) used to call Module.__init__, FliBaseMixin.__init__ and
25
+ MotionStatusMixin.__init__ explicitly with the same unfiltered kwargs -- live pyobs-monet
26
+ configs set dev_path explicitly, which must still reach FliBaseMixin through the single
27
+ super().__init__() call, not get lost or leak to object.__init__()."""
28
+ from pyobs_fli.flidriver import DeviceType
29
+
30
+ wheel = FliFilterWheel(filter_names=["A", "B", "C"], dev_path="/dev/fliusb0", keep_alive_ping=5)
31
+ assert wheel._dev_type == DeviceType.FILTERWHEEL
32
+ assert wheel._dev_path == "/dev/fliusb0"
33
+ assert wheel._keep_alive_ping == 5
34
+ assert wheel._driver is None
35
+ assert getattr(wheel, "_MotionStatusMixin__motion_status_interfaces") == ["IFilters"]
36
+
37
+
22
38
  def test_constructor_two_wheels() -> None:
23
39
  wheel = FliFilterWheel(filter_names=_TWO_WHEELS)
24
40
  assert wheel._filter_names == _TWO_WHEELS