pyobs-gui 2.5.2__tar.gz → 2.6.0__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 (203) hide show
  1. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/PKG-INFO +11 -5
  2. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/docs/source/index.rst +57 -0
  3. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/__init__.py +7 -0
  4. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/base.py +19 -1
  5. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/datadisplaywidget.py +21 -2
  6. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/gui.py +139 -1
  7. pyobs_gui-2.6.0/pyobs_gui/guisignals.py +16 -0
  8. pyobs_gui-2.6.0/pyobs_gui/livestream.py +181 -0
  9. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/login.py +15 -7
  10. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/loginwindow.py +3 -0
  11. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/mainwindow.py +25 -0
  12. pyobs_gui-2.6.0/pyobs_gui/notificationmanager.py +201 -0
  13. pyobs_gui-2.6.0/pyobs_gui/notifications.py +268 -0
  14. pyobs_gui-2.6.0/pyobs_gui/notifier.py +57 -0
  15. pyobs_gui-2.6.0/pyobs_gui/notifier_backend.py +119 -0
  16. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/videowidget.ui +71 -0
  17. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/videowidget_ui.py +55 -3
  18. pyobs_gui-2.6.0/pyobs_gui/settings.py +197 -0
  19. pyobs_gui-2.6.0/pyobs_gui/settingsdialog.py +379 -0
  20. pyobs_gui-2.6.0/pyobs_gui/videowidget.py +559 -0
  21. pyobs_gui-2.6.0/pyobs_gui/windows_identity.py +93 -0
  22. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyproject.toml +11 -5
  23. pyobs_gui-2.6.0/specs/2026-09-29-live-view-mjpeg-raw.md +70 -0
  24. pyobs_gui-2.6.0/specs/2026-10-04-desktop-notifications.md +208 -0
  25. pyobs_gui-2.6.0/specs/2026-10-04-settings-dialog.md +155 -0
  26. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/specs/index.md +11 -0
  27. pyobs_gui-2.6.0/specs/plans/2026-10-04-desktop-notifications.md +193 -0
  28. pyobs_gui-2.6.0/specs/plans/2026-10-04-settings-dialog.md +80 -0
  29. pyobs_gui-2.6.0/specs/plans/index.md +8 -0
  30. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/tests/test_basewidget_discard.py +19 -4
  31. pyobs_gui-2.6.0/tests/test_gui_notifications.py +171 -0
  32. pyobs_gui-2.6.0/tests/test_import_order.py +22 -0
  33. pyobs_gui-2.6.0/tests/test_livestream.py +109 -0
  34. pyobs_gui-2.6.0/tests/test_missing_vfs_root.py +92 -0
  35. pyobs_gui-2.6.0/tests/test_notificationmanager.py +285 -0
  36. pyobs_gui-2.6.0/tests/test_notifications.py +389 -0
  37. pyobs_gui-2.6.0/tests/test_notifier_backend.py +205 -0
  38. pyobs_gui-2.6.0/tests/test_settings.py +201 -0
  39. pyobs_gui-2.6.0/tests/test_settingsdialog.py +336 -0
  40. pyobs_gui-2.6.0/tests/test_video_refresh.py +127 -0
  41. pyobs_gui-2.6.0/tests/test_videowidget.py +258 -0
  42. pyobs_gui-2.6.0/tests/test_windows_identity.py +69 -0
  43. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/uv.lock +287 -959
  44. pyobs_gui-2.5.2/pyobs_gui/videowidget.py +0 -268
  45. pyobs_gui-2.5.2/tests/test_videowidget.py +0 -49
  46. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/.claude/skills/verify/SKILL.md +0 -0
  47. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/.github/dependabot.yml +0 -0
  48. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/.github/workflows/dependabot-automerge.yml +0 -0
  49. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/.github/workflows/pypi.yml +0 -0
  50. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/.github/workflows/pyrefly.yml +0 -0
  51. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/.github/workflows/ruff.yml +0 -0
  52. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/.gitignore +0 -0
  53. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/.pre-commit-config.yaml +0 -0
  54. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/.readthedocs.yml +0 -0
  55. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/CHANGELOG.rst +0 -0
  56. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/CLAUDE.md +0 -0
  57. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/LICENSE +0 -0
  58. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/README.md +0 -0
  59. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/bin/mypyuic5 +0 -0
  60. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/docs/Makefile +0 -0
  61. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/docs/make.bat +0 -0
  62. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/docs/requirements.txt +0 -0
  63. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/docs/source/_static/pyobs.gif +0 -0
  64. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/docs/source/conf.py +0 -0
  65. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/mypy.ini +0 -0
  66. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/__main__.py +0 -0
  67. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/_nuitka_astropy_patch.py +0 -0
  68. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/accounts.py +0 -0
  69. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/acquisitionwidget.py +0 -0
  70. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/autofocuswidget.py +0 -0
  71. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/autoguidingwidget.py +0 -0
  72. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/camerawidget.py +0 -0
  73. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/commandinputwidget.py +0 -0
  74. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/compassmovewidget.py +0 -0
  75. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/coolingwidget.py +0 -0
  76. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/eventswidget.py +0 -0
  77. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/filterwidget.py +0 -0
  78. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/fitsheaderswidget.py +0 -0
  79. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/focuswidget.py +0 -0
  80. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/logmodel.py +0 -0
  81. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/modewidget.py +0 -0
  82. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/modifiedmixin.py +0 -0
  83. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/modulegui.py +0 -0
  84. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/movestack.py +0 -0
  85. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/nowheelfilter.py +0 -0
  86. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/__init__.py +0 -0
  87. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/acquisitionwidget.ui +0 -0
  88. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/acquisitionwidget_ui.py +0 -0
  89. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/autofocuswidget.ui +0 -0
  90. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/autofocuswidget_ui.py +0 -0
  91. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/autoguidingwidget.ui +0 -0
  92. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/autoguidingwidget_ui.py +0 -0
  93. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/camerawidget.ui +0 -0
  94. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/camerawidget_ui.py +0 -0
  95. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/compassmoveplugin.py +0 -0
  96. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/compassmovewidget.py +0 -0
  97. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/compassmovewidget.ui +0 -0
  98. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/compassmovewidget_ui.py +0 -0
  99. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/compile.sh +0 -0
  100. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/coolingwidget.ui +0 -0
  101. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/coolingwidget_ui.py +0 -0
  102. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/datadisplayplugin.py +0 -0
  103. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/datadisplaywidget.ui +0 -0
  104. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/datadisplaywidget_ui.py +0 -0
  105. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/designer.sh +0 -0
  106. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/eventswidget.ui +0 -0
  107. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/eventswidget_ui.py +0 -0
  108. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/filterwidget.ui +0 -0
  109. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/filterwidget_ui.py +0 -0
  110. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/fitsheaderswidget.ui +0 -0
  111. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/fitsheaderswidget_ui.py +0 -0
  112. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/focuswidget.ui +0 -0
  113. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/focuswidget_ui.py +0 -0
  114. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/mainwindow.ui +0 -0
  115. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/mainwindow_ui.py +0 -0
  116. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/modewidget.ui +0 -0
  117. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/modewidget_ui.py +0 -0
  118. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/resources/arrow-alt-circle-down-solid.svg +0 -0
  119. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/resources/arrow-alt-circle-left-solid.svg +0 -0
  120. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/resources/arrow-alt-circle-right-solid.svg +0 -0
  121. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/resources/arrow-alt-circle-up-solid.svg +0 -0
  122. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/resources/arrow-up-right-from-square-solid.svg +0 -0
  123. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/resources/arrows-to-eye-solid.svg +0 -0
  124. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/resources/chart-line-solid.svg +0 -0
  125. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/resources/circle-question-solid.svg +0 -0
  126. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/resources/cloud-sun-solid.svg +0 -0
  127. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/resources/edit-solid.svg +0 -0
  128. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/resources/search-solid.svg +0 -0
  129. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/resources/undo-solid.svg +0 -0
  130. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/resources.qrc +0 -0
  131. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/resources_rc.py +0 -0
  132. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/roboticwidget.ui +0 -0
  133. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/roboticwidget_ui.py +0 -0
  134. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/roofwidget.ui +0 -0
  135. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/roofwidget_ui.py +0 -0
  136. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/schedulewidget.ui +0 -0
  137. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/schedulewidget_ui.py +0 -0
  138. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/shellwidget.ui +0 -0
  139. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/shellwidget_ui.py +0 -0
  140. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/spectrographwidget.ui +0 -0
  141. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/spectrographwidget_ui.py +0 -0
  142. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/telescopewidget.ui +0 -0
  143. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/telescopewidget_ui.py +0 -0
  144. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/temperaturesplotwidget.ui +0 -0
  145. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/temperaturesplotwidget_ui.py +0 -0
  146. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/temperatureswidget.ui +0 -0
  147. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/temperatureswidget_ui.py +0 -0
  148. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/videograbwidget.ui +0 -0
  149. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/videograbwidget_ui.py +0 -0
  150. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/weatherwidget.ui +0 -0
  151. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/weatherwidget_ui.py +0 -0
  152. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/roboticwidget.py +0 -0
  153. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/roofwidget.py +0 -0
  154. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/schedulewidget.py +0 -0
  155. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/shellwidget.py +0 -0
  156. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/spectrographwidget.py +0 -0
  157. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/statuswidget.py +0 -0
  158. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/structuredconfigwidget.py +0 -0
  159. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/telescopewidget.py +0 -0
  160. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/temperaturesplotwidget.py +0 -0
  161. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/temperatureswidget.py +0 -0
  162. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/utils.py +0 -0
  163. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/videograbwidget.py +0 -0
  164. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/watchedlabel.py +0 -0
  165. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/weatherwidget.py +0 -0
  166. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pysidedeploy.spec +0 -0
  167. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/specs/2026-08-20-gui-remote-call-error-handling.md +0 -0
  168. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/specs/2026-08-21-gui-widget-startup-responsiveness.md +0 -0
  169. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/specs/2026-08-28-gui-main-vs-sidebar-widgets.md +0 -0
  170. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/specs/2026-08-28-structuredconfig-widget.md +0 -0
  171. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/specs/2026-08-31-irobotic-widgets.md +0 -0
  172. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/specs/2026-09-01-gui-video-widget-split.md +0 -0
  173. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/specs/2026-09-14-fitswidget-toolbar-overflow.md +0 -0
  174. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/specs/2026-09-14-stacked-widget-scroll-fallback.md +0 -0
  175. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/spike_standalone/main.py +0 -0
  176. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/spike_standalone/pysidedeploy.spec +0 -0
  177. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/test/acquisition.yaml +0 -0
  178. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/test/autofocus.yaml +0 -0
  179. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/test/camera.yaml +0 -0
  180. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/test/full.yaml +0 -0
  181. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/test/guiding.yaml +0 -0
  182. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/test/mode.yaml +0 -0
  183. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/test/robotic.yaml +0 -0
  184. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/test/roof.yaml +0 -0
  185. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/test/spectrograph.yaml +0 -0
  186. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/test/structuredconfig.yaml +0 -0
  187. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/test/telescope.yaml +0 -0
  188. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/test/telescope_acl.yaml +0 -0
  189. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/test/telescope_acl_denied.yaml +0 -0
  190. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/test/video.yaml +0 -0
  191. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/tests/__init__.py +0 -0
  192. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/tests/conftest.py +0 -0
  193. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/tests/test_accounts.py +0 -0
  194. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/tests/test_camerawidget.py +0 -0
  195. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/tests/test_loginwindow.py +0 -0
  196. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/tests/test_mainwindow_startup.py +0 -0
  197. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/tests/test_modewidget.py +0 -0
  198. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/tests/test_multiwidget_pages.py +0 -0
  199. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/tests/test_roboticwidget.py +0 -0
  200. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/tests/test_schedulewidget.py +0 -0
  201. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/tests/test_spectrographwidget.py +0 -0
  202. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/tests/test_structuredconfigwidget.py +0 -0
  203. {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/tests/test_videograbwidget.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pyobs-gui
3
- Version: 2.5.2
3
+ Version: 2.6.0
4
4
  Summary: A remote GUI for pyobs
5
5
  Author-email: Tim-Oliver Husser <thusser@uni-goettingen.de>
6
6
  License-Expression: MIT
@@ -8,13 +8,19 @@ License-File: LICENSE
8
8
  Requires-Python: >=3.11
9
9
  Requires-Dist: astroplan>=0.10.1
10
10
  Requires-Dist: astropy<8,>=7.0.1
11
+ Requires-Dist: astroquery<0.5,>=0.4.10
11
12
  Requires-Dist: colour<0.2,>=0.1.5
13
+ Requires-Dist: desktop-notifier<7,>=6.2
12
14
  Requires-Dist: keyring<26,>=25.7.0
13
15
  Requires-Dist: matplotlib<4,>=3.10.1
14
- Requires-Dist: pyobs-core<3,>=2.7.2
15
- Requires-Dist: pyside6>=6.10.1
16
+ Requires-Dist: pandas>=2.2.3
17
+ Requires-Dist: pydantic<3,>=2.12.5
18
+ Requires-Dist: pyobs-core<3,>=2.13.5
19
+ Requires-Dist: pyside6-essentials>=6.10.1
20
+ Requires-Dist: pywin32>=312; sys_platform == 'win32'
21
+ Requires-Dist: pyyaml<7,>=6.0.2
16
22
  Requires-Dist: qasync<0.29,>=0.27.1
17
- Requires-Dist: qfitswidget>=1.1.3
23
+ Requires-Dist: qfitswidget>=1.2.0
18
24
  Requires-Dist: qtawesome<2,>=1.4.0
19
25
  Requires-Dist: secretstorage<4,>=3.5.0
20
- Requires-Dist: sunpy[all]>=7.0.1
26
+ Requires-Dist: sunpy>=7.0.1
@@ -44,6 +44,63 @@ This is an example configuration for a GUI that defines one custom widget for th
44
44
  download: http://localhost:37077/
45
45
 
46
46
 
47
+ Settings and desktop notifications
48
+ ***********************************
49
+
50
+ The GUI can show a desktop notification when a module goes into ``ERROR`` or when an ``ERROR`` or
51
+ ``CRITICAL`` log event arrives, so an operator who has the GUI open but is not looking at it notices.
52
+ No tray icon is needed. Notifications are only shown while the GUI is not the active application.
53
+ Clicking one brings the window forward (where the desktop allows it, otherwise it is highlighted in
54
+ the taskbar) and, for a module error, selects that module.
55
+
56
+ Configured GUI (YAML)
57
+ =====================
58
+
59
+ Notifications are **off** unless the configuration has a ``notifications:`` block. With the block
60
+ present they are on, and these values can be set (all optional, shown with their defaults)::
61
+
62
+ class: pyobs_gui.GUI
63
+
64
+ notifications:
65
+ enabled: true
66
+ min_log_level: ERROR # ERROR or CRITICAL
67
+ only_when_inactive: true # nothing while the GUI is the active application
68
+ muted_modules: [] # modules that never notify
69
+ rate_limit: 10 # seconds, repeats from one module are combined into "N more"
70
+
71
+ A typo in the block is an error at startup. At most 5 notifications are shown in 30 seconds, the rest
72
+ is combined into one "+N more notifications". Modules that are already in ``ERROR`` when the GUI
73
+ connects are announced together in one notification.
74
+
75
+ Standalone GUI (login window)
76
+ =============================
77
+
78
+ The standalone GUI has a **Settings** button above "Log out". The settings are saved per account in
79
+ ``settings.yaml`` in the user's config directory (``~/.config/pyobs/pyobs-gui/`` on Linux) and apply
80
+ immediately. There are two tabs:
81
+
82
+ * *Notifications*: the values above, plus **Send test notification**, which shows whether the desktop
83
+ shows notifications at all.
84
+ * *VFS*: the VFS roots, which the standalone GUI otherwise does not have. Without a root for the
85
+ path a camera publishes, images and the live view cannot be loaded. A root has a name, a class
86
+ (e.g. ``pyobs.vfs.HttpFile``) and parameters as YAML, the same as in a ``vfs:`` block.
87
+
88
+ The file can contain tokens in clear text, so it is only readable by the user (on systems that have
89
+ such permissions). A file that cannot be read is never overwritten: fix or delete it.
90
+
91
+ If nothing appears
92
+ ==================
93
+
94
+ Use the test button first. It reports an error if the system refuses, and says what to check if it
95
+ does not. Notifications were checked on KDE Plasma and GNOME (Wayland) and on Windows 11, not yet on
96
+ macOS.
97
+
98
+ * On every system, check that Do Not Disturb (Focus Assist on Windows) is off.
99
+ * Windows shows a notification only for an app that has an entry in the Start Menu. pyobs-gui creates
100
+ ``pyobs-gui`` there when it first sends a notification. Windows gives no error when it drops a
101
+ notification, so if the entry was deleted, restart the GUI.
102
+ * macOS asks for permission once. If it was refused, allow it for pyobs-gui in System Settings.
103
+
47
104
  Available classes
48
105
  ******************
49
106
 
@@ -4,6 +4,13 @@ TODO: write doc
4
4
 
5
5
  __title__ = "GUI"
6
6
 
7
+ # pydantic loads its submodules lazily. If that first happens after PySide6 was imported, PySide6's
8
+ # import hook (shibokensupport.feature) trips over it: "cannot import name 'import_string' ...
9
+ # circular import". The `pyobs` command imports pydantic through pyobs-core before it imports this
10
+ # package, but only partly, so what this package uses from pydantic is loaded here, before anything
11
+ # imports PySide6 (see tests/test_import_order.py).
12
+ from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator # noqa: E402, F401
13
+
7
14
  from ._nuitka_astropy_patch import patch_generic_unit_parser
8
15
 
9
16
  # Only relevant in the Nuitka-compiled standalone binary: under a regular interpreter the
@@ -45,6 +45,20 @@ async def show_remote_error(parent: QtWidgets.QWidget, exception: Exception) ->
45
45
  await QAsyncMessageBox.warning(parent, "Error", str(exception))
46
46
 
47
47
 
48
+ def missing_root_message(exception: Exception, path: str, module: str) -> str | None:
49
+ """User-facing text if `exception` is the VFS's "unknown root" error, else None.
50
+
51
+ `VirtualFileSystem.open_file()` raises a plain `ValueError("Could not find root ...")`, so the
52
+ message prefix is the only way to tell it from other `ValueError`s.
53
+ """
54
+ from pyobs.vfs import VirtualFileSystem
55
+
56
+ if not isinstance(exception, ValueError) or not str(exception).startswith("Could not find root"):
57
+ return None
58
+ root, _ = VirtualFileSystem.split_root(path)
59
+ return f"Cannot open '{path}' from module '{module}': the VFS root '{root}' is not configured."
60
+
61
+
48
62
  async def cancel_and_drain(task: asyncio.Task[Any]) -> None:
49
63
  """Cancel a task and await its unwind, swallowing the resulting CancelledError."""
50
64
  task.cancel()
@@ -344,7 +358,11 @@ class BaseWidget(BaseWindow, QtWidgets.QWidget): # type: ignore
344
358
  self._init_steps_done.add(key)
345
359
 
346
360
  def hideEvent(self, event: QtGui.QHideEvent) -> None:
347
- # run in loop
361
+ # run in loop (at shutdown Qt still hides widgets after the loop has stopped, nothing to cancel then)
362
+ try:
363
+ asyncio.get_running_loop()
364
+ except RuntimeError:
365
+ return
348
366
  asyncio.create_task(self._hide_event(event))
349
367
 
350
368
  async def _hide_event(self, event: QtGui.QHideEvent) -> None:
@@ -15,7 +15,8 @@ from qfitswidget import QFitsWidget # type: ignore
15
15
  from pyobs.events import NewImageEvent, NewSpectrumEvent, Event
16
16
  from pyobs.interfaces import IData, ISpectrograph
17
17
  from pyobs.vfs import VirtualFileSystem
18
- from .base import BaseWidget
18
+ from .base import BaseWidget, missing_root_message
19
+ from .utils import QAsyncMessageBox
19
20
  from .qt.datadisplaywidget_ui import Ui_DataDisplayWidget
20
21
 
21
22
  if TYPE_CHECKING:
@@ -43,6 +44,8 @@ class DataDisplayWidget(BaseWidget, Ui_DataDisplayWidget):
43
44
  self.canvas: FigureCanvas | None = None
44
45
  self.plotTools: NavigationToolbar2QT | None = None
45
46
  self.is_spectrograph = False
47
+ # roots already reported to the user, so a stream of images doesn't open one box each
48
+ self._reported_roots: set[str] = set()
46
49
 
47
50
  # before first update, disable mys
48
51
  self.setEnabled(False)
@@ -101,6 +104,15 @@ class DataDisplayWidget(BaseWidget, Ui_DataDisplayWidget):
101
104
  # signal GUI update
102
105
  self.signal_update_gui.emit()
103
106
 
107
+ async def _report_missing_root(self, path: str, message: str) -> None:
108
+ """Log every time, but open the message box only once per root."""
109
+ log.error(message)
110
+ root = VirtualFileSystem.split_root(path)[0]
111
+ if root in self._reported_roots:
112
+ return
113
+ self._reported_roots.add(root)
114
+ await QAsyncMessageBox.warning(self, "VFS root not configured", message)
115
+
104
116
  def plot(self) -> None:
105
117
  """Show data."""
106
118
  if self.data is None:
@@ -198,7 +210,14 @@ class DataDisplayWidget(BaseWidget, Ui_DataDisplayWidget):
198
210
  # download data
199
211
  if self.vfs is None or not isinstance(self.vfs, VirtualFileSystem):
200
212
  return False
201
- data = await self.vfs.read_fits(event.filename)
213
+ try:
214
+ data = await self.vfs.read_fits(event.filename)
215
+ except ValueError as e:
216
+ message = missing_root_message(e, event.filename, self.module)
217
+ if message is None:
218
+ raise
219
+ await self._report_missing_root(event.filename, message)
220
+ return False
202
221
 
203
222
  # auto save?
204
223
  if autosave is not None:
@@ -1,4 +1,5 @@
1
1
  import asyncio
2
+ from functools import partial
2
3
  import logging
3
4
  import sys
4
5
  from typing import Any, cast, TYPE_CHECKING
@@ -12,6 +13,12 @@ from pyobs.interfaces import FitsHeaderEntry, IFitsHeaderBefore
12
13
  from pyobs.modules import Module
13
14
 
14
15
  from .nowheelfilter import NoWheelWhenUnfocused
16
+ from .guisignals import gui_signals
17
+ from .notificationmanager import NotificationManager
18
+ from .notifier import GuardedNotifier
19
+ from .notifier_backend import DesktopNotifierBackend
20
+ from .settings import GuiSettings, NotificationSettings, SettingsError, SettingsStore, unknown_keys
21
+ from .settingsdialog import SettingsDialog
15
22
  from .utils import QAsyncMessageBox
16
23
 
17
24
  log = logging.getLogger(__name__)
@@ -34,6 +41,8 @@ class GUI(Module, IFitsHeaderBefore):
34
41
  widgets: list[dict[str, Any]] | None = None,
35
42
  sidebar: list[dict[str, Any]] | None = None,
36
43
  standalone: bool = False,
44
+ notifications: dict[str, Any] | None = None,
45
+ settings_key: str | None = None,
37
46
  *args: Any,
38
47
  **kwargs: Any,
39
48
  ):
