pyobs-fli 2.0.0.dev9__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.dev9 → pyobs_fli-2.0.0.dev11}/.github/dependabot.yml +5 -0
  2. {pyobs_fli-2.0.0.dev9 → pyobs_fli-2.0.0.dev11}/.github/workflows/pypi.yml +1 -1
  3. pyobs_fli-2.0.0.dev11/.github/workflows/pyrefly.yml +26 -0
  4. pyobs_fli-2.0.0.dev11/.github/workflows/pytest.yml +28 -0
  5. {pyobs_fli-2.0.0.dev9 → pyobs_fli-2.0.0.dev11}/PKG-INFO +5 -2
  6. {pyobs_fli-2.0.0.dev9 → pyobs_fli-2.0.0.dev11}/pyobs_fli/flibase.py +7 -1
  7. {pyobs_fli-2.0.0.dev9 → pyobs_fli-2.0.0.dev11}/pyobs_fli/flicamera.py +9 -4
  8. {pyobs_fli-2.0.0.dev9 → pyobs_fli-2.0.0.dev11}/pyobs_fli/flidriver.pyx +14 -5
  9. {pyobs_fli-2.0.0.dev9 → pyobs_fli-2.0.0.dev11}/pyobs_fli/flifilterwheel.py +2 -4
  10. pyobs_fli-2.0.0.dev11/pyobs_fli/gui.py +229 -0
  11. {pyobs_fli-2.0.0.dev9 → pyobs_fli-2.0.0.dev11}/pyproject.toml +17 -2
  12. pyobs_fli-2.0.0.dev11/tests/test_flicamera.py +106 -0
  13. pyobs_fli-2.0.0.dev11/tests/test_flifilterwheel.py +95 -0
  14. pyobs_fli-2.0.0.dev11/tests/test_import.py +29 -0
  15. {pyobs_fli-2.0.0.dev9 → pyobs_fli-2.0.0.dev11}/uv.lock +617 -15
  16. {pyobs_fli-2.0.0.dev9 → pyobs_fli-2.0.0.dev11}/.github/workflows/ruff.yml +0 -0
  17. {pyobs_fli-2.0.0.dev9 → pyobs_fli-2.0.0.dev11}/.pre-commit-config.yaml +0 -0
  18. {pyobs_fli-2.0.0.dev9 → pyobs_fli-2.0.0.dev11}/.readthedocs.yml +0 -0
  19. {pyobs_fli-2.0.0.dev9 → pyobs_fli-2.0.0.dev11}/CHANGELOG.rst +0 -0
  20. {pyobs_fli-2.0.0.dev9 → pyobs_fli-2.0.0.dev11}/CMakeLists.txt +0 -0
  21. {pyobs_fli-2.0.0.dev9 → pyobs_fli-2.0.0.dev11}/LICENSE +0 -0
  22. {pyobs_fli-2.0.0.dev9 → pyobs_fli-2.0.0.dev11}/README.md +0 -0
  23. {pyobs_fli-2.0.0.dev9 → pyobs_fli-2.0.0.dev11}/docs/Makefile +0 -0
  24. {pyobs_fli-2.0.0.dev9 → pyobs_fli-2.0.0.dev11}/docs/make.bat +0 -0
  25. {pyobs_fli-2.0.0.dev9 → pyobs_fli-2.0.0.dev11}/docs/requirements.txt +0 -0
  26. {pyobs_fli-2.0.0.dev9 → pyobs_fli-2.0.0.dev11}/docs/source/_static/pyobs.gif +0 -0
  27. {pyobs_fli-2.0.0.dev9 → pyobs_fli-2.0.0.dev11}/docs/source/conf.py +0 -0
  28. {pyobs_fli-2.0.0.dev9 → pyobs_fli-2.0.0.dev11}/docs/source/index.rst +0 -0
  29. {pyobs_fli-2.0.0.dev9 → pyobs_fli-2.0.0.dev11}/lib/Makefile +0 -0
  30. {pyobs_fli-2.0.0.dev9 → pyobs_fli-2.0.0.dev11}/lib/docxx.sty +0 -0
  31. {pyobs_fli-2.0.0.dev9 → pyobs_fli-2.0.0.dev11}/lib/libfli-camera-parport.c +0 -0
  32. {pyobs_fli-2.0.0.dev9 → pyobs_fli-2.0.0.dev11}/lib/libfli-camera-parport.h +0 -0
  33. {pyobs_fli-2.0.0.dev9 → pyobs_fli-2.0.0.dev11}/lib/libfli-camera-usb.c +0 -0
  34. {pyobs_fli-2.0.0.dev9 → pyobs_fli-2.0.0.dev11}/lib/libfli-camera-usb.h +0 -0
  35. {pyobs_fli-2.0.0.dev9 → pyobs_fli-2.0.0.dev11}/lib/libfli-camera.c +0 -0
  36. {pyobs_fli-2.0.0.dev9 → pyobs_fli-2.0.0.dev11}/lib/libfli-camera.h +0 -0
  37. {pyobs_fli-2.0.0.dev9 → pyobs_fli-2.0.0.dev11}/lib/libfli-debug.h +0 -0
  38. {pyobs_fli-2.0.0.dev9 → pyobs_fli-2.0.0.dev11}/lib/libfli-filter-focuser.c +0 -0
  39. {pyobs_fli-2.0.0.dev9 → pyobs_fli-2.0.0.dev11}/lib/libfli-filter-focuser.h +0 -0
  40. {pyobs_fli-2.0.0.dev9 → pyobs_fli-2.0.0.dev11}/lib/libfli-libfli.h +0 -0
  41. {pyobs_fli-2.0.0.dev9 → pyobs_fli-2.0.0.dev11}/lib/libfli-mem.c +0 -0
  42. {pyobs_fli-2.0.0.dev9 → pyobs_fli-2.0.0.dev11}/lib/libfli-mem.h +0 -0
  43. {pyobs_fli-2.0.0.dev9 → pyobs_fli-2.0.0.dev11}/lib/libfli-raw.c +0 -0
  44. {pyobs_fli-2.0.0.dev9 → pyobs_fli-2.0.0.dev11}/lib/libfli-raw.h +0 -0
  45. {pyobs_fli-2.0.0.dev9 → pyobs_fli-2.0.0.dev11}/lib/libfli.c +0 -0
  46. {pyobs_fli-2.0.0.dev9 → pyobs_fli-2.0.0.dev11}/lib/libfli.dxx +0 -0
  47. {pyobs_fli-2.0.0.dev9 → pyobs_fli-2.0.0.dev11}/lib/libfli.h +0 -0
  48. {pyobs_fli-2.0.0.dev9 → pyobs_fli-2.0.0.dev11}/lib/unix/bsd/libfli-usb-sys.c +0 -0
  49. {pyobs_fli-2.0.0.dev9 → pyobs_fli-2.0.0.dev11}/lib/unix/libfli-debug.c +0 -0
  50. {pyobs_fli-2.0.0.dev9 → pyobs_fli-2.0.0.dev11}/lib/unix/libfli-parport.h +0 -0
  51. {pyobs_fli-2.0.0.dev9 → pyobs_fli-2.0.0.dev11}/lib/unix/libfli-serial.c +0 -0
  52. {pyobs_fli-2.0.0.dev9 → pyobs_fli-2.0.0.dev11}/lib/unix/libfli-serial.h +0 -0
  53. {pyobs_fli-2.0.0.dev9 → pyobs_fli-2.0.0.dev11}/lib/unix/libfli-sys.c +0 -0
  54. {pyobs_fli-2.0.0.dev9 → pyobs_fli-2.0.0.dev11}/lib/unix/libfli-sys.h +0 -0
  55. {pyobs_fli-2.0.0.dev9 → pyobs_fli-2.0.0.dev11}/lib/unix/libfli-usb.c +0 -0
  56. {pyobs_fli-2.0.0.dev9 → pyobs_fli-2.0.0.dev11}/lib/unix/libfli-usb.h +0 -0
  57. {pyobs_fli-2.0.0.dev9 → pyobs_fli-2.0.0.dev11}/lib/unix/linux/fli_ioctl.h +0 -0
  58. {pyobs_fli-2.0.0.dev9 → pyobs_fli-2.0.0.dev11}/lib/unix/linux/fliusb_ioctl.h +0 -0
  59. {pyobs_fli-2.0.0.dev9 → pyobs_fli-2.0.0.dev11}/lib/unix/linux/libfli-parport.c +0 -0
  60. {pyobs_fli-2.0.0.dev9 → pyobs_fli-2.0.0.dev11}/lib/unix/linux/libfli-usb-sys.c +0 -0
  61. {pyobs_fli-2.0.0.dev9 → pyobs_fli-2.0.0.dev11}/lib/unix/osx/libfli-usb-sys.c +0 -0
  62. {pyobs_fli-2.0.0.dev9 → pyobs_fli-2.0.0.dev11}/lib/unix/osx/libfli-usb-sys.h +0 -0
  63. {pyobs_fli-2.0.0.dev9 → pyobs_fli-2.0.0.dev11}/lib/windows/libfli-debug.c +0 -0
  64. {pyobs_fli-2.0.0.dev9 → pyobs_fli-2.0.0.dev11}/lib/windows/libfli-parport.h +0 -0
  65. {pyobs_fli-2.0.0.dev9 → pyobs_fli-2.0.0.dev11}/lib/windows/libfli-serial.c +0 -0
  66. {pyobs_fli-2.0.0.dev9 → pyobs_fli-2.0.0.dev11}/lib/windows/libfli-serial.h +0 -0
  67. {pyobs_fli-2.0.0.dev9 → pyobs_fli-2.0.0.dev11}/lib/windows/libfli-sys.h +0 -0
  68. {pyobs_fli-2.0.0.dev9 → pyobs_fli-2.0.0.dev11}/lib/windows/libfli-usb.c +0 -0
  69. {pyobs_fli-2.0.0.dev9 → pyobs_fli-2.0.0.dev11}/lib/windows/libfli-usb.h +0 -0
  70. {pyobs_fli-2.0.0.dev9 → pyobs_fli-2.0.0.dev11}/lib/windows/libfli-windows-parport.c +0 -0
  71. {pyobs_fli-2.0.0.dev9 → pyobs_fli-2.0.0.dev11}/lib/windows/libfli-windows.c +0 -0
  72. {pyobs_fli-2.0.0.dev9 → pyobs_fli-2.0.0.dev11}/lib/windows/libfli.def +0 -0
  73. {pyobs_fli-2.0.0.dev9 → pyobs_fli-2.0.0.dev11}/lib/windows/libfli.rc +0 -0
  74. {pyobs_fli-2.0.0.dev9 → pyobs_fli-2.0.0.dev11}/lib/windows/libfli.sln +0 -0
  75. {pyobs_fli-2.0.0.dev9 → pyobs_fli-2.0.0.dev11}/lib/windows/libfli.vcproj +0 -0
  76. {pyobs_fli-2.0.0.dev9 → pyobs_fli-2.0.0.dev11}/lib/windows/libfli64.def +0 -0
  77. {pyobs_fli-2.0.0.dev9 → pyobs_fli-2.0.0.dev11}/lib/windows/resource.h +0 -0
  78. {pyobs_fli-2.0.0.dev9 → pyobs_fli-2.0.0.dev11}/poetry.lock +0 -0
  79. {pyobs_fli-2.0.0.dev9 → pyobs_fli-2.0.0.dev11}/pyobs_fli/__init__.py +0 -0
  80. {pyobs_fli-2.0.0.dev9 → pyobs_fli-2.0.0.dev11}/pyobs_fli/libfli.pxd +0 -0
  81. {pyobs_fli-2.0.0.dev9 → pyobs_fli-2.0.0.dev11}/specs/plans/2026-08-16-nogil-fli-driver-calls.md +0 -0
