pyobs-fli 2.0.0.dev10__tar.gz → 2.0.0.dev12__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 (82) hide show
  1. pyobs_fli-2.0.0.dev12/.github/workflows/dependabot-automerge.yml +24 -0
  2. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/PKG-INFO +5 -2
  3. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/pyobs_fli/flibase.py +31 -6
  4. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/pyobs_fli/flicamera.py +8 -4
  5. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/pyobs_fli/flidriver.pyx +14 -5
  6. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/pyobs_fli/flifilterwheel.py +32 -6
  7. pyobs_fli-2.0.0.dev12/pyobs_fli/gui.py +229 -0
  8. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/pyproject.toml +12 -2
  9. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/tests/test_flicamera.py +17 -2
  10. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/tests/test_flifilterwheel.py +24 -4
  11. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/uv.lock +557 -15
  12. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/.github/dependabot.yml +0 -0
  13. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/.github/workflows/pypi.yml +0 -0
  14. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/.github/workflows/pyrefly.yml +0 -0
  15. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/.github/workflows/pytest.yml +0 -0
  16. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/.github/workflows/ruff.yml +0 -0
  17. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/.pre-commit-config.yaml +0 -0
  18. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/.readthedocs.yml +0 -0
  19. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/CHANGELOG.rst +0 -0
  20. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/CMakeLists.txt +0 -0
  21. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/LICENSE +0 -0
  22. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/README.md +0 -0
  23. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/docs/Makefile +0 -0
  24. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/docs/make.bat +0 -0
  25. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/docs/requirements.txt +0 -0
  26. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/docs/source/_static/pyobs.gif +0 -0
  27. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/docs/source/conf.py +0 -0
  28. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/docs/source/index.rst +0 -0
  29. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/Makefile +0 -0
  30. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/docxx.sty +0 -0
  31. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/libfli-camera-parport.c +0 -0
  32. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/libfli-camera-parport.h +0 -0
  33. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/libfli-camera-usb.c +0 -0
  34. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/libfli-camera-usb.h +0 -0
  35. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/libfli-camera.c +0 -0
  36. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/libfli-camera.h +0 -0
  37. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/libfli-debug.h +0 -0
  38. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/libfli-filter-focuser.c +0 -0
  39. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/libfli-filter-focuser.h +0 -0
  40. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/libfli-libfli.h +0 -0
  41. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/libfli-mem.c +0 -0
  42. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/libfli-mem.h +0 -0
  43. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/libfli-raw.c +0 -0
  44. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/libfli-raw.h +0 -0
  45. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/libfli.c +0 -0
  46. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/libfli.dxx +0 -0
  47. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/libfli.h +0 -0
  48. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/unix/bsd/libfli-usb-sys.c +0 -0
  49. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/unix/libfli-debug.c +0 -0
  50. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/unix/libfli-parport.h +0 -0
  51. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/unix/libfli-serial.c +0 -0
  52. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/unix/libfli-serial.h +0 -0
  53. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/unix/libfli-sys.c +0 -0
  54. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/unix/libfli-sys.h +0 -0
  55. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/unix/libfli-usb.c +0 -0
  56. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/unix/libfli-usb.h +0 -0
  57. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/unix/linux/fli_ioctl.h +0 -0
  58. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/unix/linux/fliusb_ioctl.h +0 -0
  59. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/unix/linux/libfli-parport.c +0 -0
  60. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/unix/linux/libfli-usb-sys.c +0 -0
  61. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/unix/osx/libfli-usb-sys.c +0 -0
  62. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/unix/osx/libfli-usb-sys.h +0 -0
  63. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/windows/libfli-debug.c +0 -0
  64. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/windows/libfli-parport.h +0 -0
  65. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/windows/libfli-serial.c +0 -0
  66. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/windows/libfli-serial.h +0 -0
  67. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/windows/libfli-sys.h +0 -0
  68. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/windows/libfli-usb.c +0 -0
  69. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/windows/libfli-usb.h +0 -0
  70. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/windows/libfli-windows-parport.c +0 -0
  71. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/windows/libfli-windows.c +0 -0
  72. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/windows/libfli.def +0 -0
  73. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/windows/libfli.rc +0 -0
  74. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/windows/libfli.sln +0 -0
  75. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/windows/libfli.vcproj +0 -0
  76. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/windows/libfli64.def +0 -0
  77. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/windows/resource.h +0 -0
  78. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/poetry.lock +0 -0
  79. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/pyobs_fli/__init__.py +0 -0
  80. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/pyobs_fli/libfli.pxd +0 -0
  81. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/specs/plans/2026-08-16-nogil-fli-driver-calls.md +0 -0
  82. {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/tests/test_import.py +0 -0
@@ -0,0 +1,24 @@
1
+ name: Dependabot auto-merge
2
+
3
+ on: pull_request
4
+
5
+ permissions:
6
+ contents: write
7
+ pull-requests: write
8
+
9
+ jobs:
10
+ dependabot:
11
+ runs-on: ubuntu-latest
12
+ if: ${{ github.event.pull_request.user.login == 'dependabot[bot]' }}
13
+ steps:
14
+ - name: Fetch Dependabot metadata
15
+ id: metadata
16
+ uses: dependabot/fetch-metadata@v2
17
+ with:
18
+ compat-lookup: true
19
+ - name: Enable auto-merge for patch and minor updates
20
+ if: ${{ steps.metadata.outputs.update-type == 'version-update:semver-patch' || steps.metadata.outputs.update-type == 'version-update:semver-minor' }}
21
+ run: gh pr merge --auto --merge "$PR_URL"
22
+ env:
23
+ PR_URL: ${{ github.event.pull_request.html_url }}
24
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -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.dev12
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
 
@@ -27,9 +27,10 @@ class FliBaseMixin:
27
27
  dev_name: str | None = None,
28
28
  dev_path: str | None = None,
29
29
  keep_alive_ping: int = 10,
30
+ sdk_call_timeout: float = _SDK_CALL_TIMEOUT,
30
31
  **kwargs: Any,
31
32
  ):