@@ -49,10 +58,28 @@ class GUI(Module, IFitsHeaderBefore):
49
58
  (pyobs_gui.login.login_and_build_gui) rather than a YAML-configured Comm. When
50
59
  True, MainWindow's bottom-left button reads "Log out" and reconnects via the
51
60
  login window instead of quitting the whole app.
61
+ notifications: Notification settings, see `NotificationSettings`. Unknown keys are
62
+ rejected, so a typo in the YAML doesn't silently do nothing.
63
+ settings_key: Standalone only: key of this connection's settings in the settings file
64
+ (see `login.settings_key`). The settings then come from that file and can be
65
+ edited with the settings dialog, `notifications` is not used.
52
66
  """
53
67
 
68
+ unknown = unknown_keys(NotificationSettings, notifications or {}, "notifications.")
69
+ if unknown:
70
+ raise ValueError(f"Unknown GUI settings: {', '.join(unknown)}")
71
+ # YAML mode: off unless the config has a `notifications:` block, so existing control-room GUIs
72
+ # don't start showing desktop notifications after an upgrade
73
+ notification_settings = (
74
+ NotificationSettings.model_validate(notifications)
75
+ if notifications is not None
76
+ else NotificationSettings(enabled=False)
77
+ )
78
+ settings = GuiSettings(notifications=notification_settings)
79
+
54
80
  # init module
55
81
  Module.__init__(self, *args, **kwargs)
82
+ self.settings = settings
56
83
  self._window: MainWindow | None = None
57
84
  self._show_shell = show_shell
58
85
  self._show_events = show_events
@@ -63,6 +90,60 @@ class GUI(Module, IFitsHeaderBefore):
63
90
  self._standalone = standalone
64
91
  self._logging_out = False
65
92
 
93
+ # standalone: settings live in a file, per connection
94
+ self._settings_key = settings_key
95
+ self._store: SettingsStore | None = SettingsStore() if standalone and settings_key else None
96
+ self._settings_dialog: SettingsDialog | None = None
97
+ self._notifications: NotificationManager | None = None
98
+ self._notifier_backend: DesktopNotifierBackend | None = None
99
+ if self._store is not None and settings_key is not None:
100
+ self.settings = self._load_settings(settings_key)
101
+
102
+ def _load_settings(self, key: str) -> GuiSettings:
103
+ """Settings from the file. An unreadable file must not keep the GUI from starting, so
104
+ that falls back to the defaults (saving then fails with the same error, rather than
105
+ overwriting the file)."""
106
+ assert self._store is not None
107
+ try:
108
+ return self._store.get(key)
109
+ except SettingsError:
110
+ log.exception("Could not load settings, using defaults.")
111
+ return GuiSettings()
112
+
113
+ def _apply_vfs_roots(self) -> None:
114
+ if self._store is not None:
115
+ self.vfs.set_roots(self.settings.vfs.roots)
116
+
117
+ def apply_settings(self, settings: GuiSettings) -> None:
118
+ """Standalone only: save the settings, put them into effect and tell the widgets.
119
+
120
+ Raises:
121
+ SettingsError: The file couldn't be written. Nothing was changed then.
122
+ """
123
+ if self._store is None or self._settings_key is None:
124
+ raise RuntimeError("Settings can only be changed in standalone mode.")
125
+ self._store.set(self._settings_key, settings)
126
+ self.settings = settings
127
+ self._apply_vfs_roots()
128
+ gui_signals.settings_changed.emit()
129
+
130
+ def _open_settings(self) -> None:
131
+ """Wired to MainWindow's "Settings" button (standalone mode only)."""
132
+ if self._settings_dialog is not None:
133
+ self._settings_dialog.raise_()
134
+ self._settings_dialog.activateWindow()
135
+ return
136
+ dialog = SettingsDialog(
137
+ self.settings,
138
+ list(self.comm.clients),
139
+ self.apply_settings,
140
+ send_test=self._send_test_notification,
141
+ parent=self._window,
142
+ )
143
+ dialog.finished.connect(lambda _result: setattr(self, "_settings_dialog", None))
144
+ self._settings_dialog = dialog
145
+ dialog.open()
146
+
66
147
  @staticmethod