@@ -6,5 +6,10 @@ updates:
6
6
  interval: "weekly"
7
7
  day: "tuesday"
8
8
  target-branch: "develop"
9
+ groups:
10
+ patch-and-minor:
11
+ update-types:
12
+ - "minor"
13
+ - "patch"
9
14
  labels:
10
15
  - "dependencies"
@@ -56,7 +56,7 @@ jobs:
56
56
  - name: Set up Python
57
57
  uses: astral-sh/setup-uv@v5
58
58
  with:
59
- enable-cache: true
59
+ enable-cache: false
60
60
  python-version: "3.13"
61
61
 
62
62
  - name: Publish
@@ -0,0 +1,26 @@
1
+ name: pyrefly
2
+ on: push
3
+
4
+ jobs:
5
+ pyrefly:
6
+ runs-on: ubuntu-latest
7
+ timeout-minutes: 10
8
+
9
+ steps:
10
+ - name: Check out repository code
11
+ uses: actions/checkout@v4
12
+
13
+ - name: Set up Python
14
+ uses: astral-sh/setup-uv@v5
15
+ with:
16
+ enable-cache: true
17
+ python-version: "3.13"
18
+
19
+ - name: Install system dependencies
20
+ run: sudo apt-get install -y libcfitsio-dev
21
+
22
+ - name: Install packages
23
+ run: uv sync --all-extras --dev
24
+
25
+ - name: Run pyrefly
26
+ run: uv run pyrefly check
@@ -0,0 +1,28 @@
1
+ name: PyTest
2
+ on: push
3
+
4
+ jobs:
5
+ test:
6
+ runs-on: ubuntu-latest
7
+ timeout-minutes: 10
8
+
9
+ steps:
10
+ - name: Check out repository code
11
+ uses: actions/checkout@v4
12
+
13
+ - name: Set up Python
14
+ uses: astral-sh/setup-uv@v5
15
+ with:
16
+ enable-cache: true
17
+ python-version: "3.13"
18
+
19
+ - name: Install system dependencies
20
+ run: sudo apt-get install -y libcfitsio-dev
21
+
22
+ - name: Install packages
23
+ run: uv sync --all-extras --dev
24
+
25
+ - name: Run tests
26
+ run: uv run pytest tests
27
+ env:
28
+ CI: true
@@ -1,11 +1,14 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyobs-fli
3
- Version: 2.0.0.dev9
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
@@ -92,6 +91,7 @@ class FliCamera(FliBaseMixin, BaseCamera, ICamera, IWindow, IBinning, ICooling,
92
91
  ),