32
- """Initializes a new FliCamera.
33
+ """Initializes a new FLI device mixin.
33
34
 
34
35
  If neither dev_name nor dev_path are given, the first found device is used.
35
36
 
@@ -38,6 +39,7 @@ class FliBaseMixin:
38
39
  dev_name: Optional name for device.
39
40
  dev_path: Optional path to device.
40
41
  keep_alive_ping: Interval for keep alive ping.
42
+ sdk_call_timeout: Default timeout for blocking FLI SDK calls.
41
43
  """
42
44
  from .flidriver import FliDriver # type: ignore
43
45
 
@@ -46,30 +48,47 @@ class FliBaseMixin:
46
48
  self._dev_name = dev_name
47
49
  self._dev_path = dev_path
48
50
  self._keep_alive_ping = keep_alive_ping
51
+ self._sdk_call_timeout = sdk_call_timeout
49
52
  self._driver: FliDriver | None = None
50
53
  self._device: Any | None = None
51
54
 
52
55
  # keep alive
53
56
  self.add_background_task(self._keep_alive) # type: ignore[attr-defined]
54
57
 
55
- @staticmethod
56
- async def _run_blocking(func: Callable[[], None], timeout: float = _SDK_CALL_TIMEOUT) -> bool:
58
+ # forward anything left to the next class in the MRO -- lets a class combining this mixin
59
+ # with Module/BaseCamera (which must run first, since add_background_task above needs it)
60
+ # claim its own kwargs cooperatively instead of this mixin silently absorbing them
61
+ super().__init__(**kwargs) # type: ignore[call-arg]
62
+
63
+ async def _run_blocking(self, func: Callable[[], None], timeout: float | None = None) -> bool:
57
64
  """Run a blocking FLI SDK call in a daemon thread, so a hung call can't freeze the module.
58
65
 
59
66
  A plain executor isn't used here, since its worker threads are non-daemon and Python joins
60
67
  them on interpreter shutdown -- a hung call would then just move the freeze to process exit.
61
68
 
69
+ Args:
70
+ func: Blocking callable to run off the event loop.
71
+ timeout: Seconds to wait for completion. Defaults to sdk_call_timeout.
72
+
62
73
  Returns:
63
74
  True if func completed within timeout, False if it's still running in the background.