67
148
  def new_event_loop() -> asyncio.AbstractEventLoop:
68
149
  GUI.app = QtWidgets.QApplication(sys.argv)
@@ -80,6 +161,7 @@ class GUI(Module, IFitsHeaderBefore):
80
161
  from .mainwindow import MainWindow
81
162
 
82
163
  await Module.open(self)
164
+ self._apply_vfs_roots()
83
165
 
84
166
  # create and show window
85
167
  self._window = MainWindow(
@@ -90,6 +172,7 @@ class GUI(Module, IFitsHeaderBefore):
90
172
  widgets=self._custom_widgets,
91
173
  sidebar=self._custom_sidebar_widgets,
92
174
  on_logout=self._request_logout if self._standalone else None,
175
+ on_settings=self._open_settings if self._store is not None else None,
93
176
  )
94
177
  await self._window.open(
95
178
  module=self,
@@ -98,6 +181,54 @@ class GUI(Module, IFitsHeaderBefore):
98
181
  observer=self._observer,
99
182
  )
100
183
  self._window.show()
184
+ await self._start_notifications()
185
+
186
+ def _get_notifier_backend(self) -> DesktopNotifierBackend:
187
+ """One backend for the notifications and the test button, so the system sees one app."""
188
+ if self._notifier_backend is None:
189
+ self._notifier_backend = DesktopNotifierBackend()
190
+ return self._notifier_backend
191
+
192
+ async def _send_test_notification(self) -> None:
193
+ """The settings dialog's test button: straight through the backend, so a problem is raised
194
+ (as NotifierError) and the filters of the policy don't apply."""
195
+ await self._get_notifier_backend().send(
196
+ "pyobs-gui", "Desktop notifications work.", False, partial(self._bring_window_to_front, None)
197
+ )
198
+
199
+ async def _start_notifications(self) -> None:
200
+ """Desktop notifications for module errors. A problem here must not keep the GUI from starting."""
201
+ backend = self._get_notifier_backend()
202
+ manager = NotificationManager(
203
+ comm=self.comm,
204
+ settings=lambda: self.settings.notifications,
205
+ notifier=GuardedNotifier(backend),
206
+ bring_to_front=self._bring_window_to_front,
207
+ authorise=backend.ensure_authorisation,
208
+ )
209
+ try:
210
+ await manager.start(self.comm.clients)
211
+ except Exception:
212
+ log.warning("Could not start desktop notifications.", exc_info=True)
213
+ return
214
+ self._notifications = manager
215
+
216
+ async def _close_notifications(self) -> None:
217
+ manager, self._notifications = self._notifications, None
218
+ if manager is not None:
219
+ try:
220
+ await manager.close()
221
+ except Exception:
222
+ log.warning("Could not stop desktop notifications cleanly.", exc_info=True)
223
+
224
+ def _bring_window_to_front(self, module: str | None) -> None:
225
+ if self._window is not None:
226
+ self._window.bring_to_front(module)
227
+
228
+ async def close(self) -> None:
229
+ """Close module."""
230
+ await self._close_notifications()
231
+ await Module.close(self)
101
232
 
