pyobs-gui 2.0.0.dev1__py3-none-any.whl → 2.0.0.dev2__py3-none-any.whl

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.
@@ -0,0 +1,152 @@
1
+ import logging
2
+ import os
3
+ from typing import Any
4
+
5
+ import qasync # type: ignore
6
+ from PySide6 import QtCore, QtWidgets # type: ignore
7
+
8
+ os.environ["QT_API"] = "PySide6"
9
+ from matplotlib import pyplot as plt
10
+ from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas
11
+ from matplotlib.ticker import MaxNLocator
12
+
13
+ from pyobs.interfaces import (
14
+ AcquisitionAttempt,
15
+ AcquisitionResult,
16
+ AcquisitionState,
17
+ IAcquisition,
18
+ IRunning,
19
+ OffsetFrame,
20
+ )
21
+ from pyobs.interfaces.IRunning import RunningState
22
+ from .base import BaseWidget
23
+ from .qt.acquisitionwidget_ui import Ui_AcquisitionWidget
24
+
25
+ log = logging.getLogger(__name__)
26
+
27
+
28
+ class AcquisitionWidget(BaseWidget, Ui_AcquisitionWidget):
29
+ signal_update_gui = QtCore.Signal()
30
+
31
+ def __init__(self, **kwargs: Any):
32
+ BaseWidget.__init__(self, **kwargs)
33
+ self.setupUi(self) # type: ignore
34
+
35
+ # cached state
36
+ self._attempts: list[AcquisitionAttempt] = []
37
+ self._running = False
38
+ self._result: AcquisitionResult | None = None
39
+
40
+ # add plots: distance vs. attempt, and the 2D offset trajectory
41
+ self.figure, (self.ax, self.ax2) = plt.subplots(1, 2)
42
+ layout = QtWidgets.QVBoxLayout(self.framePlot)
43
+ self.canvas = FigureCanvas(self.figure)
44
+ layout.addWidget(self.canvas)
45
+ self.framePlot.setLayout(layout)
46
+
47
+ # connect signals
48
+ self.signal_update_gui.connect(self.update_gui)
49
+ self.buttonAcquire.clicked.connect(self._acquire)
50
+ self.buttonAbort.clicked.connect(self._abort)
51
+
52
+ # button colors
53
+ self.colorize_button(self.buttonAcquire, QtCore.Qt.GlobalColor.green)
54
+ self.colorize_button(self.buttonAbort, QtCore.Qt.GlobalColor.red)
55
+
56
+ async def _init(self) -> None:
57
+ await self.comm.subscribe_state(self.module, IRunning, self._on_running_state)
58
+ await self.comm.subscribe_state(self.module, IAcquisition, self._on_acquisition_state)
59
+
60
+ # permitted methods (ACLs)
61
+ await self._fetch_permitted_methods()
62
+
63
+ def _on_running_state(self, state: RunningState) -> None:
64
+ if state.running and not self._running:
65
+ # rising edge -> a new run started (possibly triggered elsewhere), clear the plot
66
+ self._attempts = []
67
+ self._result = None
68
+ self._running = state.running
69
+ self.signal_update_gui.emit()
70
+
71
+ def _on_acquisition_state(self, state: AcquisitionState) -> None:
72
+ self._attempts = state.attempts
73
+ self._result = state.result
74
+ self.signal_update_gui.emit()
75
+
76
+ def update_gui(self) -> None:
77
+ self.buttonAcquire.setEnabled(not self._running and self.permitted("acquire_target"))
78
+ self.buttonAbort.setEnabled(self._running and self.permitted("abort"))
79
+ self.labelStatus.setText("Acquiring..." if self._running else "Acquired." if self._result else "Idle")
80
+
81
+ if self._result:
82
+ self.labelResultRa.setText(f"{self._result.ra:.5f}")
83
+ self.labelResultDec.setText(f"{self._result.dec:.5f}")
84
+ self.labelResultAlt.setText(f"{self._result.alt:.3f}")
85
+ self.labelResultAz.setText(f"{self._result.az:.3f}")
86
+ if self._result.offset_frame is not None:
87
+ label = "RA/Dec offset:" if self._result.offset_frame == OffsetFrame.RA_DEC else "Alt/Az offset:"
88
+ self.labelOffsetType.setText(label)
89
+ self.labelResultOffset.setText(f"({self._result.offset_lon:+.5f}, {self._result.offset_lat:+.5f})")
90
+ else:
91
+ self.labelOffsetType.setText("Offset:")
92
+ self.labelResultOffset.setText("")
93
+ else:
94
+ self.labelResultRa.setText("")
95
+ self.labelResultDec.setText("")
96
+ self.labelResultAlt.setText("")
97
+ self.labelResultAz.setText("")
98
+ self.labelOffsetType.setText("Offset:")
99
+ self.labelResultOffset.setText("")
100
+
101
+ self.ax.clear()
102
+ if self._attempts:
103
+ self.ax.plot(
104
+ [a.attempt for a in self._attempts],
105
+ [a.distance for a in self._attempts],
106
+ marker="o",
107
+ color="tab:blue",
108
+ )
109
+ self.ax.set_xlabel("Attempt")
110
+ self.ax.set_ylabel("Distance to target [arcsec]")
111
+ self.ax.xaxis.set_major_locator(MaxNLocator(integer=True))
112
+ self.ax.grid(linestyle=":", alpha=0.5)
113
+ self.ax.set_axisbelow(True)
114
+
115
+ self.ax2.clear()
116
+ offset_attempts = [a for a in self._attempts if a.offset_frame is not None]
117
+ points = [(a.offset_lon, a.offset_lat) for a in offset_attempts]
118
+ if offset_attempts and offset_attempts[0].offset_frame == OffsetFrame.RA_DEC:
119
+ xlabel, ylabel = "RA offset [deg]", "Dec offset [deg]"
120
+ elif offset_attempts and offset_attempts[0].offset_frame == OffsetFrame.ALT_AZ:
121
+ xlabel, ylabel = "Alt offset [deg]", "Az offset [deg]"
122
+ else:
123
+ xlabel, ylabel = "Offset 1 [deg]", "Offset 2 [deg]"
124
+ if points:
125
+ xs, ys = zip(*points, strict=True)
126
+ self.ax2.axhline(0, color="gray", linewidth=0.5)
127
+ self.ax2.axvline(0, color="gray", linewidth=0.5)
128
+ self.ax2.plot(xs, ys, marker="o", color="tab:orange")
129
+ self.ax2.plot(xs[0], ys[0], marker="s", color="tab:red", markersize=8, linestyle="", label="start")
130
+ self.ax2.plot(xs[-1], ys[-1], marker="*", color="tab:green", markersize=12, linestyle="", label="latest")
131
+ self.ax2.legend(fontsize="small")
132
+ self.ax2.set_aspect("equal", adjustable="datalim")
133
+ self.ax2.set_xlabel(xlabel)
134
+ self.ax2.set_ylabel(ylabel)
135
+ self.ax2.grid(linestyle=":", alpha=0.5)
136
+ self.ax2.set_axisbelow(True)
137
+
138
+ self.figure.tight_layout()
139
+ self.canvas.draw()
140
+
141
+ @qasync.asyncSlot() # type: ignore
142
+ async def _acquire(self) -> None:
143
+ async with self.comm.proxy(self.module, IAcquisition) as proxy:
144
+ await proxy.acquire_target()
145
+
146
+ @qasync.asyncSlot() # type: ignore
147
+ async def _abort(self) -> None:
148
+ async with self.comm.proxy(self.module, IAcquisition) as proxy:
149
+ await proxy.abort()
150
+
151
+
152
+ __all__ = ["AcquisitionWidget"]
@@ -0,0 +1,109 @@
1
+ import logging
2
+ import os
3
+ from typing import Any
4
+
5
+ import qasync # type: ignore
6
+ from PySide6 import QtCore, QtWidgets # type: ignore
7
+
8
+ os.environ["QT_API"] = "PySide6"
9
+ from matplotlib import pyplot as plt
10
+ from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas
11
+
12
+ from pyobs.events import Event, FocusFoundEvent
13
+ from pyobs.interfaces import AutoFocusPoint, AutoFocusState, IAutoFocus, IRunning, RunningState
14
+ from .base import BaseWidget
15
+ from .qt.autofocuswidget_ui import Ui_AutoFocusWidget
16
+
17
+ log = logging.getLogger(__name__)
18
+
19
+
20
+ class AutoFocusWidget(BaseWidget, Ui_AutoFocusWidget):
21
+ signal_update_gui = QtCore.Signal()
22
+
23
+ def __init__(self, **kwargs: Any):
24
+ BaseWidget.__init__(self, **kwargs)
25
+ self.setupUi(self) # type: ignore
26
+
27
+ # cached state
28
+ self._points: list[AutoFocusPoint] = []
29
+ self._running = False
30
+ self._last_result: tuple[float, float] | None = None
31
+
32
+ # add plot
33
+ self.figure, self.ax = plt.subplots()
34
+ layout = QtWidgets.QVBoxLayout(self.framePlot)
35
+ self.canvas = FigureCanvas(self.figure)
36
+ layout.addWidget(self.canvas)
37
+ self.framePlot.setLayout(layout)
38
+
39
+ # connect signals
40
+ self.signal_update_gui.connect(self.update_gui)
41
+ self.buttonRunAutoFocus.clicked.connect(self._run_auto_focus)
42
+ self.buttonAbort.clicked.connect(self._abort)
43
+
44
+ # button colors
45
+ self.colorize_button(self.buttonRunAutoFocus, QtCore.Qt.GlobalColor.green)
46
+ self.colorize_button(self.buttonAbort, QtCore.Qt.GlobalColor.red)
47
+
48
+ async def _init(self) -> None:
49
+ await self.comm.subscribe_state(self.module, IRunning, self._on_running_state)
50
+ await self.comm.subscribe_state(self.module, IAutoFocus, self._on_autofocus_state)
51
+ await self.register_event(FocusFoundEvent, self._on_focus_found)
52
+
53
+ # permitted methods (ACLs)
54
+ await self._fetch_permitted_methods()
55
+
56
+ def _on_running_state(self, state: RunningState) -> None:
57
+ if state.running and not self._running:
58
+ # rising edge -> a new run started (possibly triggered elsewhere), clear the plot
59
+ self._points = []
60
+ self._last_result = None
61
+ self._running = state.running
62
+ self.signal_update_gui.emit()
63
+
64
+ def _on_autofocus_state(self, state: AutoFocusState) -> None:
65
+ self._points = state.points
66
+ self.signal_update_gui.emit()
67
+
68
+ async def _on_focus_found(self, event: Event, sender: str) -> bool:
69
+ if sender != self.module or not isinstance(event, FocusFoundEvent):
70
+ return False
71
+ self._last_result = (event.focus, event.error or 0.0)
72
+ self.signal_update_gui.emit()
73
+ return True
74
+
75
+ def update_gui(self) -> None:
76
+ self.buttonRunAutoFocus.setEnabled(not self._running and self.permitted("auto_focus"))
77
+ self.buttonAbort.setEnabled(self._running and self.permitted("abort"))
78
+ self.labelStatus.setText(
79
+ "Running..."
80
+ if self._running
81
+ else f"Focus: {self._last_result[0]:.3f} ± {self._last_result[1]:.3f} mm"
82
+ if self._last_result
83
+ else "Idle"
84
+ )
85
+
86
+ self.ax.clear()
87
+ if self._points:
88
+ self.ax.scatter([p.focus for p in self._points], [p.value for p in self._points], color="tab:blue")
89
+ if self._last_result and not self._running:
90
+ self.ax.axvline(self._last_result[0], color="tab:green", linestyle="--", label="fitted focus")
91
+ self.ax.legend()
92
+ self.ax.set_xlabel("Focus [mm]")
93
+ self.ax.set_ylabel("Metric")
94
+ self.ax.grid(linestyle=":", alpha=0.5)
95
+ self.ax.set_axisbelow(True)
96
+ self.canvas.draw()
97
+
98
+ @qasync.asyncSlot() # type: ignore
99
+ async def _run_auto_focus(self) -> None:
100
+ async with self.comm.proxy(self.module, IAutoFocus) as proxy:
101
+ await proxy.auto_focus(self.spinCount.value(), self.spinStep.value(), self.spinExposureTime.value())
102
+
103
+ @qasync.asyncSlot() # type: ignore
104
+ async def _abort(self) -> None:
105
+ async with self.comm.proxy(self.module, IAutoFocus) as proxy:
106
+ await proxy.abort()
107
+
108
+
109
+ __all__ = ["AutoFocusWidget"]
@@ -0,0 +1,150 @@
1
+ import logging
2
+ import math
3
+ import os
4
+ from collections import deque
5
+ from typing import Any
6
+
7
+ import qasync # type: ignore
8
+ from PySide6 import QtCore, QtWidgets # type: ignore
9
+
10
+ os.environ["QT_API"] = "PySide6"
11
+ from matplotlib import pyplot as plt
12
+ from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas
13
+ from matplotlib.ticker import MaxNLocator
14
+
15
+ from pyobs.interfaces import GuidingState, IAutoGuiding, IExposureTime, IRunning, OffsetFrame
16
+ from pyobs.interfaces.IExposureTime import ExposureTimeState
17
+ from pyobs.interfaces.IRunning import RunningState
18
+ from .base import BaseWidget
19
+ from .qt.autoguidingwidget_ui import Ui_AutoGuidingWidget
20
+
21
+ log = logging.getLogger(__name__)
22
+
23
+ _HISTORY_LENGTH = 50
24
+
25
+
26
+ class AutoGuidingWidget(BaseWidget, Ui_AutoGuidingWidget):
27
+ signal_update_gui = QtCore.Signal()
28
+
29
+ def __init__(self, **kwargs: Any):
30
+ BaseWidget.__init__(self, **kwargs)
31
+ self.setupUi(self) # type: ignore
32
+
33
+ # cached state
34
+ self._running = False
35
+ self._loop_closed = False
36
+ self._exposure_time: float | None = None
37
+ self._offset_frame: OffsetFrame | None = None
38
+ self._offset: tuple[float, float] | None = None # last (lon, lat), arcsec
39
+ self._offset_history: deque[tuple[float, float]] = deque(maxlen=_HISTORY_LENGTH)
40
+
41
+ # add plots: offset magnitude vs. sample, and the lon/lat scatter
42
+ self.figure, (self.ax, self.ax2) = plt.subplots(1, 2)
43
+ layout = QtWidgets.QVBoxLayout(self.framePlot)
44
+ self.canvas = FigureCanvas(self.figure)
45
+ layout.addWidget(self.canvas)
46
+ self.framePlot.setLayout(layout)
47
+
48
+ # connect signals
49
+ self.signal_update_gui.connect(self.update_gui)
50
+ self.buttonStart.clicked.connect(self._start)
51
+ self.buttonStop.clicked.connect(self._stop)
52
+ self.spinExposureTime.init_modified(self.labelExposureTime).committed.connect(self._set_exposure_time)
53
+
54
+ # button colors
55
+ self.colorize_button(self.buttonStart, QtCore.Qt.GlobalColor.green)
56
+ self.colorize_button(self.buttonStop, QtCore.Qt.GlobalColor.red)
57
+
58
+ async def _init(self) -> None:
59
+ await self.comm.subscribe_state(self.module, IRunning, self._on_running_state)
60
+ await self.comm.subscribe_state(self.module, IExposureTime, self._on_exptime_state)
61
+ await self.comm.subscribe_state(self.module, IAutoGuiding, self._on_guiding_state)
62
+
63
+ # permitted methods (ACLs)
64
+ await self._fetch_permitted_methods()
65
+
66
+ def _on_running_state(self, state: RunningState) -> None:
67
+ if state.running and not self._running:
68
+ # rising edge -> a new run started (possibly triggered elsewhere), clear the history
69
+ self._offset_history.clear()
70
+ self._running = state.running
71
+ self.signal_update_gui.emit()
72
+
73
+ def _on_exptime_state(self, state: ExposureTimeState) -> None:
74
+ # only follow live updates while the spin box isn't mid-edit, so a user's in-progress
75
+ # value isn't clobbered by a state update from elsewhere
76
+ was_synced = self._exposure_time is None or self.spinExposureTime.value() == self._exposure_time
77
+ self._exposure_time = state.exposure_time
78
+ self.labelExposureTime.setText(f"{state.exposure_time:.1f}")
79
+ if was_synced:
80
+ self.spinExposureTime.setValue(state.exposure_time)
81
+ self.signal_update_gui.emit()
82
+
83
+ def _on_guiding_state(self, state: GuidingState) -> None:
84
+ self._loop_closed = state.loop_closed
85
+ self._offset_frame = state.offset_frame
86
+ if state.offset_lon is not None and state.offset_lat is not None:
87
+ self._offset = (state.offset_lon * 3600, state.offset_lat * 3600)
88
+ self._offset_history.append(self._offset)
89
+ self.signal_update_gui.emit()
90
+
91
+ def update_gui(self) -> None:
92
+ self.buttonStart.setEnabled(not self._running and self.permitted("start"))
93
+ self.buttonStop.setEnabled(self._running and self.permitted("stop"))
94
+ self.spinExposureTime.setEnabled(self.permitted("set_exposure_time"))
95
+ self.labelLoopState.setText(
96
+ "Closed loop" if self._running and self._loop_closed else "Open loop" if self._running else "Stopped"
97
+ )
98
+ self.labelOffset.setText(f"({self._offset[0]:+.2f}, {self._offset[1]:+.2f}) arcsec" if self._offset else "")
99
+
100
+ self.ax.clear()
101
+ if self._offset_history:
102
+ magnitudes = [math.sqrt(lon**2 + lat**2) for lon, lat in self._offset_history]
103
+ self.ax.plot(range(1, len(magnitudes) + 1), magnitudes, marker="o", color="tab:blue")
104
+ self.ax.set_xlabel("Sample")
105
+ self.ax.set_ylabel("Offset magnitude [arcsec]")
106
+ self.ax.xaxis.set_major_locator(MaxNLocator(integer=True))
107
+ self.ax.grid(linestyle=":", alpha=0.5)
108
+ self.ax.set_axisbelow(True)
109
+
110
+ self.ax2.clear()
111
+ if self._offset_frame == OffsetFrame.RA_DEC:
112
+ xlabel, ylabel = "RA offset [arcsec]", "Dec offset [arcsec]"
113
+ elif self._offset_frame == OffsetFrame.ALT_AZ:
114
+ xlabel, ylabel = "Alt offset [arcsec]", "Az offset [arcsec]"
115
+ else:
116
+ xlabel, ylabel = "Offset 1 [arcsec]", "Offset 2 [arcsec]"
117
+ if self._offset_history:
118
+ xs, ys = zip(*self._offset_history, strict=True)
119
+ self.ax2.axhline(0, color="gray", linewidth=0.5)
120
+ self.ax2.axvline(0, color="gray", linewidth=0.5)
121
+ self.ax2.plot(xs, ys, marker="o", linestyle="", color="tab:orange")
122
+ self.ax2.plot(xs[-1], ys[-1], marker="*", color="tab:green", markersize=12, linestyle="", label="latest")
123
+ self.ax2.legend(fontsize="small")
124
+ self.ax2.set_aspect("equal", adjustable="datalim")
125
+ self.ax2.set_xlabel(xlabel)
126
+ self.ax2.set_ylabel(ylabel)
127
+ self.ax2.grid(linestyle=":", alpha=0.5)
128
+ self.ax2.set_axisbelow(True)
129
+
130
+ self.figure.tight_layout()
131
+ self.canvas.draw()
132
+
133
+ @qasync.asyncSlot() # type: ignore
134
+ async def _start(self) -> None:
135
+ async with self.comm.proxy(self.module, IAutoGuiding) as proxy:
136
+ await proxy.start()
137
+
138
+ @qasync.asyncSlot() # type: ignore
139
+ async def _stop(self) -> None:
140
+ async with self.comm.proxy(self.module, IAutoGuiding) as proxy:
141
+ await proxy.stop()
142
+
143
+ @qasync.asyncSlot() # type: ignore
144
+ async def _set_exposure_time(self) -> None:
145
+ value = self.spinExposureTime.value()
146
+ async with self.comm.proxy(self.module, IExposureTime) as proxy:
147
+ await proxy.set_exposure_time(value)
148
+
149
+
150
+ __all__ = ["AutoGuidingWidget"]
pyobs_gui/base.py CHANGED
@@ -5,6 +5,7 @@ import logging
5
5
  from typing import TYPE_CHECKING, Any, Callable, Type, TypeVar, overload