64
75
  """
76
+ timeout = self._sdk_call_timeout if timeout is None else timeout
65
77
  loop = asyncio.get_running_loop()
66
78
  future: asyncio.Future[None] = loop.create_future()
67
79
 
80
+ def _complete() -> None:
81
+ # The worker thread may outlive the timeout (wait_for cancels the future on
82
+ # timeout), so guard against a late set_result on an already-completed future --
83
+ # otherwise the loop callback raises InvalidStateError.
84
+ if not future.done():
85
+ future.set_result(None)
86
+
68
87
  def _wrapper() -> None:
69
88
  try:
70
89
  func()
71
90
  finally:
72
- loop.call_soon_threadsafe(future.set_result, None)
91
+ loop.call_soon_threadsafe(_complete)
73
92
 
74
93
  threading.Thread(target=_wrapper, daemon=True).start()
75
94
  try:
@@ -78,14 +97,19 @@ class FliBaseMixin:
78
97
  except TimeoutError:
79
98
  return False
80
99
 
81
- async def _run_blocking_or_raise(self, func: Callable[[], _T], timeout: float = _SDK_CALL_TIMEOUT) -> _T:
100
+ async def _run_blocking_or_raise(self, func: Callable[[], _T], timeout: float | None = None) -> _T:
82
101
  """Run a blocking FLI SDK call in a thread, returning its result or re-raising what it raised.
83
102
 
84
103
  Unlike _run_blocking(), this also carries the callable's return value/exception back to the
85
104
  caller -- several FLI calls drive control flow via their return value or a raised ValueError
86
105
  (e.g. device enumeration, readout), which a bare fire-and-forget thread call would otherwise
87
106
  silently lose.
107
+
108
+ Args:
109
+ func: Blocking callable to run off the event loop.
110
+ timeout: Seconds to wait for completion. Defaults to sdk_call_timeout.
88
111
  """
112
+ timeout = self._sdk_call_timeout if timeout is None else timeout
89
113
  outcome: list[Any] = []
90
114
 
91
115
  def _wrapper() -> None:
@@ -156,13 +180,14 @@ class FliBaseMixin:
156
180
  driver = self._driver
157
181
  try:
158
182
  await self._run_blocking_or_raise(driver.get_serial_string)
159
- except ValueError:
183
+ except (ValueError, OSError):
160
184
  # no? then reopen driver
161
185
  log.warning("Lost connection to camera, reopening it.")
162
186
 
163
187
  def _reopen() -> None:
164
188
  driver.close()
165
189
  self._driver = FliDriver(self._device)
190
+ self._driver.open()
166
191
 
167
192
  await self._run_blocking_or_raise(_reopen)
168
193
 
@@ -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.
@@ -16,26 +16,36 @@ from pyobs_fli.flidriver import DeviceType
16
16
 
17
17
  log = logging.getLogger(__name__)
18
18
 
19
+ # A physical filter move can take far longer than a typical SDK call (a long rotation
20
+ # can be several seconds), so give it its own generous, configurable deadline instead of
21
+ # the short SDK-call default.
22
+ _FILTER_MOVE_TIMEOUT = 120.0
19
23
 
20
- class FliFilterWheel(FliBaseMixin, Module, MotionStatusMixin, IFilters, IFitsHeaderBefore):
24
+
25
+ class FliFilterWheel(Module, FliBaseMixin, MotionStatusMixin, IFilters, IFitsHeaderBefore):
21
26
  """A pyobs module for FLI filter wheels."""
22
27
 
23
28
  __module__ = "pyobs_fli"
24
29
 
25
- def __init__(self, filter_names: list[str] | list[list[str]], **kwargs: Any):
30
+ def __init__(
31
+ self,
32
+ filter_names: list[str] | list[list[str]],
33
+ filter_move_timeout: float = _FILTER_MOVE_TIMEOUT,
34
+ **kwargs: Any,
35
+ ):
26
36
  """Initializes a new FliFilterWheel.
27
37
 
28
38
  Args:
29
39
  filter_names: Names of filters.