102
233
  def _request_logout(self) -> None:
103
234
  """Wired to MainWindow's "Log out" button (standalone mode only). Hides the current
@@ -109,6 +240,8 @@ class GUI(Module, IFitsHeaderBefore):
109
240
  return # already logging out -- ignore a repeat click
110
241
  self._logging_out = True
111
242
 
243
+ if self._settings_dialog is not None:
244
+ self._settings_dialog.close()
112
245
  old_window = self._window
113
246
  self._window = None
114
247
  if old_window is not None:
@@ -134,6 +267,8 @@ class GUI(Module, IFitsHeaderBefore):
134
267
  await old_window.discard_all_widgets()
135
268
  old_window.deleteLater()
136
269
 
270
+ await self._close_notifications()
271
+
137
272
  old_comm = self._comm
138
273
  if old_comm is not None:
139
274
  await old_comm.close()
@@ -141,7 +276,7 @@ class GUI(Module, IFitsHeaderBefore):
141
276
  from .login import show_login_and_connect
142
277
 
143
278
  try:
144
- new_comm = await show_login_and_connect()
279
+ new_comm, new_key = await show_login_and_connect()
145
280
  except asyncio.CancelledError:
146
281
  log.info("Login window closed without connecting; nothing left to do but quit.")
147
282
  self.quit()
@@ -155,6 +290,9 @@ class GUI(Module, IFitsHeaderBefore):
155
290
  self._logging_out = False
156
291
 
157
292
  self._comm = new_comm
293
+ if self._store is not None:
294
+ self._settings_key = new_key
295
+ self.settings = self._load_settings(new_key)
158
296
  try:
159
297
  await self.startup()
160
298
  except Exception as e:
@@ -0,0 +1,16 @@
1
+ """App-wide Qt signals for things that aren't tied to one widget.
2
+
3
+ Widgets are created per connected module and don't know about the window or the GUI module that
4
+ owns the settings, so settings changes are announced here instead of passing a reference around.
5
+ """
6
+
7
+ from PySide6 import QtCore # type: ignore
8
+
9
+
10
+ class GuiSignals(QtCore.QObject):
11
+ # emitted by whoever applied new settings (e.g. new VFS roots), after they are in effect;
12
+ # widgets re-read what they derived from them
13
+ settings_changed = QtCore.Signal()
14
+
15
+
16
+ gui_signals = GuiSignals()
@@ -0,0 +1,181 @@
1
+ """Incremental parsers for BaseVideo's live-view streams (/video.mjpg and /video.raw).
2
+
3
+ Both are fed the raw bytes of an HTTP/1.0 response as they arrive on the socket and return the
4
+ frames completed so far. Kept free of Qt so they can be tested without a socket.
5
+ """
6
+
7
+ import json
8
+ from dataclasses import dataclass
9
+ from typing import Any
10
+
11
+ import numpy as np
12
+ from numpy.typing import NDArray
13
+
14
+
15
+ class StreamError(Exception):
16
+ """The server answered the stream request with something other than 200 OK."""
17
+
18
+ def __init__(self, status: int | None, body: bytes):
19
+ self.status = status
20
+ self.body = body
21
+ text = body.decode("utf-8", errors="replace").strip()
22
+ super().__init__(f"HTTP status {status}: {text}" if text else f"HTTP status {status}")
23
+
24
+
25
+ @dataclass
26
+ class RawFrame:
27
+ """One frame from the raw stream."""
28
+
29
+ meta: dict[str, Any]
30
+ data: NDArray[Any]
31
+
32
+
33
+ class _HttpStreamParser:
34
+ """Strips and checks the HTTP response header, then hands the body to _parse()."""
35
+
36
+ def __init__(self) -> None:
37
+ self._buffer = b""
38
+ self._headers_received = False
39
+
40
+ def feed(self, data: bytes) -> list[Any]:
41
+ """Add received bytes and return all frames completed by them.
42
+
43
+ Raises:
44
+ StreamError: If the response status is not 200.
45
+ """
46
+ self._buffer += data
47
+
48
+ # the boundary string also appears in the Content-Type header, so the header must go first
49
+ if not self._headers_received:
50
+ pos = self._buffer.find(b"\r\n\r\n")
51
+ if pos == -1:
52
+ return []
53
+ header, self._buffer = self._buffer[:pos], self._buffer[pos + 4 :]
54
+ self._headers_received = True
55
+ status = self._status(header)
56
+ if status != 200:
57
+ raise StreamError(status, self._buffer)
58
+
59
+ return self._parse()
60
+
61
+ @staticmethod
62
+ def _status(header: bytes) -> int | None:
63
+ """Status code from the response's status line, None if it can't be parsed."""
64
+ parts = header.split(b"\r\n", 1)[0].split()
65
+ if len(parts) < 2 or not parts[0].startswith(b"HTTP/"):
66
+ return None
67
+ try:
68
+ return int(parts[1])
69
+ except ValueError:
70
+ return None
71
+
72
+ def _parse(self) -> list[Any]:
73
+ raise NotImplementedError
74
+
75
+
76
+ class MjpegParser(_HttpStreamParser):
77
+ """Parser for /video.mjpg, returns the JPEG bytes of each frame.
78
+
79
+ A frame is only complete once the next boundary arrives, since the parts carry no length.
80
+ """
81
+
82
+ BOUNDARY = b"--jpgboundary\r\n"
83
+
84
+ def _parse(self) -> list[bytes]:
85
+ frames: list[bytes] = []
86
+ while True:
87
+ pos = self._buffer.find(self.BOUNDARY)
88
+ if pos == -1:
89
+ return frames
90
+ part, self._buffer = self._buffer[:pos], self._buffer[pos + len(self.BOUNDARY) :]
91
+
92
+ # skip part header; empty for the preamble before the first boundary
93
+ header_end = part.find(b"\r\n\r\n")
94
+ if header_end == -1:
95
+ continue
96
+ jpeg = part[header_end + 4 :]
97
+ if jpeg:
98
+ frames.append(jpeg)
99
+
100
+
101
+ class RawParser(_HttpStreamParser):
102
+ """Parser for /video.raw, returns decoded frames.
103
+
104
+ Each part carries its size implicitly (DTYPE and NAXISn in the meta header), so the payload is
105
+ read by length and never searched for the boundary, which could appear in binary data.
106
+ """
107
+
108
+ BOUNDARY = b"--rawboundary\r\n"
109
+
110
+ def __init__(self) -> None:
111
+ super().__init__()
112
+ self._meta: dict[str, Any] | None = None
113
+ self._size = 0
114
+
115
+ def _parse(self) -> list[RawFrame]:
116
+ frames: list[RawFrame] = []
117
+ while True:
118
+ if self._meta is None:
119
+ # boundary plus part header
120
+ start = self._buffer.find(self.BOUNDARY)
121
+ if start == -1:
122
+ return frames
123
+ end = self._buffer.find(b"\r\n\r\n", start)
124
+ if end == -1:
125
+ return frames
126
+ headers = self._buffer[start + len(self.BOUNDARY) : end]
127
+ self._buffer = self._buffer[end + 4 :]
128
+ self._meta = self._parse_meta(headers)
129
+ self._size = self._frame_size(self._meta)
130
+
131
+ # payload
132
+ if len(self._buffer) < self._size:
133
+ return frames
134
+ payload, self._buffer = self._buffer[: self._size], self._buffer[self._size :]
135
+ frames.append(RawFrame(self._meta, self._decode(self._meta, payload)))
136
+ self._meta = None
137
+
138
+ @staticmethod
139
+ def _parse_meta(headers: bytes) -> dict[str, Any]:
140
+ for line in headers.split(b"\r\n"):
141
+ name, _, value = line.partition(b":")
142
+ if name.strip().lower() == b"x-pyobs-frame-meta":
143
+ meta = json.loads(value.strip())
144
+ if not isinstance(meta, dict):
145
+ raise ValueError("Frame meta is not a JSON object.")
146
+ return meta
147
+ raise ValueError("Frame without meta header.")
148
+
149
+ @staticmethod
150
+ def _shape(meta: dict[str, Any]) -> tuple[int, ...]:
151
+ """Array shape, NAXISn first (numpy order), e.g. (height, width) or (height, width, colour)."""
152
+ naxis = int(meta.get("NAXIS", 2))
153
+ return tuple(int(meta[f"NAXIS{i}"]) for i in range(naxis, 0, -1))
154
+
155
+ @classmethod
156
+ def _frame_size(cls, meta: dict[str, Any]) -> int:
157
+ return int(np.prod(cls._shape(meta))) * np.dtype(meta["DTYPE"]).itemsize
158
+
159
+ @classmethod
160
+ def _decode(cls, meta: dict[str, Any], payload: bytes) -> NDArray[Any]:
161
+ return np.frombuffer(payload, dtype=np.dtype(meta["DTYPE"])).reshape(cls._shape(meta))
162
+
163
+
164
+ def fit_factor(full_width: int, full_height: int, view_width: int, view_height: int) -> int:
165
+ """Largest integer downsampling factor that keeps the image at least as large as the view.
166
+
167
+ Args:
168
+ full_width: Width of the full frame in pixels.
169
+ full_height: Height of the full frame in pixels.
170
+ view_width: Width of the view in pixels.
171
+ view_height: Height of the view in pixels.
172
+
173
+ Returns:
174
+ Factor, at least 1.
175
+ """
176
+ if view_width < 1 or view_height < 1:
177
+ return 1
178
+ return max(1, min(full_width // view_width, full_height // view_height))
179
+
180
+
181
+ __all__ = ["StreamError", "RawFrame", "MjpegParser", "RawParser", "fit_factor"]
@@ -15,15 +15,22 @@ from typing import Any
15
15
  from pyobs.comm.xmpp import XmppComm
16
16
 
17
17
  from .gui import GUI
18
- from .loginwindow import LoginWindow
18
+ from .loginwindow import ConnectionRequest, LoginWindow
19
19
 
20
20
  log = logging.getLogger(__name__)
21
21
 
22
22
 
23
- async def show_login_and_connect() -> XmppComm:
23
+ def settings_key(request: ConnectionRequest) -> str:
24
+ """Key of the settings in the settings file: the saved account id, or for a connection that
25
+ wasn't saved the bare JID (so its settings persist too)."""
26
+ return request.account_id or "jid:" + request.jid.split("/")[0]
27
+
28
+
29
+ async def show_login_and_connect() -> tuple[XmppComm, str]:
24
30
  """Shows the login window, waits for the user to click Connect (or raises
25
31
  asyncio.CancelledError if they close the window instead -- see LoginWindow.closeEvent), then
26
- returns a fresh XmppComm built from the result.
32
+ returns a fresh XmppComm built from the result, together with the key of its settings (see
33
+ `settings_key`).
27
34
 
28
35
  Used both for the initial standalone startup (login_and_build_gui) and for GUI's "Log out"
29
36
  flow (GUI._logout), which shows this same window again without tearing down the running
@@ -40,13 +47,14 @@ async def show_login_and_connect() -> XmppComm:
40
47
  window.close()
41
48
 
42
49
  log.info("Connecting as %s...", request.jid)
43
- return XmppComm(
50
+ comm = XmppComm(
44
51
  jid=request.jid,
45
52
  password=request.password,
46
53
  server=request.server,
47
54
  use_tls=request.use_tls,
48
55
  ignore_cert_errors=request.insecure_skip_tls,
49
56
  )
57
+ return comm, settings_key(request)
50
58
 
51
59
 
52
60
  async def login_and_build_gui(**gui_kwargs: Any) -> GUI:
@@ -57,8 +65,8 @@ async def login_and_build_gui(**gui_kwargs: Any) -> GUI:
57
65
  gui_kwargs: Passed through to GUI(...) (show_shell, show_events, widgets, etc.) --
58
66
  everything except `comm`, which is built here from the login window's result.
59
67
  """
60
- comm = await show_login_and_connect()
61
- return GUI(comm=comm, standalone=True, **gui_kwargs)
68
+ comm, key = await show_login_and_connect()
69
+ return GUI(comm=comm, standalone=True, settings_key=key, **gui_kwargs)
62
70
 
63
71
 
64
- __all__ = ["login_and_build_gui", "show_login_and_connect"]
72
+ __all__ = ["login_and_build_gui", "settings_key", "show_login_and_connect"]