pyobs-gui 2.0.0.dev19__tar.gz → 2.0.0.dev21__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 (148) hide show
  1. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/PKG-INFO +2 -2
  2. pyobs_gui-2.0.0.dev21/README.md +28 -0
  3. pyobs_gui-2.0.0.dev21/docs/source/index.rst +140 -0
  4. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/base.py +58 -3
  5. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/camerawidget.py +42 -10
  6. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/mainwindow.py +143 -34
  7. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/shellwidget.py +42 -6
  8. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/telescopewidget.py +22 -5
  9. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/videowidget.py +11 -1
  10. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyproject.toml +4 -4
  11. pyobs_gui-2.0.0.dev21/specs/2026-08-21-gui-widget-startup-responsiveness.md +397 -0
  12. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/specs/index.md +7 -0
  13. pyobs_gui-2.0.0.dev21/tests/test_mainwindow_startup.py +322 -0
  14. pyobs_gui-2.0.0.dev21/tests/test_videowidget.py +82 -0
  15. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/uv.lock +77 -42
  16. pyobs_gui-2.0.0.dev19/README.md +0 -3
  17. pyobs_gui-2.0.0.dev19/docs/source/index.rst +0 -51
  18. pyobs_gui-2.0.0.dev19/tests/test_videowidget.py +0 -38
  19. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/.claude/skills/verify/SKILL.md +0 -0
  20. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/.github/dependabot.yml +0 -0
  21. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/.github/workflows/pypi.yml +0 -0
  22. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/.github/workflows/pyrefly.yml +0 -0
  23. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/.github/workflows/ruff.yml +0 -0
  24. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/.gitignore +0 -0
  25. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/.pre-commit-config.yaml +0 -0
  26. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/.readthedocs.yml +0 -0
  27. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/CHANGELOG.rst +0 -0
  28. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/LICENSE +0 -0
  29. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/bin/mypyuic5 +0 -0
  30. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/docs/Makefile +0 -0
  31. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/docs/make.bat +0 -0
  32. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/docs/requirements.txt +0 -0
  33. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/docs/source/_static/pyobs.gif +0 -0
  34. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/docs/source/conf.py +0 -0
  35. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/mypy.ini +0 -0
  36. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/__init__.py +0 -0
  37. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/__main__.py +0 -0
  38. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/_nuitka_astropy_patch.py +0 -0
  39. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/accounts.py +0 -0
  40. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/acquisitionwidget.py +0 -0
  41. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/autofocuswidget.py +0 -0
  42. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/autoguidingwidget.py +0 -0
  43. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/commandinputwidget.py +0 -0
  44. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/compassmovewidget.py +0 -0
  45. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/coolingwidget.py +0 -0
  46. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/datadisplaywidget.py +0 -0
  47. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/eventswidget.py +0 -0
  48. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/filterwidget.py +0 -0
  49. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/fitsheaderswidget.py +0 -0
  50. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/focuswidget.py +0 -0
  51. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/gui.py +0 -0
  52. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/login.py +0 -0
  53. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/loginwindow.py +0 -0
  54. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/logmodel.py +0 -0
  55. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/modewidget.py +0 -0
  56. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/modifiedmixin.py +0 -0
  57. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/modulegui.py +0 -0
  58. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/qt/__init__.py +0 -0
  59. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/qt/acquisitionwidget.ui +0 -0
  60. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/qt/acquisitionwidget_ui.py +0 -0
  61. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/qt/autofocuswidget.ui +0 -0
  62. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/qt/autofocuswidget_ui.py +0 -0
  63. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/qt/autoguidingwidget.ui +0 -0
  64. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/qt/autoguidingwidget_ui.py +0 -0
  65. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/qt/camerawidget.ui +0 -0
  66. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/qt/camerawidget_ui.py +0 -0
  67. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/qt/compassmoveplugin.py +0 -0
  68. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/qt/compassmovewidget.py +0 -0
  69. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/qt/compassmovewidget.ui +0 -0
  70. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/qt/compassmovewidget_ui.py +0 -0
  71. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/qt/compile.sh +0 -0
  72. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/qt/coolingwidget.ui +0 -0
  73. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/qt/coolingwidget_ui.py +0 -0
  74. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/qt/datadisplayplugin.py +0 -0
  75. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/qt/datadisplaywidget.ui +0 -0
  76. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/qt/datadisplaywidget_ui.py +0 -0
  77. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/qt/designer.sh +0 -0
  78. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/qt/eventswidget.ui +0 -0
  79. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/qt/eventswidget_ui.py +0 -0
  80. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/qt/filterwidget.ui +0 -0
  81. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/qt/filterwidget_ui.py +0 -0
  82. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/qt/fitsheaderswidget.ui +0 -0
  83. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/qt/fitsheaderswidget_ui.py +0 -0
  84. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/qt/focuswidget.ui +0 -0
  85. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/qt/focuswidget_ui.py +0 -0
  86. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/qt/mainwindow.ui +0 -0
  87. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/qt/mainwindow_ui.py +0 -0
  88. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/qt/modewidget.ui +0 -0
  89. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/qt/modewidget_ui.py +0 -0
  90. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/qt/resources/arrow-alt-circle-down-solid.svg +0 -0
  91. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/qt/resources/arrow-alt-circle-left-solid.svg +0 -0
  92. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/qt/resources/arrow-alt-circle-right-solid.svg +0 -0
  93. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/qt/resources/arrow-alt-circle-up-solid.svg +0 -0
  94. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/qt/resources/arrow-up-right-from-square-solid.svg +0 -0
  95. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/qt/resources/arrows-to-eye-solid.svg +0 -0
  96. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/qt/resources/chart-line-solid.svg +0 -0
  97. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/qt/resources/circle-question-solid.svg +0 -0
  98. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/qt/resources/cloud-sun-solid.svg +0 -0
  99. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/qt/resources/edit-solid.svg +0 -0
  100. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/qt/resources/search-solid.svg +0 -0
  101. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/qt/resources/undo-solid.svg +0 -0
  102. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/qt/resources.qrc +0 -0
  103. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/qt/resources_rc.py +0 -0
  104. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/qt/roofwidget.ui +0 -0
  105. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/qt/roofwidget_ui.py +0 -0
  106. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/qt/shellwidget.ui +0 -0
  107. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/qt/shellwidget_ui.py +0 -0
  108. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/qt/spectrographwidget.ui +0 -0
  109. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/qt/spectrographwidget_ui.py +0 -0
  110. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/qt/telescopewidget.ui +0 -0
  111. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/qt/telescopewidget_ui.py +0 -0
  112. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/qt/temperaturesplotwidget.ui +0 -0
  113. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/qt/temperaturesplotwidget_ui.py +0 -0
  114. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/qt/temperatureswidget.ui +0 -0
  115. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/qt/temperatureswidget_ui.py +0 -0
  116. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/qt/videowidget.ui +0 -0
  117. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/qt/videowidget_ui.py +0 -0
  118. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/qt/weatherwidget.ui +0 -0
  119. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/qt/weatherwidget_ui.py +0 -0
  120. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/roofwidget.py +0 -0
  121. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/spectrographwidget.py +0 -0
  122. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/statuswidget.py +0 -0
  123. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/temperaturesplotwidget.py +0 -0
  124. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/temperatureswidget.py +0 -0
  125. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/utils.py +0 -0
  126. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/watchedlabel.py +0 -0
  127. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pyobs_gui/weatherwidget.py +0 -0
  128. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/pysidedeploy.spec +0 -0
  129. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/specs/2026-08-20-gui-remote-call-error-handling.md +0 -0
  130. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/spike_standalone/main.py +0 -0
  131. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/spike_standalone/pysidedeploy.spec +0 -0
  132. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/test/acquisition.yaml +0 -0
  133. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/test/autofocus.yaml +0 -0
  134. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/test/camera.yaml +0 -0
  135. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/test/full.yaml +0 -0
  136. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/test/guiding.yaml +0 -0
  137. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/test/mode.yaml +0 -0
  138. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/test/roof.yaml +0 -0
  139. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/test/spectrograph.yaml +0 -0
  140. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/test/telescope.yaml +0 -0
  141. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/test/telescope_acl.yaml +0 -0
  142. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/test/telescope_acl_denied.yaml +0 -0
  143. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/test/video.yaml +0 -0
  144. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/tests/__init__.py +0 -0
  145. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/tests/conftest.py +0 -0
  146. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/tests/test_accounts.py +0 -0
  147. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/tests/test_camerawidget.py +0 -0
  148. {pyobs_gui-2.0.0.dev19 → pyobs_gui-2.0.0.dev21}/tests/test_loginwindow.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pyobs-gui