93
92
  )
94
93
  await self.comm.set_state(IBinning, BinningState(x=self._binning[0], y=self._binning[1]))
94
+ await self.comm.set_state(ITemperatures, TemperaturesState())
95
95
 
96
96
  async def close(self) -> None:
97
97
  """Close the module."""
@@ -158,7 +158,12 @@ class FliCamera(FliBaseMixin, BaseCamera, ICamera, IWindow, IBinning, ICooling,
158
158
  img[row, :] = driver.grab_row(width)
159
159
  return img
160
160
 
161
- 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
162
167
 
163
168
  def _get_headers() -> tuple[float, float, tuple[int, int, int, int]]:
164
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.dev9"
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]
@@ -17,8 +23,13 @@ dev = [
17
23
  "pre-commit>=4.2.0,<5",
18
24
  "ruff>=0.9.0",
19
25
  "pyrefly>=1.1.1",
26
+ "pytest>=8.0.0",
27
+ "pytest-asyncio>=0.24.0",
20
28
  ]
21
29
 
30
+ [project.scripts]
31
+ fli-gui = "pyobs_fli.gui:main"
32
+
22
33
  [tool.scikit-build]
23
34
  wheel.packages = ["pyobs_fli"]
24
35
 
@@ -49,3 +60,7 @@ select = ["E", "F", "W", "I", "UP", "G"]
49
60
  [tool.pyrefly]