40
+ filter_move_timeout: Seconds to wait for a filter move to complete.
30
41
  """
31
- Module.__init__(self, **kwargs)
32
- FliBaseMixin.__init__(self, dev_type=DeviceType.FILTERWHEEL, **kwargs)
33
- MotionStatusMixin.__init__(self, motion_status_interfaces=["IFilters"])
42
+ super().__init__(dev_type=DeviceType.FILTERWHEEL, motion_status_interfaces=["IFilters"], **kwargs)
34
43
 
35
44
  self._filter_names: list[list[str]] = (
36
45
  [filter_names] if isinstance(filter_names[0], str) else filter_names # type: ignore[arg-type,list-item]
37
46
  )
38
47
 
48
+ self._filter_move_timeout = filter_move_timeout
39
49
  self._current_filter = ""
40
50
 
41
51
  async def open(self) -> None:
@@ -97,7 +107,23 @@ class FliFilterWheel(FliBaseMixin, Module, MotionStatusMixin, IFilters, IFitsHea
97
107
  def _set() -> None:
98
108
  driver.set_filter_pos(pos)
99
109
 
100
- await self._run_blocking_or_raise(_set)
110
+ try:
111
+ # A physical filter move can legitimately take far longer than a typical SDK
112
+ # call, so bound it with a dedicated, configurable move timeout rather than the
113
+ # short SDK-call default -- which cut off long rotations mid-move.
114
+ await self._run_blocking_or_raise(_set, timeout=self._filter_move_timeout)
115
+
116
+ # Confirm the wheel actually reports the requested position before declaring
117
+ # success, so an SDK call that returned without the wheel having arrived can't
118
+ # leave the published filter state silently wrong.
119
+ actual = await self._run_blocking_or_raise(driver.get_filter_pos)
120
+ if actual != pos:
121
+ raise exc.MoveError(f"Filter wheel reported position {actual} after moving to {pos}.")
122
+ except Exception:
123
+ # Don't leave the wheel stuck reporting "slewing" after a failed move.
124
+ await self._change_motion_status(MotionStatus.ERROR)
125
+ raise
126
+
101
127
  self._current_filter = filter_name
102
128
  await self._change_motion_status(MotionStatus.POSITIONED)
103
129
  await self.comm.send_event(FilterChangedEvent(filter_name))
@@ -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.dev12"
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()
@@ -58,7 +73,7 @@ async def test_run_blocking_runs_func_and_returns_true() -> None:
58
73
  def fast() -> None:
59
74
  ran.append(True)
60
75
 
61
- assert await FliCamera._run_blocking(fast) is True
76
+ assert await FliCamera()._run_blocking(fast) is True
62
77
  assert ran == [True]
63
78
 
64
79
 
@@ -69,7 +84,7 @@ async def test_run_blocking_times_out() -> None:
69
84
  def slow() -> None:
70
85
  done.wait()
71
86
 
72
- assert await FliCamera._run_blocking(slow, timeout=0.01) is False
87
+ assert await FliCamera()._run_blocking(slow, timeout=0.01) is False
73
88
  done.set()
74
89
  await asyncio.sleep(0.05)
75
90
 
@@ -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
@@ -45,14 +61,16 @@ async def test_set_filter_first_wheel() -> None:
45
61
  wheel = FliFilterWheel(filter_names=_TWO_WHEELS)
46
62
  wheel._driver = MagicMock()
47
63
  wheel._driver.set_filter_pos = MagicMock()
64
+ wheel._driver.get_filter_pos = MagicMock(return_value=0)
48
65
 
49
- async def fake_run(func, timeout: float = 5.0) -> None:
50
- func()
66
+ async def fake_run(func, timeout: float | None = None) -> object:
67
+ return func()
51
68
 
52
69
  wheel._run_blocking_or_raise = fake_run # type: ignore[method-assign]
53
70
 
54
71
  await wheel.set_filter("A")
55
72
  wheel._driver.set_filter_pos.assert_called_once_with(0)
73
+ wheel._driver.get_filter_pos.assert_called_once()
56
74
  assert wheel._current_filter == "A"
57
75
 
58
76
 
@@ -61,14 +79,16 @@ async def test_set_filter_second_wheel() -> None:
61
79
  wheel = FliFilterWheel(filter_names=_TWO_WHEELS)
62
80
  wheel._driver = MagicMock()
63
81
  wheel._driver.set_filter_pos = MagicMock()
82
+ wheel._driver.get_filter_pos = MagicMock(return_value=7)
64
83
 
65
- async def fake_run(func, timeout: float = 5.0) -> None:
66
- func()
84
+ async def fake_run(func, timeout: float | None = None) -> object:
85
+ return func()
67
86
 
68
87
  wheel._run_blocking_or_raise = fake_run # type: ignore[method-assign]
69
88
 
70
89
  await wheel.set_filter("H")
71
90
  wheel._driver.set_filter_pos.assert_called_once_with(7)
91
+ wheel._driver.get_filter_pos.assert_called_once()
72
92
  assert wheel._current_filter == "H"
73
93
 
74
94