3
- Version: 2.0.0.dev19
3
+ Version: 2.0.0.dev21
4
4
  Summary: A remote GUI for pyobs
5
5
  Author-email: Tim-Oliver Husser <thusser@uni-goettingen.de>
6
6
  License-Expression: MIT
@@ -11,7 +11,7 @@ Requires-Dist: astropy<8,>=7.0.1
11
11
  Requires-Dist: colour<0.2,>=0.1.5
12
12
  Requires-Dist: keyring<26,>=25.7.0
13
13
  Requires-Dist: matplotlib<4,>=3.10.1
14
- Requires-Dist: pyobs-core<3,>=2.0.0.dev78
14
+ Requires-Dist: pyobs-core<3,>=2.0.0.dev93
15
15
  Requires-Dist: pyside6>=6.10.1
16
16
  Requires-Dist: qasync<0.29,>=0.27.1
17
17
  Requires-Dist: qfitswidget>=0.13.1
@@ -0,0 +1,28 @@
1
+ pyobs-gui
2
+ =========
3
+
4
+ This is a [pyobs](https://www.pyobs.org) GUI for operating the whole system: one page per
5
+ connected module (camera, telescope, roof, focuser, ...), plus Shell/Events/Status tool pages.
6
+
7
+
8
+ Install *pyobs-gui*
9
+ ---------------------
10
+ Clone the repository:
11
+
12
+ git clone https://github.com/pyobs/pyobs-gui.git
13
+ cd pyobs-gui
14
+
15
+ Install it with [uv](https://docs.astral.sh/uv/):
16
+
17
+ uv sync
18
+
19
+
20
+ Running
21
+ -------
22
+ Two ways to start it:
23
+
24
+ - `uv run pyobs-gui` — standalone, interactive login. No YAML config needed; it prompts for XMPP
25
+ credentials (with an optional "store password") and connects directly.
26
+ - `uv run pyobs <config.yaml>` — the standard `pyobs` CLI, driven by a config file whose top-level
27
+ class is `pyobs_gui.GUI` (see `docs/source/index.rst` for a full example, including how to wire
28
+ in a custom widget for a module).
@@ -0,0 +1,140 @@
1
+ pyobs-gui
2
+ #########
3
+
4
+ This is a `pyobs <https://www.pyobs.org>`_ (`documentation <https://docs.pyobs.org>`_) module providing a GUI
5
+ for operating the whole system.
6
+
7
+
8
+ Example configuration
9
+ **********************
10
+
11
+ This is an example configuration for a GUI that defines one custom widget for the ``guiding`` module::
12
+
13
+ class: pyobs_gui.GUI
14
+
15
+ widgets:
16
+ - module: guiding
17
+ overwrite: True
18
+ widget:
19
+ class: mypackage.GuidingWidget
20
+
21
+ comm:
22
+ jid: test@example.com
23
+ password: ***
24
+
25
+ timezone: Europe/Berlin
26
+ location:
27
+ longitude: 9.944333
28
+ latitude: 51.560583
29
+ elevation: 201.
30
+
31
+ vfs:
32
+ class: pyobs.vfs.VirtualFileSystem
33
+ roots:
34
+ cache:
35
+ class: pyobs.vfs.HttpFile
36
+ download: http://localhost:37075/
37
+ webcam:
38
+ class: pyobs.vfs.HttpFile
39
+ download: http://localhost:37077/
40
+
41
+
42
+ Available classes
43
+ ******************
44
+
45
+ There is one single class for the GUI.
46
+
47
+ GUI
48
+ ===
49
+ .. autoclass:: pyobs_gui.GUI
50
+ :members:
51
+ :show-inheritance:
52
+
53
+
54
+ Widgets
55
+ *******
56
+
57
+ The GUI shows one page per connected module, picking a widget by the module's interfaces
58
+ (``DEFAULT_WIDGETS`` in ``mainwindow.py``), plus three always-present "Tools" pages. No screenshots
59
+ yet — this section was written from source, not from a running GUI; add screenshots as a follow-up.
60
+
61
+ Shell
62
+ =====
63
+ Always present. A command console: type shell-like commands against any connected module, with
64
+ tab-completion driven by the module's own docstrings, and a per-module command history.
65
+
66
+ Events
67
+ ======
68
+ Always present. Live feed of events broadcast by connected modules, and lets you send one back.
69
+
70
+ Status
71
+ ======
72
+ Always present. Per-module connection/presence indicator plus a live, expandable tree of each
73
+ connected module's published state.
74
+
75
+ Camera — drives ``ICamera``
76
+ ============================
77
+ Expose/abort control: binning, gain, image format/type, exposure time, full-frame vs. windowed
78
+ readout, and (if the module supports it) data sequences.
79
+
80
+ Telescope — drives ``ITelescope``
81
+ ====================================
82
+ Init/park/stop and move-to-target, offsets in both RA/Dec and Alt/Az, Simbad and JPL Horizons
83
+ target lookup, and tracking of orbital elements or solar-system bodies.
84
+
85
+ Roof — drives ``IRoof``
86
+ ==========================
87
+ Open/close/stop the roof, with live motion and pointing state.
88
+
89
+ Focuser — drives ``IFocuser``
90
+ ================================
91
+ Set an absolute focus value, apply/reset a focus offset.
92
+
93
+ Auto Focus — drives ``IAutoFocus``
94
+ =====================================
95
+ Run or abort an autofocus sequence.
96
+
97
+ Acquisition — drives ``IAcquisition``
98
+ ========================================
99
+ Acquire a target, abort an in-progress acquisition.
100
+
101
+ Auto Guiding — drives ``IAutoGuiding``
102
+ =========================================
103
+ Start/stop guiding and set the guide-exposure time.
104
+
105
+ Weather — drives ``IWeather``
106
+ ================================
107
+ Live sensor values plus a history plot.
108
+
109
+ Video — drives ``IVideo``
110
+ ============================
111
+ Live view (MJPEG/raw stream), grab a still, adjust exposure time/gain/image type.
112
+
113
+ Spectrograph — drives ``ISpectrograph``
114
+ ==========================================
115
+ Grab a spectrum, abort an in-progress exposure.
116
+
117
+ Filter wheel — drives ``IFilters``
118
+ =====================================
119
+ Set the active filter.
120
+
121
+
122
+ Keyboard shortcuts
123
+ *******************
124
+
125
+ Global, not per-widget — bound with the ``Ctrl``/``Ctrl+Alt`` modifier specifically so no
126
+ text/numeric-entry widget can mistake one for ordinary input.
127
+
128
+ =============== ==========================================================
129
+ Shortcut Action
130
+ =============== ==========================================================
131
+ ``Ctrl+1`` Switch to Shell (fixed)
132
+ ``Ctrl+2`` Switch to Events (fixed)
133
+ ``Ctrl+3`` Switch to Status (fixed)
134
+ ``Ctrl+4``..``Ctrl+9``, ``Ctrl+0``
135
+ Switch to whichever module page is bound to that slot
136
+ (no-op if unbound or that module isn't connected)
137
+ ``Ctrl+Alt+4``..``Ctrl+Alt+9``, ``Ctrl+Alt+0``
138
+ Bind the currently selected page to that slot, overwriting
139
+ any previous binding
140
+ =============== ==========================================================
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import asyncio
4
+ import contextlib
4
5
  import logging
5
6
  from typing import TYPE_CHECKING, Any, Callable, Type, TypeVar, overload
6
7
 
@@ -44,6 +45,13 @@ async def show_remote_error(parent: QtWidgets.QWidget, exception: Exception) ->
44
45
  await QAsyncMessageBox.warning(parent, "Error", str(exception))
45
46
 
46
47
 
48
+ async def cancel_and_drain(task: asyncio.Task[Any]) -> None:
49
+ """Cancel a task and await its unwind, swallowing the resulting CancelledError."""
50
+ task.cancel()
51
+ with contextlib.suppress(asyncio.CancelledError):
52
+ await task
53
+
54
+
47
55
  class BaseWindow:
48
56
  def __init__(self) -> None:
49
57
  """Base class for MainWindow and all widgets."""
@@ -169,6 +177,14 @@ class BaseWidget(BaseWindow, QtWidgets.QWidget): # type: ignore
169
177
  # has it been initialized?
170
178
  self._initialized = False
171
179
 
180
+ # memoized one-shot init task -- two rapid show/hide/show cycles must not run _init()
181
+ # twice concurrently (see _showEvent)
182
+ self._init_task: asyncio.Task[Any] | None = None
183
+
184
+ # keys of _init_once() sub-steps that already completed, so a retried _init() (see
185
+ # _showEvent) skips steps that already ran instead of re-subscribing to comm state
186
+ self._init_steps_done: set[str] = set()
187
+
172
188
  # methods this GUI is permitted to invoke on self.module; None until fetched or if the
173
189
  # fetch failed, meaning "treat everything as permitted"
174
190
  self._permitted_methods: set[str] | None = None
@@ -251,6 +267,15 @@ class BaseWidget(BaseWindow, QtWidgets.QWidget): # type: ignore
251
267
  in Comm._event_handlers forever, keeping this widget alive and still reacting to
252
268
  events for as long as the app runs.
253
269
  """
270
+ # cancel a still-in-flight one-time _init() first: comm's own disconnect-triggered
271
+ # subscription cleanup (Comm._client_disconnected) runs before this method is called,
272
+ # so a subscribe_state() call inside _init_task that resolves afterwards would leak a
273
+ # subscription bound to this now-discarded widget -- the same stale-callback crash
274
+ # class this method otherwise guards against
275
+ if self._init_task is not None:
276
+ await cancel_and_drain(self._init_task)
277
+ self._init_task = None
278
+
254
279
  for event_class, handler in self._registered_event_handlers:
255
280
  await self.comm.unregister_event(event_class, handler)
256
281
  self._registered_event_handlers.clear()
@@ -263,14 +288,44 @@ class BaseWidget(BaseWindow, QtWidgets.QWidget): # type: ignore
263
288
  asyncio.create_task(self._showEvent(event))
264
289
 
265
290
  async def _showEvent(self, event: QtGui.QShowEvent) -> None:
266
- if self._initialized is False and hasattr(self, "_init"):
267
- await self._init()
268
- self._initialized = True
291
+ if self._initialized is False:
292
+ if self._init_task is None:
293
+ self._init_task = asyncio.create_task(self._run_init())
294
+ try:
295
+ await self._init_task
296
+ except Exception:
297
+ # transient init failure (e.g. a comm RPC hiccup): log, and allow a retry on
298
+ # the next show instead of leaving the widget permanently marked initialized
299
+ log.exception("Init of %s failed; will retry on next show.", type(self).__name__)
300
+ self._init_task = None
269
301
 
270
302
  if self._update_func:
271
303
  # start update task
272
304
  self._update_task = asyncio.create_task(self._update_loop())
273
305
 
306
+ async def _run_init(self) -> None:
307
+ await self._init()
308
+ self._initialized = True
309
+
310
+ async def _init(self) -> None:
311
+ """Default no-op init. Widgets with one-time subscription/state setup override this;
312
+ _showEvent memoizes the call so it runs exactly once per widget lifetime."""
313
+
314
+ async def _init_once(self, key: str, coro_func: Callable[..., Coroutine[Any, Any, None]], *args: Any) -> None:
315
+ """Run one sub-step of _init() at most once across retries.
316
+
317
+ _showEvent retries the whole _init() after a partial failure (e.g. one of several
318
+ gathered comm.subscribe_state() calls raising while its siblings already subscribed).
319
+ comm.subscribe_state() unconditionally appends to comm's subscription list with no
320
+ deduplication, so a sub-step that already succeeded must not run again on retry --
321
+ override _init() and wrap each independent, subscribing sub-step in this instead of
322
+ calling it directly.
323
+ """
324
+ if key in self._init_steps_done:
325
+ return
326
+ await coro_func(*args)
327
+ self._init_steps_done.add(key)
328
+
274
329
  def hideEvent(self, event: QtGui.QHideEvent) -> None:
275
330
  # run in loop
276
331
  asyncio.create_task(self._hide_event(event))
@@ -1,3 +1,4 @@
1
+ import asyncio
1
2
  import logging
2
3
  from typing import Any
3
4
  from PySide6 import QtWidgets, QtCore # type: ignore
@@ -38,6 +39,13 @@ from .qt.camerawidget_ui import Ui_CameraWidget
38
39
  log = logging.getLogger(__name__)
39
40
 
40
41
 
42
+ # how long to wait for a module's first state value when initializing each control. The comm
43
+ # default is 10 s per interface, which would let a slow-publishing camera hold the page blank
44
+ # for ~70 s in the worst case; with this shorter timeout the control just keeps its default
45
+ # value and the subscription callback corrects it as soon as state arrives.
46
+ _WAIT_FOR_STATE_TIMEOUT = 2.0
47
+
48
+
41
49
  class CameraWidget(BaseWidget, Ui_CameraWidget):
42
50
  signal_update_gui = QtCore.Signal()
43
51
  signal_new_image = QtCore.Signal(NewImageEvent, str)
@@ -108,6 +116,26 @@ class CameraWidget(BaseWidget, Ui_CameraWidget):
108
116
  await self.add_to_sidebar(self.create_widget(TemperaturesWidget, module=self.module))
109
117
 
110
118
  async def _init(self) -> None:
119
+ # every interface is initialized independently (caps -> state -> subscribe, in that
120
+ # order per interface), so run them concurrently instead of serially: a slow-publishing
121
+ # camera used to hold the page blank for up to ~70 s (7 interfaces x the 10 s default
122
+ # wait_for_state timeout); now each interface waits at most _WAIT_FOR_STATE_TIMEOUT and
123
+ # the others fill in regardless
124
+ await asyncio.gather(
125
+ self._init_once("window", self._init_window),
126
+ self._init_once("binning", self._init_binning),
127
+ self._init_once("gain", self._init_gain),
128
+ self._init_once("image_format", self._init_image_format),
129
+ self._init_once("image_type", self._init_image_type),
130
+ self._init_once("exposure", self._init_exposure),
131
+ self._init_once("exposure_time", self._init_exposure_time),
132
+ self._init_once("data_sequence", self._init_data_sequence),
133
+ )
134
+
135
+ # update GUI
136
+ self.signal_update_gui.emit()
137
+
138
+ async def _init_window(self) -> None:
111
139
  # window
112
140
  window_caps = await self.comm.get_capabilities(self.module, IWindow)
113
141
  if window_caps is not None:
@@ -117,7 +145,7 @@ class CameraWidget(BaseWidget, Ui_CameraWidget):
117
145
  self.spinWindowHeight.setMaximum(int(window_caps.full_frame_height))
118
146
  async with self.comm.safe_proxy(self.module, IWindow) as proxy:
119
147
  if proxy is not None:
120
- state = await proxy.wait_for_state(IWindow)
148
+ state = await proxy.wait_for_state(IWindow, timeout=_WAIT_FOR_STATE_TIMEOUT)
121
149
  if state is not None:
122
150
  self.spinWindowLeft.setValue(state.x)
123
151
  self.spinWindowTop.setValue(state.y)
@@ -129,6 +157,7 @@ class CameraWidget(BaseWidget, Ui_CameraWidget):
129
157
  self.spinWindowWidth.setValue(window_caps.full_frame_width)
130
158
  self.spinWindowHeight.setValue(window_caps.full_frame_height)
131
159
 
160
+ async def _init_binning(self) -> None:
132
161
  # binning
133
162
  binning_caps = await self.comm.get_capabilities(self.module, IBinning)
134
163
  if binning_caps is not None:
@@ -137,20 +166,22 @@ class CameraWidget(BaseWidget, Ui_CameraWidget):
137
166
  self.comboBinning.addItems(binnings)
138
167
  async with self.comm.safe_proxy(self.module, IBinning) as proxy:
139
168
  if proxy is not None:
140
- state = await proxy.wait_for_state(IBinning)
169
+ state = await proxy.wait_for_state(IBinning, timeout=_WAIT_FOR_STATE_TIMEOUT)
141
170
  if state is not None:
142
171
  self.comboBinning.setCurrentText(f"{state.x}x{state.y}")
143
172
  await self.comm.subscribe_state(self.module, IBinning, self._update_binning)
144
173
 
174
+ async def _init_gain(self) -> None:
145
175
  # gain
146
176
  async with self.comm.safe_proxy(self.module, IGain) as proxy:
147
177
  if proxy is not None:
148
- state = await proxy.wait_for_state(IGain)
178
+ state = await proxy.wait_for_state(IGain, timeout=_WAIT_FOR_STATE_TIMEOUT)
149
179
  if state is not None:
150
180
  self.spinGain.setValue(state.gain)
151
181
  self.spinGainOffset.setValue(state.offset)
152
182
  await self.comm.subscribe_state(self.module, IGain, self._update_gain)
153
183
 
184
+ async def _init_image_format(self) -> None:
154
185
  # image format
155
186
  image_format_caps = await self.comm.get_capabilities(self.module, IImageFormat)
156
187
  if image_format_caps is not None:
@@ -159,44 +190,45 @@ class CameraWidget(BaseWidget, Ui_CameraWidget):
159
190
  self.comboImageFormat.addItems([f.name for f in image_formats])
160
191
  async with self.comm.safe_proxy(self.module, IImageFormat) as proxy:
161
192
  if proxy is not None:
162
- state = await proxy.wait_for_state(IImageFormat)
193
+ state = await proxy.wait_for_state(IImageFormat, timeout=_WAIT_FOR_STATE_TIMEOUT)
163
194
  if state is not None:
164
195
  self.comboImageFormat.setCurrentText(state.image_format.name)
165
196
  await self.comm.subscribe_state(self.module, IImageFormat, self._update_image_format)
166
197
 
198
+ async def _init_image_type(self) -> None:
167
199
  # image type
168
200
  async with self.comm.safe_proxy(self.module, IImageType) as proxy:
169
201
  if proxy is not None:
170
- state = await proxy.wait_for_state(IImageType)
202
+ state = await proxy.wait_for_state(IImageType, timeout=_WAIT_FOR_STATE_TIMEOUT)
171
203
  if state is not None:
172
204
  self.comboImageType.setCurrentText(state.image_type.name)
173
205
  await self.comm.subscribe_state(self.module, IImageType, self._update_image_type)
174
206
 
207
+ async def _init_exposure(self) -> None:
175
208
  # exposure (status, progress, time left)
176
209
  async with self.comm.safe_proxy(self.module, IExposure) as proxy:
177
210
  if proxy is not None:
178
- state = await proxy.wait_for_state(IExposure)
211
+ state = await proxy.wait_for_state(IExposure, timeout=_WAIT_FOR_STATE_TIMEOUT)
179
212
  if state is not None:
180
213
  self.exposure_status = state.status
181
214
  self.exposure_progress = state.progress
182
215
  self.exposure_time_left = state.exposure_time_left
183
216
  await self.comm.subscribe_state(self.module, IExposure, self._update_exposure)
184
217
 
218
+ async def _init_exposure_time(self) -> None:
185
219
  # exposure time
186
220
  async with self.comm.safe_proxy(self.module, IExposureTime) as proxy:
187
221
  if proxy is not None:
188
- state = await proxy.wait_for_state(IExposureTime)
222
+ state = await proxy.wait_for_state(IExposureTime, timeout=_WAIT_FOR_STATE_TIMEOUT)
189
223
  if state is not None:
190
224
  self.spinExpTime.setValue(state.exposure_time)
191
225
  await self.comm.subscribe_state(self.module, IExposureTime, self._update_exposure_time)
192
226
 
227
+ async def _init_data_sequence(self) -> None:
193
228
  # data sequence
194
229
  if await self.comm.has_proxy(self.module, IDataSequence):
195
230
  await self.comm.subscribe_state(self.module, IDataSequence, self._update_sequence)
196
231
 
197
- # update GUI
198
- self.signal_update_gui.emit()
199
-
200
232
  def _update_binning(self, state: BinningState):
201
233
  self.comboBinning.setCurrentText(f"{state.x}x{state.y}")
202
234