pyobs-gui 2.0.0.dev1__tar.gz → 2.0.0.dev2__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_gui-2.0.0.dev2/.claude/skills/verify/SKILL.md +103 -0
- pyobs_gui-2.0.0.dev2/.github/dependabot.yml +10 -0
- pyobs_gui-2.0.0.dev2/DEVELOPMENT.md +155 -0
- pyobs_gui-2.0.0.dev2/DEV_IAcquisition.md +95 -0
- pyobs_gui-2.0.0.dev2/DEV_IAutoFocus.md +162 -0
- pyobs_gui-2.0.0.dev2/DEV_IAutoGuiding.md +156 -0
- pyobs_gui-2.0.0.dev2/DEV_NavbarShortcuts.md +286 -0
- pyobs_gui-2.0.0.dev2/DEV_qml_evaluation.md +218 -0
- pyobs_gui-2.0.0.dev2/DEV_telescopewidget_layout.md +199 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/PKG-INFO +2 -2
- pyobs_gui-2.0.0.dev2/pyobs_gui/acquisitionwidget.py +152 -0
- pyobs_gui-2.0.0.dev2/pyobs_gui/autofocuswidget.py +109 -0
- pyobs_gui-2.0.0.dev2/pyobs_gui/autoguidingwidget.py +150 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/base.py +59 -4
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/camerawidget.py +26 -10
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/compassmovewidget.py +13 -6
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/datadisplaywidget.py +2 -2
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/filterwidget.py +4 -1
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/fitsheaderswidget.py +7 -5
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/gui.py +2 -2
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/mainwindow.py +222 -21
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/modewidget.py +21 -19
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/modulegui.py +2 -2
- pyobs_gui-2.0.0.dev2/pyobs_gui/qt/acquisitionwidget.ui +173 -0
- pyobs_gui-2.0.0.dev2/pyobs_gui/qt/acquisitionwidget_ui.py +147 -0
- pyobs_gui-2.0.0.dev2/pyobs_gui/qt/autofocuswidget.ui +163 -0
- pyobs_gui-2.0.0.dev2/pyobs_gui/qt/autofocuswidget_ui.py +132 -0
- pyobs_gui-2.0.0.dev2/pyobs_gui/qt/autoguidingwidget.ui +161 -0
- pyobs_gui-2.0.0.dev2/pyobs_gui/qt/autoguidingwidget_ui.py +131 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/mainwindow.ui +24 -30
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/mainwindow_ui.py +17 -23
- pyobs_gui-2.0.0.dev2/pyobs_gui/statuswidget.py +294 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/telescopewidget.py +116 -26
- pyobs_gui-2.0.0.dev2/pyobs_gui/weatherwidget.py +152 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyproject.toml +2 -2
- pyobs_gui-2.0.0.dev2/test/acquisition.yaml +23 -0
- pyobs_gui-2.0.0.dev2/test/autofocus.yaml +23 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/test/full.yaml +22 -1
- pyobs_gui-2.0.0.dev2/test/guiding.yaml +23 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/test/telescope.yaml +1 -1
- pyobs_gui-2.0.0.dev2/test/telescope_acl.yaml +27 -0
- pyobs_gui-2.0.0.dev2/test/telescope_acl_denied.yaml +27 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/uv.lock +5 -5
- pyobs_gui-2.0.0.dev1/pyobs_gui/statuswidget.py +0 -147
- pyobs_gui-2.0.0.dev1/pyobs_gui/weatherwidget.py +0 -160
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/.github/workflows/pypi.yml +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/.github/workflows/pyrefly.yml +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/.github/workflows/ruff.yml +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/.pre-commit-config.yaml +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/.readthedocs.yml +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/CHANGELOG.rst +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/LICENSE +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/README.md +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/bin/mypyuic5 +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/docs/Makefile +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/docs/make.bat +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/docs/requirements.txt +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/docs/source/_static/pyobs.gif +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/docs/source/conf.py +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/docs/source/index.rst +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/mypy.ini +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/__init__.py +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/commandinputwidget.py +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/coolingwidget.py +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/eventswidget.py +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/focuswidget.py +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/logmodel.py +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/modifiedmixin.py +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/__init__.py +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/camerawidget.ui +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/camerawidget_ui.py +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/compassmoveplugin.py +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/compassmovewidget.py +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/compassmovewidget.ui +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/compassmovewidget_ui.py +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/compile.sh +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/coolingwidget.ui +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/coolingwidget_ui.py +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/datadisplayplugin.py +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/datadisplaywidget.ui +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/datadisplaywidget_ui.py +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/designer.sh +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/eventswidget.ui +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/eventswidget_ui.py +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/filterwidget.ui +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/filterwidget_ui.py +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/fitsheaderswidget.ui +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/fitsheaderswidget_ui.py +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/focuswidget.ui +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/focuswidget_ui.py +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/modewidget.ui +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/modewidget_ui.py +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/resources/arrow-alt-circle-down-solid.svg +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/resources/arrow-alt-circle-left-solid.svg +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/resources/arrow-alt-circle-right-solid.svg +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/resources/arrow-alt-circle-up-solid.svg +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/resources/arrow-up-right-from-square-solid.svg +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/resources/arrows-to-eye-solid.svg +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/resources/chart-line-solid.svg +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/resources/circle-question-solid.svg +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/resources/cloud-sun-solid.svg +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/resources/edit-solid.svg +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/resources/search-solid.svg +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/resources/undo-solid.svg +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/resources.qrc +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/resources_rc.py +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/roofwidget.ui +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/roofwidget_ui.py +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/shellwidget.ui +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/shellwidget_ui.py +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/spectrographwidget.ui +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/spectrographwidget_ui.py +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/telescopewidget.ui +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/telescopewidget_ui.py +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/temperaturesplotwidget.ui +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/temperaturesplotwidget_ui.py +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/temperatureswidget.ui +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/temperatureswidget_ui.py +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/videowidget.ui +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/videowidget_ui.py +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/weatherwidget.ui +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/weatherwidget_ui.py +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/roofwidget.py +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/shellwidget.py +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/spectrographwidget.py +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/temperaturesplotwidget.py +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/temperatureswidget.py +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/utils.py +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/videowidget.py +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/watchedlabel.py +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/test/camera.yaml +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/test/mode.yaml +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/test/roof.yaml +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/test/spectrograph.yaml +0 -0
- {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/test/video.yaml +0 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: verify
|
|
3
|
+
description: Drive the real pyobs-gui app headlessly against a real pyobs module (LocalComm) to verify a widget change actually works end-to-end.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Verifying pyobs-gui changes
|
|
7
|
+
|
|
8
|
+
pyobs-gui is a PySide6 app that talks to pyobs modules over `Comm`. There's no
|
|
9
|
+
pytest suite (see `DEVELOPMENT.md`) — verification means running the real
|
|
10
|
+
`GUI` module against a real target module and observing widget state /
|
|
11
|
+
screenshots. This works headlessly, no Xvfb needed.
|
|
12
|
+
|
|
13
|
+
## The recipe
|
|
14
|
+
|
|
15
|
+
`test/*.yaml` are `MultiModule` fixtures that run a target module (e.g.
|
|
16
|
+
`DummyCamera`) and `pyobs_gui.GUI` in one process, connected via
|
|
17
|
+
`pyobs.comm.local.LocalComm` (in-process, no network). Normally launched with
|
|
18
|
+
`uv run pyobs test/camera.yaml`, but for scripted verification, drive it from
|
|
19
|
+
Python directly so you can reach into live widget state:
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
import os
|
|
23
|
+
os.environ["QT_QPA_PLATFORM"] = "offscreen" # before importing PySide6/qasync
|
|
24
|
+
|
|
25
|
+
import asyncio, yaml
|
|
26
|
+
from pyobs.object import get_object
|
|
27
|
+
from pyobs.modules import Module
|
|
28
|
+
from pyobs_gui.gui import GUI
|
|
29
|
+
|
|
30
|
+
async def main():
|
|
31
|
+
with open("test/camera.yaml") as f:
|
|
32
|
+
cfg = yaml.safe_load(f)
|
|
33
|
+
multi = get_object(cfg, Module)
|
|
34
|
+
await multi.open() # returns once sub-modules are *scheduled*, not ready
|
|
35
|
+
gui = multi["gui"]
|
|
36
|
+
# poll gui._window, then window._widgets["<module-name>"] until present
|
|
37
|
+
...
|
|
38
|
+
|
|
39
|
+
loop = GUI.new_event_loop() # QApplication(offscreen) + qasync.QEventLoop
|
|
40
|
+
asyncio.set_event_loop(loop)
|
|
41
|
+
loop.run_until_complete(main())
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
A working end-to-end example lives in git history / can be reconstructed from
|
|
45
|
+
this skill — see the gotchas below, they're all non-obvious.
|
|
46
|
+
|
|
47
|
+
## Gotchas
|
|
48
|
+
|
|
49
|
+
1. **`MultiModule.open()` doesn't wait for children.** Each sub-module opens
|
|
50
|
+
as its own asyncio task. Poll `gui._window is not None`, then
|
|
51
|
+
`"<name>" in window._widgets`, with `asyncio.sleep(0.1)` between checks.
|
|
52
|
+
|
|
53
|
+
2. **Widgets only run `_init()` on a real Qt `showEvent`**
|
|
54
|
+
(`pyobs_gui/base.py:245-252`, `BaseWidget.showEvent`/`_showEvent`). A
|
|
55
|
+
widget sitting in a `QStackedWidget` that's never made current, or a
|
|
56
|
+
`QMainWindow` that's never `.show()`n, never fires it — `has_proxy`/
|
|
57
|
+
`subscribe_state` calls in `_init()` never run, buttons stay disabled
|
|
58
|
+
forever. Always `window.stackedWidget.setCurrentWidget(widget)` +
|
|
59
|
+
`window.show()` *before* waiting for the widget to become enabled.
|
|
60
|
+
|
|
61
|
+
3. **Don't call `QApplication.processEvents()` manually inside a coroutine
|
|
62
|
+
running on the qasync loop.** It reenters qasync's own dispatch and
|
|
63
|
+
silently drops other scheduled coroutines (you'll see spurious
|
|
64
|
+
`RuntimeWarning: coroutine '...' was never awaited` for totally unrelated
|
|
65
|
+
widgets). Just `await asyncio.sleep(...)` — qasync pumps Qt events as
|
|
66
|
+
part of the normal loop.
|
|
67
|
+
|
|
68
|
+
4. **Fire-and-forget RPCs prove nothing about the server side just by not
|
|
69
|
+
raising.** Methods like `IDataSequence.grab_sequence()` return
|
|
70
|
+
immediately; the real work happens in a background task on the target
|
|
71
|
+
module. A clean `await proxy.grab_sequence(...)` only means the call
|
|
72
|
+
dispatched — verify the actual effect via the pushed state
|
|
73
|
+
(`subscribe_state` callback / widget fields) or a registered event
|
|
74
|
+
handler (e.g. `NewImageEvent`), not the call's return.
|
|
75
|
+
|
|
76
|
+
5. **`DummyCamera`'s default `"pyobs"` VFS root is hardcoded to
|
|
77
|
+
`/opt/pyobs/storage/`** (`pyobs/vfs/vfs.py`), used for its FITS
|
|
78
|
+
frame-number cache regardless of your `vfs:` config — not writable by a
|
|
79
|
+
regular user. Override it in the loaded config before `get_object()`:
|
|
80
|
+
```python
|
|
81
|
+
cfg["vfs"]["roots"]["pyobs"] = {"class": "pyobs.vfs.LocalFile", "root": "/tmp/some-writable-dir/"}
|
|
82
|
+
```
|
|
83
|
+
(YAML anchors mean `cfg["vfs"]` is shared by reference with each module's
|
|
84
|
+
`vfs: *vfs`, so mutating the top-level dict before construction is
|
|
85
|
+
enough.)
|
|
86
|
+
|
|
87
|
+
6. **`DummyCamera` needs the optional `photutils` package** to simulate
|
|
88
|
+
images at all (`pip install photutils` into the venv — not a pyobs-gui
|
|
89
|
+
dependency, just needed for this fixture). Even then, as of photutils
|
|
90
|
+
3.0.0 there's a real incompatibility bug in pyobs-core's
|
|
91
|
+
`DummyCamera._simulate_image()`: `make_model_image()` is called with a
|
|
92
|
+
params table using column name `x_0`, which newer photutils rejects
|
|
93
|
+
(`ValueError: value "x_0" not in params_table column names`) — but only
|
|
94
|
+
when `exposure_time > 0` (it tries to paint simulated star sources sized
|
|
95
|
+
by exposure). Setting exposure_time to `0.0` sidesteps it entirely, and
|
|
96
|
+
`DummyCamera`'s default `readout_time=2` (seconds, per grab) is still
|
|
97
|
+
enough on its own to observe multi-grab sequence progress and test
|
|
98
|
+
aborting mid-sequence.
|
|
99
|
+
|
|
100
|
+
## Capturing evidence
|
|
101
|
+
|
|
102
|
+
`window.grab().save(path)` on the (offscreen) `QMainWindow` produces a real
|
|
103
|
+
PNG of the actual rendered UI — read it back with the Read tool to inspect.
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# Development
|
|
2
|
+
|
|
3
|
+
Backlog of planned work for pyobs-gui. Newest/most important items at the top.
|
|
4
|
+
|
|
5
|
+
## pyobs-core 2.0 ACLs — hide/grey out actions an operator isn't permitted to use
|
|
6
|
+
|
|
7
|
+
**Status: Option B implemented and verified end-to-end against pyobs-core 2.0.0.dev11 (the first
|
|
8
|
+
release with ACL support).** `BaseWidget._fetch_permitted_methods()` / `BaseWidget.permitted()`
|
|
9
|
+
(`base.py`) fetch and cache `IModule.get_permitted_methods()` once per widget, guarded by
|
|
10
|
+
`hasattr(IModule, "get_permitted_methods")` so it's a no-op against any older pinned pyobs-core
|
|
11
|
+
(>=2.0.0.dev6) that predates the feature — `permitted()` then falls back to "everything allowed."
|
|
12
|
+
Wired into `filterwidget.py`, `modewidget.py`, and `telescopewidget.py` (buttons, `move()` branches,
|
|
13
|
+
and `compassmovewidget`'s enable state). Fully-denied modules are hidden from the sidebar via the
|
|
14
|
+
same `get_permitted_methods()` fetch in `mainwindow.py:_client_connected`.
|
|
15
|
+
|
|
16
|
+
Verified with two new fixtures, `test/telescope_acl.yaml` (`acl: allow: {gui: [init, move_radec,
|
|
17
|
+
set_offsets_radec]}`) and `test/telescope_acl_denied.yaml` (`acl: allow: {gui: []}`), driven via a
|
|
18
|
+
`LocalComm`-backed harness (no pytest suite exists in this repo) that opens the real `GUI` module
|
|
19
|
+
against a real ACL-enforcing `DummyTelescope` and inspects live widget state:
|
|
20
|
+
- `telescope_acl.yaml`: `_permitted_methods` came back exactly `{init, move_radec,
|
|
21
|
+
set_offsets_radec}`; every button/branch gated on a permitted method stayed enabled
|
|
22
|
+
(`buttonMove`, `buttonSetRaOffset`, `buttonSetDecOffset`, `buttonResetEquatorialOffsets`,
|
|
23
|
+
`compassmovewidget`, `permitted("move_radec")`), every one gated on a denied method was forced off
|
|
24
|
+
(`buttonPark`, `buttonSetAltOffset`, `buttonSetAzOffset`, `buttonResetHorizontalOffsets`,
|
|
25
|
+
`permitted("move_altaz")`) — cross-checked with `motion_status=idle` so state-based gating alone
|
|
26
|
+
wouldn't explain the result.
|
|
27
|
+
- `telescope_acl_denied.yaml`: `MainWindow._client_connected` returned `False` for the telescope
|
|
28
|
+
client and it never entered `_widgets`, confirming the fully-denied module never reaches the
|
|
29
|
+
sidebar.
|
|
30
|
+
|
|
31
|
+
<details>
|
|
32
|
+
<summary>Original planning notes</summary>
|
|
33
|
+
|
|
34
|
+
`pyobs-core` 2.0's per-module access control has landed on its `develop` branch: Phase 8 is
|
|
35
|
+
implemented in full (`exc.ForbiddenError`, `acl:` config parsing, the `Module.execute()` check,
|
|
36
|
+
`IModule.get_permitted_methods()`, XMPP `forbidden`-condition mapping) — confirmed directly against
|
|
37
|
+
`pyobs-core`'s code and tests (`pyobs/interfaces/IModule.py`, `pyobs/modules/module.py:388-390,597`,
|
|
38
|
+
`tests/modules/test/standalone.py`), not just its own `DEVELOPMENT.md` claims. See its
|
|
39
|
+
[Access Control (ACLs)](https://github.com/pyobs/pyobs-core/blob/develop/DEVELOPMENT.md#access-control-acls)
|
|
40
|
+
section.
|
|
41
|
+
|
|
42
|
+
`IModule.get_permitted_methods(**kwargs) -> list[str]` returns the names of methods the *calling*
|
|
43
|
+
module is allowed to invoke on the target — caller-specific, always permitted itself (exempted from
|
|
44
|
+
its own ACL check), so any widget can call it on its target proxy to ask "what am I allowed to do
|
|
45
|
+
here" up front.
|
|
46
|
+
|
|
47
|
+
**Reactive handling already works today, no change needed:** `BaseWidget._background_task`
|
|
48
|
+
(`pyobs_gui/base.py:271-277`) already catches `exc.PyObsError` generically around every RPC call
|
|
49
|
+
and routes it to `show_error` (`base.py:282-285`), a plain message box with the exception's text.
|
|
50
|
+
`exc.ForbiddenError` is a `RemoteError` is a `PyObsError`, so a denied call already surfaces as a
|
|
51
|
+
normal error dialog — confirmed by reading the actual code, not assumed.
|
|
52
|
+
|
|
53
|
+
**Open, now unblocked — proactive greying-out of unpermitted actions:** widgets already have a
|
|
54
|
+
disable/enable mechanism built for a different purpose — `_enable_buttons.emit(disable, False)` /
|
|
55
|
+
`w.setEnabled(enable)` (`base.py:268,287-289`) — currently only used to disable buttons while their
|
|
56
|
+
own background task is running. The natural fix is to reuse the same mechanism for "not permitted,"
|
|
57
|
+
fetched once per widget via `get_permitted_methods()` alongside the capabilities/state it already
|
|
58
|
+
pulls from its target proxy at setup, rather than only finding out via an error dialog after the
|
|
59
|
+
operator clicks.
|
|
60
|
+
|
|
61
|
+
**Decided: grey out individual actions, not hide them — except a fully-blocked module, which can be
|
|
62
|
+
hidden from the sidebar entirely.** `pyobs-core`'s own design doc hedges throughout ("grey out or
|
|
63
|
+
hide") without picking one. Within a widget, the only mechanism it actually points at is the
|
|
64
|
+
existing `_enable_buttons` / `setEnabled()` path, which disables widgets — it doesn't remove them
|
|
65
|
+
from a layout — and hiding individual buttons would be new machinery (layout changes,
|
|
66
|
+
`setVisible(False)` bookkeeping) for no stated benefit, so the per-action plan is disable-only.
|
|
67
|
+
|
|
68
|
+
But a *module* that's entirely blocked (`get_permitted_methods()` returns `[]` for this GUI's
|
|
69
|
+
identity) is a different case, and there hiding is both possible and natural: `mainwindow.py`'s
|
|
70
|
+
`_client_connected` (376-424) already has an early-return "ignore it?" gate for `show_modules`
|
|
71
|
+
allowlisting, right before a module gets a widget or a nav entry at all
|
|
72
|
+
(`mainwindow.py:381-383`) — before `_add_client` runs, so before `self.listPages.addItem(item)`
|
|
73
|
+
(`mainwindow.py:241-270`). A "fully denied" check slots into the exact same spot: fetch
|
|
74
|
+
`get_permitted_methods()` once per newly-connected client, and if it comes back empty, `return
|
|
75
|
+
False` there instead of building a widget for it — the module never appears in the sidebar/nav list
|
|
76
|
+
at all, not merely greyed out. This reuses the identical pattern already in place for
|
|
77
|
+
`show_modules`, so it's cheap to add alongside the per-widget button work below, not a separate
|
|
78
|
+
design.
|
|
79
|
+
|
|
80
|
+
**Granularity: fetch per-widget, apply per-button — not a single per-widget on/off switch.** Two
|
|
81
|
+
things are independent:
|
|
82
|
+
|
|
83
|
+
- **The `get_permitted_methods()` fetch is per-widget** (really per-target-proxy): each widget
|
|
84
|
+
already talks to one module via `self.comm.proxy(self.module, ...)`, so it fetches once at setup,
|
|
85
|
+
alongside the state/capabilities it already pulls there.
|
|
86
|
+
- **The enable/disable decision is per-button.** Widgets already have fine-grained, per-button
|
|
87
|
+
`setEnabled()` calls keyed to current state — e.g. `telescopewidget.py:266-294`:
|
|
88
|
+
`self.buttonInit.setEnabled(self._motion_status == MotionStatus.PARKED)`,
|
|
89
|
+
`self.buttonPark.setEnabled(...)`, `self.buttonStop.setEnabled(...)`. Each button maps to a
|
|
90
|
+
different RPC method (`init`, `park`, `stop`, `move_radec`, ...), often on a different interface
|
|
91
|
+
(`IMotion`, `IPointingRaDec`, `IOffsetsAltAz`, ...) even within the same widget. ACL greying is a
|
|
92
|
+
second condition ANDed into each existing check — e.g.
|
|
93
|
+
`buttonPark.setEnabled(initialized and "park" in self._permitted_methods)` — not a single
|
|
94
|
+
"is this whole widget permitted" switch.
|
|
95
|
+
|
|
96
|
+
**Concrete surface: only widgets that already gate buttons on background tasks.** Grepping
|
|
97
|
+
`run_background(` with a `disable=` argument across `pyobs_gui/*.py` finds exactly four files with
|
|
98
|
+
actionable buttons to gate: `telescopewidget.py` (6 call sites), `filterwidget.py`, `modewidget.py`,
|
|
99
|
+
`compassmovewidget.py`. Not every `BaseWidget` subclass needs this — several
|
|
100
|
+
(`weatherwidget.py`, `datadisplaywidget.py`, `eventswidget.py`, etc.) are read-only displays with no
|
|
101
|
+
actions to gate at all.
|
|
102
|
+
|
|
103
|
+
### Option A: reactive-only (already shipped, zero work)
|
|
104
|
+
|
|
105
|
+
The reactive path needs no new code at all — confirmed by reading `base.py`, not assumed:
|
|
106
|
+
|
|
107
|
+
- Every RPC call goes through `BaseWidget._background_task` (`base.py:271-277`), which already
|
|
108
|
+
wraps `await method(*args, **kwargs)` in `try/except exc.PyObsError as e: await
|
|
109
|
+
self.show_error(e)`.
|
|
110
|
+
- `show_error` (`base.py:282-285`) already pops a `QAsyncMessageBox.warning` with the exception's
|
|
111
|
+
own text.
|
|
112
|
+
- A denial surfaces client-side as `exc.RemoteError(sender, f"Forbidden to invoke {method} at
|
|
113
|
+
{target}!")` (`pyobs-core`'s `pyobs/comm/xmpp/rpc.py:299`) — a `RemoteError` is a `PyObsError`, so
|
|
114
|
+
it's already caught by the generic handler above with a reasonable message.
|
|
115
|
+
|
|
116
|
+
Net: zero implementation. The only "work" is a manual smoke test against an ACL-restricted module
|
|
117
|
+
to confirm the dialog text reads sensibly. Weaker UX than greying out (the operator can still click
|
|
118
|
+
into a wall instead of seeing the action disabled up front), but it's the free fallback and already
|
|
119
|
+
covers the "reactive handling" bullet above.
|
|
120
|
+
|
|
121
|
+
### Option B: proactive greying-out — effort estimate (~half a day, 3-5 hours)
|
|
122
|
+
|
|
123
|
+
Bigger than Option A, but still small — no new architecture, just extending gating logic that
|
|
124
|
+
already exists in four files:
|
|
125
|
+
|
|
126
|
+
1. **Fetch (~30 min).** `get_permitted_methods()` lives on `IModule`, not on `Comm` like the cheap
|
|
127
|
+
`get_interfaces()`/`get_capabilities()` calls, so it needs an actual proxy round trip:
|
|
128
|
+
`async with self.comm.proxy(self.module, IModule) as proxy: permitted =
|
|
129
|
+
set(await proxy.get_permitted_methods())`. Mirrors the existing pattern at
|
|
130
|
+
`telescopewidget.py:144` (`self._interfaces = await self.comm.get_interfaces(self.module)`),
|
|
131
|
+
fetched once in `_init()`/`open()`. Worth a shared helper on `BaseWidget` so the four widgets
|
|
132
|
+
don't duplicate the round trip.
|
|
133
|
+
|
|
134
|
+
2. **Wiring into existing per-button gating (~1.5-2.5 hours, mostly `telescopewidget.py`).**
|
|
135
|
+
- `filterwidget.py`, `modewidget.py`: trivial — one `setEnabled()` call site each, already gated
|
|
136
|
+
on state; AND in `"set_filter" in self._permitted` / `"set_mode" in self._permitted`.
|
|
137
|
+
- `compassmovewidget.py`: **no internal changes needed** — already gated as a whole block by its
|
|
138
|
+
parent, `self.compassmovewidget.setEnabled(initialized)` at `telescopewidget.py:288`. Just AND
|
|
139
|
+
the relevant offset method's permission into that one line.
|
|
140
|
+
- `telescopewidget.py`: the real work — ~8-10 buttons (`buttonInit`, `buttonPark`, `buttonStop`,
|
|
141
|
+
`buttonMove`, 4x offset buttons, 2x reset-offset buttons), each needing its existing
|
|
142
|
+
`update_gui()` `setEnabled()` line extended with a permission check. `buttonMove` is the one
|
|
143
|
+
wrinkle: it maps to 4 different RPC methods (`move_radec`/`move_altaz`/`move_hgs`/
|
|
144
|
+
`move_helioprojective`) depending on the selected coordinate tab — but `move()`
|
|
145
|
+
(lines 328-392) already branches per-coordinate-type at click time and pops a
|
|
146
|
+
`QMessageBox.critical` for unsupported types, so the permission check slots into that existing
|
|
147
|
+
branch rather than requiring new architecture.
|
|
148
|
+
|
|
149
|
+
3. **Verification (~1 hour).** UI behavior, not something type-checking catches — needs a manual
|
|
150
|
+
pass (or a `LocalComm`-backed integration test) against a module with an `acl:` block denying a
|
|
151
|
+
couple of methods, confirming the right buttons actually grey out.
|
|
152
|
+
|
|
153
|
+
Option A is a strict subset of Option B's work, so nothing here is wasted if Option B follows later.
|
|
154
|
+
|
|
155
|
+
</details>
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# IAcquisition widget
|
|
2
|
+
|
|
3
|
+
**Status:** implemented and shipped.
|
|
4
|
+
|
|
5
|
+
## Shipped (pyobs-core, `develop`)
|
|
6
|
+
|
|
7
|
+
`IAcquisition` (`pyobs/interfaces/IAcquisition.py`) inherits `IRunning` and `IAbortable`:
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
class IAcquisition(IRunning, IAbortable, metaclass=ABCMeta):
|
|
11
|
+
state = AcquisitionState
|
|
12
|
+
|
|
13
|
+
async def acquire_target(self, **kwargs: Any) -> AcquisitionResult: ...
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
`Acquisition.acquire_target()` publishes `RunningState` around the run, same pattern as
|
|
17
|
+
`IAutoFocus`. Per-attempt telemetry is published live via a dedicated growing state, mirroring
|
|
18
|
+
`AutoFocusState`:
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
@dataclass
|
|
22
|
+
class AcquisitionResult:
|
|
23
|
+
time: Time
|
|
24
|
+
ra: Annotated[float, Unit.DEGREES]
|
|
25
|
+
dec: Annotated[float, Unit.DEGREES]
|
|
26
|
+
alt: Annotated[float, Unit.DEGREES]
|
|
27
|
+
az: Annotated[float, Unit.DEGREES]
|
|
28
|
+
offset_frame: OffsetFrame | None = None
|
|
29
|
+
offset_lon: Annotated[float, Unit.DEGREES] | None = None
|
|
30
|
+
offset_lat: Annotated[float, Unit.DEGREES] | None = None
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class AcquisitionAttempt: # AcquisitionState.attempts element
|
|
34
|
+
attempt: int
|
|
35
|
+
distance: Annotated[float, Unit.ARCSEC]
|
|
36
|
+
offset_applied: bool
|
|
37
|
+
offset_frame: OffsetFrame | None = None
|
|
38
|
+
offset_lon: Annotated[float, Unit.DEGREES] | None = None
|
|
39
|
+
offset_lat: Annotated[float, Unit.DEGREES] | None = None
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class AcquisitionState:
|
|
43
|
+
attempts: list[AcquisitionAttempt] = field(default_factory=list)
|
|
44
|
+
result: AcquisitionResult | None = None
|
|
45
|
+
time: Time = field(default_factory=Time.now)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Note this is `offset_frame`/`offset_lon`/`offset_lat`, not the originally-sketched
|
|
49
|
+
`off_ra`/`off_dec`/`off_alt`/`off_az` (see "Resolved from the original open questions" below) —
|
|
50
|
+
`off_ra`/`off_dec` and `off_alt`/`off_az` were mutually-exclusive duplicate field pairs, since only
|
|
51
|
+
one is ever populated depending on mount type; a single `offset_frame` enum + generic `lon`/`lat`
|
|
52
|
+
pair replaces both. `OffsetFrame` itself lives in `pyobs/utils/enums.py` (alongside `Unit`), not in
|
|
53
|
+
this file — it's shared with `IAutoGuiding` and `pyobs.utils.offsets`.
|
|
54
|
+
|
|
55
|
+
`offset_frame`/`offset_lon`/`offset_lat` report the telescope's **cumulative** offset (queried via
|
|
56
|
+
`IOffsetsRaDec`/`IOffsetsAltAz` state, through a small `Acquisition._get_offsets()` helper), fetched
|
|
57
|
+
after each attempt applies a correction and once more for the final result. This is deliberately
|
|
58
|
+
different from `IAutoGuiding`'s `GuidingState`, which reports the size of each individual correction,
|
|
59
|
+
not a cumulative value — see `DEV_IAutoGuiding.md` for why the two interfaces need different
|
|
60
|
+
semantics despite looking superficially similar.
|
|
61
|
+
|
|
62
|
+
`Acquisition._acquire()` publishes a new `AcquisitionAttempt` as soon as the distance is known
|
|
63
|
+
(before applying the correction, so the distance plot updates immediately), then republishes the
|
|
64
|
+
same attempt with `offset_frame`/`offset_lon`/`offset_lat` filled in once the correction is applied.
|
|
65
|
+
|
|
66
|
+
## Widget design (pyobs-gui) — shipped
|
|
67
|
+
|
|
68
|
+
Same visual language as the `IAutoFocus` widget, but with **two** plots side by side
|
|
69
|
+
(`plt.subplots(1, 2)` in a single `framePlot`):
|
|
70
|
+
|
|
71
|
+
- left (`ax`): distance-to-target (arcsec) vs. attempt number, integer x-axis ticks
|
|
72
|
+
(`MaxNLocator(integer=True)`)
|
|
73
|
+
- right (`ax2`): 2D trajectory of the accumulated offset in whichever frame the mount supports
|
|
74
|
+
(RA/Dec or Alt/Az, degrees), points connected by a line since this is a single converging run,
|
|
75
|
+
with distinct "start" (red square) and "latest" (green star) markers and reference crosshairs at
|
|
76
|
+
(0, 0) — this is what actually motivated moving to `offset_frame`/`offset_lon`/`offset_lat`, since
|
|
77
|
+
a fixed set of four `off_*` labels couldn't drive a "show whichever pair is real" plot cleanly
|
|
78
|
+
- `buttonAcquire` (green) / `buttonAbort` (red), grey out via ACL (`permitted("acquire_target")`/
|
|
79
|
+
`permitted("abort")`, same convention as `telescopewidget.py`/`filterwidget.py`/`modewidget.py`)
|
|
80
|
+
- `labelStatus` — Idle / Acquiring... / `Acquired.`
|
|
81
|
+
- Result group box: RA/Dec, Alt/Az, and a single offset row whose label text switches between
|
|
82
|
+
"RA/Dec offset:"/"Alt/Az offset:" based on `AcquisitionResult.offset_frame`
|
|
83
|
+
|
|
84
|
+
State handling (`_on_running_state`/`_on_acquisition_state`) matches the original sketch: clear
|
|
85
|
+
`self._attempts`/`self._result` on the `IRunning` rising edge, otherwise just mirror
|
|
86
|
+
`AcquisitionState.attempts`/`.result` and re-render on every update.
|
|
87
|
+
|
|
88
|
+
## Resolved from the original open questions
|
|
89
|
+
|
|
90
|
+
- ~~`off_ra`/`off_dec` vs `off_alt`/`off_az` are mutually exclusive~~ — resolved by replacing both
|
|
91
|
+
pairs with a single `offset_frame` (`OffsetFrame.RA_DEC`/`ALT_AZ`) + `offset_lon`/`offset_lat`
|
|
92
|
+
pair, in both `AcquisitionResult` and `AcquisitionAttempt`. The widget picks the right axis
|
|
93
|
+
labels/result-row text from `offset_frame` instead of checking which pair is non-`None`.
|
|
94
|
+
- ~~Should `attempts` be capped~~ — left uncapped, as expected: it's naturally bounded by
|
|
95
|
+
`self._attempts` (the configured max attempt count), same reasoning as before.
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# IAutoFocus widget
|
|
2
|
+
|
|
3
|
+
**Status:** design proposal — not implemented yet.
|
|
4
|
+
|
|
5
|
+
## Current state (pyobs-core, `develop`)
|
|
6
|
+
|
|
7
|
+
`IAutoFocus` (`pyobs/interfaces/IAutoFocus.py:30`) inherits only `IAbortable`, not `IRunning`:
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
class IAutoFocus(IAbortable, metaclass=ABCMeta):
|
|
11
|
+
state = AutoFocusState # points: list[AutoFocusPoint], time
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
`AutoFocusState.points` (`IAutoFocus.py:25`) grows during a run — the sole implementation,
|
|
15
|
+
`AutoFocusSeries` (`pyobs/modules/focus/focusseries.py`), calls
|
|
16
|
+
`set_state(IAutoFocus, AutoFocusState(points=...))` once per focus step (`focusseries.py:224`)
|
|
17
|
+
after resetting the series at the start of `_auto_focus()`.
|
|
18
|
+
|
|
19
|
+
The module tracks a `self._running` flag locally (`focusseries.py:68`, set `True`/`False` around
|
|
20
|
+
the run at `126`/`131`) but never publishes it — there is no live "is this thing running" signal on
|
|
21
|
+
the wire. The only completion signal is `FocusFoundEvent` (`pyobs/events/focusfound.py`), sent once
|
|
22
|
+
on success; nothing is sent on failure or abort.
|
|
23
|
+
|
|
24
|
+
## Gap
|
|
25
|
+
|
|
26
|
+
A widget that didn't itself trigger the run (e.g. the scheduler ran an autofocus) has no way to know
|
|
27
|
+
a run started, and no way to know a run ended in *failure* — points just stop growing, silently.
|
|
28
|
+
|
|
29
|
+
## Proposed pyobs-core change
|
|
30
|
+
|
|
31
|
+
Add `IRunning` to `IAutoFocus`'s bases, and have `AutoFocusSeries` actually publish it:
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
# IAutoFocus.py
|
|
35
|
+
from .IRunning import IRunning, RunningState
|
|
36
|
+
|
|
37
|
+
class IAutoFocus(IRunning, IAbortable, metaclass=ABCMeta):
|
|
38
|
+
...
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
# focusseries.py
|
|
43
|
+
async def auto_focus(self, count: int, step: float, exposure_time: float, **kwargs: Any) -> AutoFocusResult:
|
|
44
|
+
try:
|
|
45
|
+
self._running = True
|
|
46
|
+
await self.comm.set_state(IRunning, RunningState(running=True))
|
|
47
|
+
focus, error = await self._auto_focus(count, step, exposure_time, **kwargs)
|
|
48
|
+
return AutoFocusResult(focus=focus, focus_err=error)
|
|
49
|
+
finally:
|
|
50
|
+
self._running = False
|
|
51
|
+
await self.comm.set_state(IRunning, RunningState(running=False))
|
|
52
|
+
|
|
53
|
+
async def is_running(self, **kwargs: Any) -> bool:
|
|
54
|
+
return self._running
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
`AutoFocusState` and `RunningState` are independent pubsub topics (`set_state`/`subscribe_state` key
|
|
58
|
+
off the interface class passed in — confirmed against `pyobs/comm/comm.py:468,544`), so this
|
|
59
|
+
doesn't conflict with the existing points state, and mirrors the pattern `FocusWidget` already uses
|
|
60
|
+
for `IFocuser` + `IMotion`.
|
|
61
|
+
|
|
62
|
+
## Widget design (pyobs-gui)
|
|
63
|
+
|
|
64
|
+
A focus/value scatter plot, matplotlib-in-a-frame the same way `TemperaturesPlotWidget` does it
|
|
65
|
+
(`pyobs_gui/temperaturesplotwidget.py`), plus run controls.
|
|
66
|
+
|
|
67
|
+
Layout:
|
|
68
|
+
- `framePlot` — matplotlib canvas, x = focus, y = metric
|
|
69
|
+
- `spinCount` / `spinStep` / `spinExposureTime` — the three `auto_focus()` params
|
|
70
|
+
- `buttonRunAutoFocus` (green) / `buttonAbort` (red)
|
|
71
|
+
- `labelStatus` — Idle / Running... / `Focus: 12.345 ± 0.012 mm`
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
class AutoFocusWidget(BaseWidget, Ui_AutoFocusWidget):
|
|
75
|
+
signal_update_gui = QtCore.Signal()
|
|
76
|
+
|
|
77
|
+
def __init__(self, **kwargs: Any):
|
|
78
|
+
BaseWidget.__init__(self, **kwargs)
|
|
79
|
+
self.setupUi(self)
|
|
80
|
+
|
|
81
|
+
self._points: list[AutoFocusPoint] = []
|
|
82
|
+
self._running = False
|
|
83
|
+
self._last_result: tuple[float, float] | None = None
|
|
84
|
+
|
|
85
|
+
self.figure, self.ax = plt.subplots()
|
|
86
|
+
layout = QtWidgets.QVBoxLayout(self.framePlot)
|
|
87
|
+
self.canvas = FigureCanvas(self.figure)
|
|
88
|
+
layout.addWidget(self.canvas)
|
|
89
|
+
|
|
90
|
+
self.signal_update_gui.connect(self.update_gui)
|
|
91
|
+
self.buttonRunAutoFocus.clicked.connect(self._run_auto_focus)
|
|
92
|
+
self.buttonAbort.clicked.connect(self._abort)
|
|
93
|
+
self.colorize_button(self.buttonRunAutoFocus, QtCore.Qt.GlobalColor.green)
|
|
94
|
+
self.colorize_button(self.buttonAbort, QtCore.Qt.GlobalColor.red)
|
|
95
|
+
|
|
96
|
+
async def _init(self) -> None:
|
|
97
|
+
await self.comm.subscribe_state(self.module, IRunning, self._on_running_state)
|
|
98
|
+
await self.comm.subscribe_state(self.module, IAutoFocus, self._on_autofocus_state)
|
|
99
|
+
await self.comm.register_event(FocusFoundEvent, self._on_focus_found)
|
|
100
|
+
|
|
101
|
+
def _on_running_state(self, state: RunningState) -> None:
|
|
102
|
+
if state.running and not self._running:
|
|
103
|
+
# rising edge -> a new run started (possibly triggered elsewhere), clear the plot
|
|
104
|
+
self._points = []
|
|
105
|
+
self._last_result = None
|
|
106
|
+
self._running = state.running
|
|
107
|
+
self.signal_update_gui.emit()
|
|
108
|
+
|
|
109
|
+
def _on_autofocus_state(self, state: AutoFocusState) -> None:
|
|
110
|
+
self._points = state.points
|
|
111
|
+
self.signal_update_gui.emit()
|
|
112
|
+
|
|
113
|
+
def _on_focus_found(self, event: FocusFoundEvent, sender: str) -> None:
|
|
114
|
+
if sender != self.module:
|
|
115
|
+
return
|
|
116
|
+
self._last_result = (event.focus, event.error or 0.0)
|
|
117
|
+
self.signal_update_gui.emit()
|
|
118
|
+
|
|
119
|
+
def update_gui(self) -> None:
|
|
120
|
+
self.buttonRunAutoFocus.setEnabled(not self._running)
|
|
121
|
+
self.buttonAbort.setEnabled(self._running)
|
|
122
|
+
self.labelStatus.setText(
|
|
123
|
+
"Running..." if self._running
|
|
124
|
+
else f"Focus: {self._last_result[0]:.3f} \u00b1 {self._last_result[1]:.3f} mm" if self._last_result
|
|
125
|
+
else "Idle"
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
self.ax.clear()
|
|
129
|
+
if self._points:
|
|
130
|
+
self.ax.scatter([p.focus for p in self._points], [p.value for p in self._points], color="tab:blue")
|
|
131
|
+
if self._last_result and not self._running:
|
|
132
|
+
self.ax.axvline(self._last_result[0], color="tab:green", linestyle="--", label="fitted focus")
|
|
133
|
+
self.ax.legend()
|
|
134
|
+
self.ax.set_xlabel("Focus [mm]")
|
|
135
|
+
self.ax.set_ylabel("Metric")
|
|
136
|
+
self.ax.grid(linestyle=":", alpha=0.5)
|
|
137
|
+
self.ax.set_axisbelow(True)
|
|
138
|
+
self.canvas.draw()
|
|
139
|
+
|
|
140
|
+
@qasync.asyncSlot() # type: ignore
|
|
141
|
+
async def _run_auto_focus(self) -> None:
|
|
142
|
+
async with self.comm.proxy(self.module, IAutoFocus) as proxy:
|
|
143
|
+
await proxy.auto_focus(self.spinCount.value(), self.spinStep.value(), self.spinExposureTime.value())
|
|
144
|
+
|
|
145
|
+
@qasync.asyncSlot() # type: ignore
|
|
146
|
+
async def _abort(self) -> None:
|
|
147
|
+
async with self.comm.proxy(self.module, IAutoFocus) as proxy:
|
|
148
|
+
await proxy.abort()
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
The widget is entirely state-driven: `_run_auto_focus`/`_abort` just fire RPCs, and the display
|
|
152
|
+
updates purely from what the module publishes — correct with multiple GUI instances open, or a
|
|
153
|
+
scheduler-triggered run, unlike a design that tracks "am I running" as local widget state.
|
|
154
|
+
|
|
155
|
+
## Open questions
|
|
156
|
+
|
|
157
|
+
- `FocusFoundEvent` still isn't sent on failure/abort — with `RunningState` in place the widget at
|
|
158
|
+
least knows the run *ended*, but not *why*. Worth a follow-up event (`FocusFailedEvent`?) or just
|
|
159
|
+
leaving "Idle" as the catch-all for "not running, no success yet" and letting the operator check
|
|
160
|
+
logs for failures.
|
|
161
|
+
- Should the fitted-focus vertical line persist across the next run's points, or clear immediately
|
|
162
|
+
on the next rising edge? Current design clears it on rising edge (new run hides the old fit).
|