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.
- pyobs_fli-2.0.0.dev12/.github/workflows/dependabot-automerge.yml +24 -0
- {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/PKG-INFO +5 -2
- {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/pyobs_fli/flibase.py +31 -6
- {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/pyobs_fli/flicamera.py +8 -4
- {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/pyobs_fli/flidriver.pyx +14 -5
- {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/pyobs_fli/flifilterwheel.py +32 -6
- pyobs_fli-2.0.0.dev12/pyobs_fli/gui.py +229 -0
- {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/pyproject.toml +12 -2
- {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/tests/test_flicamera.py +17 -2
- {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/tests/test_flifilterwheel.py +24 -4
- {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/uv.lock +557 -15
- {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/.github/dependabot.yml +0 -0
- {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/.github/workflows/pypi.yml +0 -0
- {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/.github/workflows/pyrefly.yml +0 -0
- {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/.github/workflows/pytest.yml +0 -0
- {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/.github/workflows/ruff.yml +0 -0
- {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/.pre-commit-config.yaml +0 -0
- {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/.readthedocs.yml +0 -0
- {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/CHANGELOG.rst +0 -0
- {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/CMakeLists.txt +0 -0
- {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/LICENSE +0 -0
- {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/README.md +0 -0
- {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/docs/Makefile +0 -0
- {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/docs/make.bat +0 -0
- {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/docs/requirements.txt +0 -0
- {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/docs/source/_static/pyobs.gif +0 -0
- {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/docs/source/conf.py +0 -0
- {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/docs/source/index.rst +0 -0
- {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/Makefile +0 -0
- {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/docxx.sty +0 -0
- {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/libfli-camera-parport.c +0 -0
- {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/libfli-camera-parport.h +0 -0
- {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/libfli-camera-usb.c +0 -0
- {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/libfli-camera-usb.h +0 -0
- {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/libfli-camera.c +0 -0
- {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/libfli-camera.h +0 -0
- {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/libfli-debug.h +0 -0
- {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/libfli-filter-focuser.c +0 -0
- {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/libfli-filter-focuser.h +0 -0
- {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/libfli-libfli.h +0 -0
- {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/libfli-mem.c +0 -0
- {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/libfli-mem.h +0 -0
- {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/libfli-raw.c +0 -0
- {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/libfli-raw.h +0 -0
- {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/libfli.c +0 -0
- {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/libfli.dxx +0 -0
- {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/libfli.h +0 -0
- {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/unix/bsd/libfli-usb-sys.c +0 -0
- {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/unix/libfli-debug.c +0 -0
- {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/unix/libfli-parport.h +0 -0
- {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/unix/libfli-serial.c +0 -0
- {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/unix/libfli-serial.h +0 -0
- {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/unix/libfli-sys.c +0 -0
- {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/unix/libfli-sys.h +0 -0
- {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/unix/libfli-usb.c +0 -0
- {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/unix/libfli-usb.h +0 -0
- {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/unix/linux/fli_ioctl.h +0 -0
- {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/unix/linux/fliusb_ioctl.h +0 -0
- {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/unix/linux/libfli-parport.c +0 -0
- {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/unix/linux/libfli-usb-sys.c +0 -0
- {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/unix/osx/libfli-usb-sys.c +0 -0
- {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/unix/osx/libfli-usb-sys.h +0 -0
- {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/windows/libfli-debug.c +0 -0
- {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/windows/libfli-parport.h +0 -0
- {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/windows/libfli-serial.c +0 -0
- {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/windows/libfli-serial.h +0 -0
- {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/windows/libfli-sys.h +0 -0
- {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/windows/libfli-usb.c +0 -0
- {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/windows/libfli-usb.h +0 -0
- {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/windows/libfli-windows-parport.c +0 -0
- {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/windows/libfli-windows.c +0 -0
- {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/windows/libfli.def +0 -0
- {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/windows/libfli.rc +0 -0
- {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/windows/libfli.sln +0 -0
- {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/windows/libfli.vcproj +0 -0
- {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/windows/libfli64.def +0 -0
- {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/lib/windows/resource.h +0 -0
- {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/poetry.lock +0 -0
- {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/pyobs_fli/__init__.py +0 -0
- {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/pyobs_fli/libfli.pxd +0 -0
- {pyobs_fli-2.0.0.dev10 → pyobs_fli-2.0.0.dev12}/specs/plans/2026-08-16-nogil-fli-driver-calls.md +0 -0
- {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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
56
|
-
|
|
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(
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
|
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
|
-
|
|
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__(
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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 =
|
|
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 =
|
|
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
|
|