6
6
 
7
7
  import pyobs.utils.exceptions as exc
8
+ from pyobs.interfaces import IModule
8
9
  from pyobs.object import create_object
9
10
  from pyobs.utils.enums import ModuleState
10
11
  from PySide6 import QtCore, QtGui, QtWidgets # type: ignore
@@ -16,6 +17,8 @@ if TYPE_CHECKING:
16
17
 
17
18
  from astroplan import Observer
18
19
  from pyobs.comm import Comm
20
+ from pyobs.events import Event
21
+ from pyobs.interfaces import FitsHeaderEntry
19
22
  from pyobs.vfs import VirtualFileSystem
20
23
 
21
24
  log = logging.getLogger(__name__)
@@ -150,6 +153,16 @@ class BaseWidget(BaseWindow, QtWidgets.QWidget): # type: ignore
150
153
  # has it been initialized?
151
154
  self._initialized = False
152
155
 
156
+ # methods this GUI is permitted to invoke on self.module; None until fetched or if the
157
+ # fetch failed, meaning "treat everything as permitted"
158
+ self._permitted_methods: set[str] | None = None
159
+
160
+ # (event_class, handler) pairs registered via self.register_event(), so discard() can
161
+ # unregister them all -- comm.register_event() has no automatic per-client teardown like
162
+ # subscribe_state()/subscribe_presence() do, since a handler isn't inherently tied to one
163
+ # remote module, so whoever registers it is responsible for unregistering it
164
+ self._registered_event_handlers: list[tuple[type[Event], Callable[[Event, str], Any]]] = []
165
+
153
166
  def resizeEvent(self, a0: QtGui.QResizeEvent) -> None:
154
167
  if self.extract_window_button:
155
168
  self.extract_window_button.move(self.width() - 20, 0)
@@ -203,6 +216,32 @@ class BaseWidget(BaseWindow, QtWidgets.QWidget): # type: ignore
203
216
  self.sidebar_widgets.append(widget)
204
217
  self.sidebar_layout.insertWidget(len(self.sidebar_widgets) - 1, widget)
205
218
 
219
+ async def register_event(self, event_class: type[Event], handler: Callable[[Event, str], Any]) -> None:
220
+ """Register an event handler through comm, tracked so discard() can unregister it later.
221
+
222
+ Widgets that are created/destroyed per connected client (see DEFAULT_WIDGETS in
223
+ mainwindow.py) must use this instead of calling self.comm.register_event() directly,
224
+ so their handler stops firing once the widget is discarded.
225
+ """
226
+ await self.comm.register_event(event_class, handler)
227
+ self._registered_event_handlers.append((event_class, handler))
228
+
229
+ async def discard(self) -> None:
230
+ """Tear down everything this widget registered with comm, and recursively discard
231
+ its sidebar widgets.
232
+
233
+ Must be called (e.g. from mainwindow._client_disconnected) whenever a widget is
234
+ removed from the UI -- otherwise a handler registered via register_event() lingers
235
+ in Comm._event_handlers forever, keeping this widget alive and still reacting to
236
+ events for as long as the app runs.
237
+ """
238
+ for event_class, handler in self._registered_event_handlers:
239
+ await self.comm.unregister_event(event_class, handler)
240
+ self._registered_event_handlers.clear()
241
+
242
+ for widget in self.sidebar_widgets:
243
+ await widget.discard()
244
+
206
245
  def showEvent(self, event: QtGui.QShowEvent) -> None:
207
246
  # run in loop
208
247
  asyncio.create_task(self._showEvent(event))
@@ -250,7 +289,7 @@ class BaseWidget(BaseWindow, QtWidgets.QWidget): # type: ignore
250
289
  # sleep a little
251
290
  await asyncio.sleep(1)
252
291
 
253
- except (exc.PyObsError, IndexError):
292
+ except (exc.PyobsError, IndexError):
254
293
  # ignore these and sleep a little
255
294
  await asyncio.sleep(1)
256
295
 
@@ -269,7 +308,7 @@ class BaseWidget(BaseWindow, QtWidgets.QWidget): # type: ignore
269
308
  # call method
270
309
  try:
271
310
  await method(*args, **kwargs)
272
- except exc.PyObsError as e:
311
+ except exc.PyobsError as e:
273
312
  await self.show_error(e)
274
313
  except Exception as e:
275
314
  log.exception("An error occurred.")
@@ -278,7 +317,7 @@ class BaseWidget(BaseWindow, QtWidgets.QWidget): # type: ignore
278
317
  # enable widgets
279
318
  self._enable_buttons.emit(disable, True)
280
319
 
281
- async def show_error(self, exception: exc.PyObsError) -> None:
320
+ async def show_error(self, exception: exc.PyobsError) -> None:
282
321
  err = str(exception)
