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.
Files changed (135) hide show
  1. pyobs_gui-2.0.0.dev2/.claude/skills/verify/SKILL.md +103 -0
  2. pyobs_gui-2.0.0.dev2/.github/dependabot.yml +10 -0
  3. pyobs_gui-2.0.0.dev2/DEVELOPMENT.md +155 -0
  4. pyobs_gui-2.0.0.dev2/DEV_IAcquisition.md +95 -0
  5. pyobs_gui-2.0.0.dev2/DEV_IAutoFocus.md +162 -0
  6. pyobs_gui-2.0.0.dev2/DEV_IAutoGuiding.md +156 -0
  7. pyobs_gui-2.0.0.dev2/DEV_NavbarShortcuts.md +286 -0
  8. pyobs_gui-2.0.0.dev2/DEV_qml_evaluation.md +218 -0
  9. pyobs_gui-2.0.0.dev2/DEV_telescopewidget_layout.md +199 -0
  10. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/PKG-INFO +2 -2
  11. pyobs_gui-2.0.0.dev2/pyobs_gui/acquisitionwidget.py +152 -0
  12. pyobs_gui-2.0.0.dev2/pyobs_gui/autofocuswidget.py +109 -0
  13. pyobs_gui-2.0.0.dev2/pyobs_gui/autoguidingwidget.py +150 -0
  14. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/base.py +59 -4
  15. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/camerawidget.py +26 -10
  16. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/compassmovewidget.py +13 -6
  17. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/datadisplaywidget.py +2 -2
  18. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/filterwidget.py +4 -1
  19. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/fitsheaderswidget.py +7 -5
  20. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/gui.py +2 -2
  21. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/mainwindow.py +222 -21
  22. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/modewidget.py +21 -19
  23. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/modulegui.py +2 -2
  24. pyobs_gui-2.0.0.dev2/pyobs_gui/qt/acquisitionwidget.ui +173 -0
  25. pyobs_gui-2.0.0.dev2/pyobs_gui/qt/acquisitionwidget_ui.py +147 -0
  26. pyobs_gui-2.0.0.dev2/pyobs_gui/qt/autofocuswidget.ui +163 -0
  27. pyobs_gui-2.0.0.dev2/pyobs_gui/qt/autofocuswidget_ui.py +132 -0
  28. pyobs_gui-2.0.0.dev2/pyobs_gui/qt/autoguidingwidget.ui +161 -0
  29. pyobs_gui-2.0.0.dev2/pyobs_gui/qt/autoguidingwidget_ui.py +131 -0
  30. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/mainwindow.ui +24 -30
  31. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/mainwindow_ui.py +17 -23
  32. pyobs_gui-2.0.0.dev2/pyobs_gui/statuswidget.py +294 -0
  33. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/telescopewidget.py +116 -26
  34. pyobs_gui-2.0.0.dev2/pyobs_gui/weatherwidget.py +152 -0
  35. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyproject.toml +2 -2
  36. pyobs_gui-2.0.0.dev2/test/acquisition.yaml +23 -0
  37. pyobs_gui-2.0.0.dev2/test/autofocus.yaml +23 -0
  38. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/test/full.yaml +22 -1
  39. pyobs_gui-2.0.0.dev2/test/guiding.yaml +23 -0
  40. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/test/telescope.yaml +1 -1
  41. pyobs_gui-2.0.0.dev2/test/telescope_acl.yaml +27 -0
  42. pyobs_gui-2.0.0.dev2/test/telescope_acl_denied.yaml +27 -0
  43. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/uv.lock +5 -5
  44. pyobs_gui-2.0.0.dev1/pyobs_gui/statuswidget.py +0 -147
  45. pyobs_gui-2.0.0.dev1/pyobs_gui/weatherwidget.py +0 -160
  46. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/.github/workflows/pypi.yml +0 -0
  47. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/.github/workflows/pyrefly.yml +0 -0
  48. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/.github/workflows/ruff.yml +0 -0
  49. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/.pre-commit-config.yaml +0 -0
  50. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/.readthedocs.yml +0 -0
  51. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/CHANGELOG.rst +0 -0
  52. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/LICENSE +0 -0
  53. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/README.md +0 -0
  54. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/bin/mypyuic5 +0 -0
  55. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/docs/Makefile +0 -0
  56. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/docs/make.bat +0 -0
  57. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/docs/requirements.txt +0 -0
  58. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/docs/source/_static/pyobs.gif +0 -0
  59. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/docs/source/conf.py +0 -0
  60. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/docs/source/index.rst +0 -0
  61. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/mypy.ini +0 -0
  62. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/__init__.py +0 -0
  63. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/commandinputwidget.py +0 -0
  64. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/coolingwidget.py +0 -0
  65. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/eventswidget.py +0 -0
  66. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/focuswidget.py +0 -0
  67. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/logmodel.py +0 -0
  68. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/modifiedmixin.py +0 -0
  69. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/__init__.py +0 -0
  70. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/camerawidget.ui +0 -0
  71. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/camerawidget_ui.py +0 -0
  72. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/compassmoveplugin.py +0 -0
  73. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/compassmovewidget.py +0 -0
  74. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/compassmovewidget.ui +0 -0
  75. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/compassmovewidget_ui.py +0 -0
  76. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/compile.sh +0 -0
  77. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/coolingwidget.ui +0 -0
  78. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/coolingwidget_ui.py +0 -0
  79. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/datadisplayplugin.py +0 -0
  80. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/datadisplaywidget.ui +0 -0
  81. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/datadisplaywidget_ui.py +0 -0
  82. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/designer.sh +0 -0
  83. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/eventswidget.ui +0 -0
  84. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/eventswidget_ui.py +0 -0
  85. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/filterwidget.ui +0 -0
  86. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/filterwidget_ui.py +0 -0
  87. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/fitsheaderswidget.ui +0 -0
  88. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/fitsheaderswidget_ui.py +0 -0
  89. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/focuswidget.ui +0 -0
  90. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/focuswidget_ui.py +0 -0
  91. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/modewidget.ui +0 -0
  92. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/modewidget_ui.py +0 -0
  93. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/resources/arrow-alt-circle-down-solid.svg +0 -0
  94. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/resources/arrow-alt-circle-left-solid.svg +0 -0
  95. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/resources/arrow-alt-circle-right-solid.svg +0 -0
  96. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/resources/arrow-alt-circle-up-solid.svg +0 -0
  97. {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
  98. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/resources/arrows-to-eye-solid.svg +0 -0
  99. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/resources/chart-line-solid.svg +0 -0
  100. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/resources/circle-question-solid.svg +0 -0
  101. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/resources/cloud-sun-solid.svg +0 -0
  102. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/resources/edit-solid.svg +0 -0
  103. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/resources/search-solid.svg +0 -0
  104. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/resources/undo-solid.svg +0 -0
  105. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/resources.qrc +0 -0
  106. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/resources_rc.py +0 -0
  107. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/roofwidget.ui +0 -0
  108. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/roofwidget_ui.py +0 -0
  109. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/shellwidget.ui +0 -0
  110. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/shellwidget_ui.py +0 -0
  111. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/spectrographwidget.ui +0 -0
  112. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/spectrographwidget_ui.py +0 -0
  113. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/telescopewidget.ui +0 -0
  114. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/telescopewidget_ui.py +0 -0
  115. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/temperaturesplotwidget.ui +0 -0
  116. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/temperaturesplotwidget_ui.py +0 -0
  117. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/temperatureswidget.ui +0 -0
  118. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/temperatureswidget_ui.py +0 -0
  119. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/videowidget.ui +0 -0
  120. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/videowidget_ui.py +0 -0
  121. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/weatherwidget.ui +0 -0
  122. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/qt/weatherwidget_ui.py +0 -0
  123. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/roofwidget.py +0 -0
  124. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/shellwidget.py +0 -0
  125. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/spectrographwidget.py +0 -0
  126. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/temperaturesplotwidget.py +0 -0
  127. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/temperatureswidget.py +0 -0
  128. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/utils.py +0 -0
  129. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/videowidget.py +0 -0
  130. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/pyobs_gui/watchedlabel.py +0 -0
  131. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/test/camera.yaml +0 -0
  132. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/test/mode.yaml +0 -0
  133. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/test/roof.yaml +0 -0
  134. {pyobs_gui-2.0.0.dev1 → pyobs_gui-2.0.0.dev2}/test/spectrograph.yaml +0 -0
  135. {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,10 @@
1
+ version: 2
2
+ updates:
3
+ - package-ecosystem: "uv"
4
+ directory: "/"
5
+ schedule:
6
+ interval: "weekly"
7
+ day: "tuesday"
8
+ target-branch: "develop"
9
+ labels:
10
+ - "dependencies"
@@ -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).