50
61
  python-version = "3.11"
51
62
  ignore-missing-imports = []
63
+ project-excludes = ["pyobs_fli/gui.py"]
64
+
65
+ [tool.pytest.ini_options]
66
+ asyncio_mode = "strict"
@@ -0,0 +1,106 @@
1
+ """Unit tests for the non-hardware logic in FliCamera: constructor defaults, the
2
+ window/binning setters, and the _run_blocking/_run_blocking_or_raise thread wrappers.
3
+
4
+ Hardware I/O (opening, exposing, reading out) is out of scope here.
5
+ """
6
+
7
+ import asyncio
8
+ import threading
9
+ from unittest.mock import AsyncMock
10
+
11
+ import pytest
12
+ from pyobs.interfaces import IBinning, IWindow
13
+
14
+ from pyobs_fli import FliCamera
15
+
16
+
17
+ def test_constructor_defaults() -> None:
18
+ camera = FliCamera()
19
+ assert camera._temp_setpoint == -20.0
20
+ assert camera._cooling_enabled is False
21
+ assert camera._window == (0, 0, 0, 0)
22
+ assert camera._binning == (1, 1)
23
+ assert camera._driver is None
24
+
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
+
41
+ @pytest.mark.asyncio
42
+ async def test_set_window() -> None:
43
+ camera = FliCamera()
44
+ camera.comm.set_state = AsyncMock() # type: ignore[method-assign]
45
+
46
+ await camera.set_window(10, 20, 100, 200)
47
+
48
+ assert camera._window == (10, 20, 100, 200)
49
+ assert camera.comm.set_state.await_args is not None
50
+ interface, state = camera.comm.set_state.await_args.args
51
+ assert interface is IWindow
52
+ assert (state.x, state.y, state.width, state.height) == (10, 20, 100, 200)
53
+
54
+
55
+ @pytest.mark.asyncio
56
+ async def test_set_binning() -> None:
57
+ camera = FliCamera()
58
+ camera.comm.set_state = AsyncMock() # type: ignore[method-assign]
59
+
60
+ await camera.set_binning(2, 3)
61
+
62
+ assert camera._binning == (2, 3)
63
+ assert camera.comm.set_state.await_args is not None
64
+ interface, state = camera.comm.set_state.await_args.args
65
+ assert interface is IBinning
66
+ assert (state.x, state.y) == (2, 3)
67
+
68
+
69
+ @pytest.mark.asyncio
70
+ async def test_run_blocking_runs_func_and_returns_true() -> None:
71
+ ran: list[bool] = []
72
+
73
+ def fast() -> None:
74
+ ran.append(True)
75
+
76
+ assert await FliCamera._run_blocking(fast) is True
77
+ assert ran == [True]
78
+
79
+
80
+ @pytest.mark.asyncio
81
+ async def test_run_blocking_times_out() -> None:
82
+ done = threading.Event()
83
+
84
+ def slow() -> None:
85
+ done.wait()
86
+
87
+ assert await FliCamera._run_blocking(slow, timeout=0.01) is False
88
+ done.set()
89
+ await asyncio.sleep(0.05)
90
+
91
+
92
+ @pytest.mark.asyncio
93
+ async def test_run_blocking_or_raise_returns_value() -> None:
94
+ camera = FliCamera()
95
+ assert await camera._run_blocking_or_raise(lambda: 42) == 42
96
+
97
+
98
+ @pytest.mark.asyncio
99
+ async def test_run_blocking_or_raise_reraises() -> None:
100
+ camera = FliCamera()
101
+
102
+ def boom() -> int:
103
+ raise ValueError("boom")
104
+
105
+ with pytest.raises(ValueError):
106
+ await camera._run_blocking_or_raise(boom)