283
322
  title, message = err.split(":") if ":" in err else ("Error", err)
284
323
  await QAsyncMessageBox.warning(self, title, message)
@@ -287,7 +326,23 @@ class BaseWidget(BaseWindow, QtWidgets.QWidget): # type: ignore
287
326
  for w in widgets:
288
327
  w.setEnabled(enable)
289
328
 
290
- def get_fits_headers(self, namespaces: list[str] | None = None, **kwargs: Any) -> dict[str, tuple[Any, str]]:
329
+ async def _fetch_permitted_methods(self) -> None:
330
+ # leaves _permitted_methods at None on failure, so permitted() falls back to "everything
331
+ # allowed" - an actual denial still surfaces via ForbiddenError in _background_task
332
+ if not hasattr(IModule, "get_permitted_methods"):
333
+ # pyobs-core older than the ACL feature (still within our supported version range)
334
+ return
335
+ try:
336
+ async with self.comm.proxy(self.module, IModule) as proxy:
337
+ self._permitted_methods = set(await proxy.get_permitted_methods())
338
+ except exc.PyobsError:
339
+ self._permitted_methods = None
340
+
341
+ def permitted(self, method: str) -> bool:
342
+ """Returns whether this GUI is permitted to invoke the given method on self.module."""
343
+ return self._permitted_methods is None or method in self._permitted_methods
344
+
345
+ def get_fits_headers(self, namespaces: list[str] | None = None, **kwargs: Any) -> dict[str, FitsHeaderEntry]:
291
346
  """Returns FITS header for the current status of this module.
292
347
 
293
348
  Args:
pyobs_gui/camerawidget.py CHANGED
@@ -25,6 +25,8 @@ from pyobs.interfaces import (
25
25
  GainState,
26
26
  IExposure,
27
27
  ExposureState,
28
+ IDataSequence,
29
+ DataSequenceState,
28
30
  )
29
31
  from pyobs.utils.enums import ImageType, ImageFormat, ExposureStatus
30
32
  from pyobs.vfs import VirtualFileSystem
@@ -153,6 +155,9 @@ class CameraWidget(BaseWidget, Ui_CameraWidget):
153
155
  if proxy is not None:
154
156
  await self.comm.subscribe_state(self.module, IExposureTime, self._update_exposure_time)
155
157
 
158
+ if await self.comm.has_proxy(self.module, IDataSequence):
159
+ await self.comm.subscribe_state(self.module, IDataSequence, self._update_sequence)
160
+
156
161
  # update GUI
157
162
  self.signal_update_gui.emit()
158
163
 
@@ -185,6 +190,10 @@ class CameraWidget(BaseWidget, Ui_CameraWidget):
185
190
  self.spinExpTime.setValue(state.exposure_time)
186
191
  self.update_gui()
187
192
 
193
+ def _update_sequence(self, state: DataSequenceState):
194
+ self.exposures_left = state.count_left
195
+ self.update_gui()
196
+
188
197
  @qasync.asyncSlot()
189
198
  async def _window_changed(self):
190
199
  print("ok")
@@ -238,19 +247,21 @@ class CameraWidget(BaseWidget, Ui_CameraWidget):
238
247
 
239
248
  @qasync.asyncSlot() # type: ignore
240
249
  async def expose(self) -> None:
241
- # set initial image count
242
- self.exposures_left = self.spinCount.value()
250
+ broadcast = self.checkBroadcast.isChecked()
251
+ count = self.spinCount.value()
252
+
253
+ # if the module can grab a counted sequence server-side, let it -- progress comes
254
+ # back via the DataSequenceState subscription
255
+ async with self.comm.safe_proxy(self.module, IDataSequence) as proxy:
256
+ if proxy is not None:
257
+ await proxy.grab_sequence(count, broadcast)
258
+ return
243
259
 
244
- # do exposure(s)
260
+ # fall back to a client-side loop for modules that don't support IDataSequence
261
+ self.exposures_left = count
245
262
  while self.exposures_left > 0:
246
- # expose
247
- broadcast = self.checkBroadcast.isChecked()
248
263
  await self.datadisplay.grab_data(broadcast)
249
-
250
- # decrement number of exposures left
251
264
  self.exposures_left -= 1
252
-
253
- # signal GUI update
254
265
  self.signal_update_gui.emit()
255
266
 
256
267
  @qasync.asyncSlot() # type: ignore
@@ -262,7 +273,12 @@ class CameraWidget(BaseWidget, Ui_CameraWidget):
262
273
 
263
274
  # got exposures left?
264
275
  if self.exposures_left > 1:
265
- # abort sequence
276
+ # soft-stop the sequence server-side (current grab finishes normally), if
277
+ # supported; otherwise just stop the client-side loop after the current grab
278
+ async with self.comm.safe_proxy(self.module, IDataSequence) as proxy:
279
+ if proxy is not None:
280
+ await proxy.abort_sequence()
281
+ return
266
282
  self.exposures_left = 0
267
283
  else:
268
284
  async with self.comm.safe_proxy(self.module, IAbortable) as proxy:
@@ -42,20 +42,27 @@ class CompassMoveWidget(BaseWidget, Ui_CompassMoveWidget):
42
42
  has_offsets_radec = await self.comm.has_proxy(self.module, IOffsetsRaDec)
43
43
  if has_offsets_altaz and has_pointing_altaz:
44
44
  async with self.comm.proxy(self.module, IPointingAltAz) as p:
45
- alt, az = await p.get_altaz() # type: ignore[attr-defined]
45
+ altaz_state = await p.wait_for_state(IPointingAltAz, timeout=5.0)
46
+ if altaz_state is None:
47
+ return
46
48
  altaz = SkyCoord(
47
- alt=alt * u.degree, # type: ignore[attr-defined]
48
- az=az * u.degree, # type: ignore[attr-defined]
49
+ alt=altaz_state.alt * u.degree,
50
+ az=altaz_state.az * u.degree,
49
51
  obstime=Time.now(),
50
52
  location=self.observer.location,
51
53
  frame="altaz",
52
54
  )
53
55
  async with self.comm.proxy(self.module, IOffsetsAltAz) as p:
54
- off_alt, off_az = await p.get_offsets_altaz() # type: ignore[attr-defined]
55
- off_ra, off_dec = offset_altaz_to_radec(altaz, off_alt, off_az)
56
+ offset_state = await p.wait_for_state(IOffsetsAltAz, timeout=5.0)
57
+ if offset_state is None:
58
+ return
59
+ off_ra, off_dec = offset_altaz_to_radec(altaz, offset_state.alt, offset_state.az)
56
60
  elif has_offsets_radec:
57
61
  async with self.comm.proxy(self.module, IOffsetsRaDec) as p:
58
- off_ra, off_dec = await p.get_offsets_radec() # type: ignore[attr-defined]
62
+ offset_state = await p.wait_for_state(IOffsetsRaDec, timeout=5.0)
63
+ if offset_state is None:
64
+ return
65
+ off_ra, off_dec = offset_state.ra, offset_state.dec
59
66
  else:
60
67
  return
61
68
 
@@ -77,8 +77,8 @@ class DataDisplayWidget(BaseWidget, Ui_DataDisplayWidget):
77
77
  raise ValueError("Unknown type")
78
78
 
79
79
  # subscribe to events
80
- await self.comm.register_event(NewImageEvent, self._on_new_data)
81
- await self.comm.register_event(NewSpectrumEvent, self._on_new_data)
80
+ await self.register_event(NewImageEvent, self._on_new_data)
81
+ await self.register_event(NewSpectrumEvent, self._on_new_data)
82
82
 
83
83
  async def grab_data(self, broadcast: bool) -> None:
84
84
  """Grab data."""
pyobs_gui/filterwidget.py CHANGED
@@ -38,6 +38,9 @@ class FilterWidget(BaseWidget, Ui_FilterWidget):
38
38
  await self.comm.subscribe_state(self.module, IFilters, self._on_filter_state)
39
39
  await self.comm.subscribe_state(self.module, IMotion, self._on_motion_state)
40
40
 
41
+ # permitted methods (ACLs)
42
+ await self._fetch_permitted_methods()
43
+
41
44
  def _on_filter_state(self, state: FilterState) -> None:
42
45
  self._filter = state.filter
43
46
  self.signal_update_gui.emit()
@@ -56,7 +59,7 @@ class FilterWidget(BaseWidget, Ui_FilterWidget):
56
59
  MotionStatus.IDLE,
57
60
  MotionStatus.POSITIONED,
58
61
  ]
59
- self.buttonSetFilter.setEnabled(initialized)
62
+ self.buttonSetFilter.setEnabled(initialized and self.permitted("set_filter"))
60
63
 
61
64
  @qasync.asyncSlot() # type: ignore
62
65
  async def set_filter(self) -> None: