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.
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/PKG-INFO +11 -5
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/docs/source/index.rst +57 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/__init__.py +7 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/base.py +19 -1
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/datadisplaywidget.py +21 -2
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/gui.py +139 -1
- pyobs_gui-2.6.0/pyobs_gui/guisignals.py +16 -0
- pyobs_gui-2.6.0/pyobs_gui/livestream.py +181 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/login.py +15 -7
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/loginwindow.py +3 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/mainwindow.py +25 -0
- pyobs_gui-2.6.0/pyobs_gui/notificationmanager.py +201 -0
- pyobs_gui-2.6.0/pyobs_gui/notifications.py +268 -0
- pyobs_gui-2.6.0/pyobs_gui/notifier.py +57 -0
- pyobs_gui-2.6.0/pyobs_gui/notifier_backend.py +119 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/videowidget.ui +71 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/videowidget_ui.py +55 -3
- pyobs_gui-2.6.0/pyobs_gui/settings.py +197 -0
- pyobs_gui-2.6.0/pyobs_gui/settingsdialog.py +379 -0
- pyobs_gui-2.6.0/pyobs_gui/videowidget.py +559 -0
- pyobs_gui-2.6.0/pyobs_gui/windows_identity.py +93 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyproject.toml +11 -5
- pyobs_gui-2.6.0/specs/2026-09-29-live-view-mjpeg-raw.md +70 -0
- pyobs_gui-2.6.0/specs/2026-10-04-desktop-notifications.md +208 -0
- pyobs_gui-2.6.0/specs/2026-10-04-settings-dialog.md +155 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/specs/index.md +11 -0
- pyobs_gui-2.6.0/specs/plans/2026-10-04-desktop-notifications.md +193 -0
- pyobs_gui-2.6.0/specs/plans/2026-10-04-settings-dialog.md +80 -0
- pyobs_gui-2.6.0/specs/plans/index.md +8 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/tests/test_basewidget_discard.py +19 -4
- pyobs_gui-2.6.0/tests/test_gui_notifications.py +171 -0
- pyobs_gui-2.6.0/tests/test_import_order.py +22 -0
- pyobs_gui-2.6.0/tests/test_livestream.py +109 -0
- pyobs_gui-2.6.0/tests/test_missing_vfs_root.py +92 -0
- pyobs_gui-2.6.0/tests/test_notificationmanager.py +285 -0
- pyobs_gui-2.6.0/tests/test_notifications.py +389 -0
- pyobs_gui-2.6.0/tests/test_notifier_backend.py +205 -0
- pyobs_gui-2.6.0/tests/test_settings.py +201 -0
- pyobs_gui-2.6.0/tests/test_settingsdialog.py +336 -0
- pyobs_gui-2.6.0/tests/test_video_refresh.py +127 -0
- pyobs_gui-2.6.0/tests/test_videowidget.py +258 -0
- pyobs_gui-2.6.0/tests/test_windows_identity.py +69 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/uv.lock +287 -959
- pyobs_gui-2.5.2/pyobs_gui/videowidget.py +0 -268
- pyobs_gui-2.5.2/tests/test_videowidget.py +0 -49
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/.claude/skills/verify/SKILL.md +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/.github/dependabot.yml +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/.github/workflows/dependabot-automerge.yml +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/.github/workflows/pypi.yml +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/.github/workflows/pyrefly.yml +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/.github/workflows/ruff.yml +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/.gitignore +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/.pre-commit-config.yaml +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/.readthedocs.yml +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/CHANGELOG.rst +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/CLAUDE.md +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/LICENSE +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/README.md +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/bin/mypyuic5 +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/docs/Makefile +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/docs/make.bat +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/docs/requirements.txt +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/docs/source/_static/pyobs.gif +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/docs/source/conf.py +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/mypy.ini +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/__main__.py +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/_nuitka_astropy_patch.py +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/accounts.py +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/acquisitionwidget.py +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/autofocuswidget.py +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/autoguidingwidget.py +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/camerawidget.py +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/commandinputwidget.py +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/compassmovewidget.py +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/coolingwidget.py +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/eventswidget.py +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/filterwidget.py +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/fitsheaderswidget.py +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/focuswidget.py +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/logmodel.py +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/modewidget.py +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/modifiedmixin.py +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/modulegui.py +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/movestack.py +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/nowheelfilter.py +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/__init__.py +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/acquisitionwidget.ui +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/acquisitionwidget_ui.py +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/autofocuswidget.ui +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/autofocuswidget_ui.py +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/autoguidingwidget.ui +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/autoguidingwidget_ui.py +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/camerawidget.ui +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/camerawidget_ui.py +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/compassmoveplugin.py +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/compassmovewidget.py +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/compassmovewidget.ui +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/compassmovewidget_ui.py +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/compile.sh +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/coolingwidget.ui +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/coolingwidget_ui.py +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/datadisplayplugin.py +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/datadisplaywidget.ui +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/datadisplaywidget_ui.py +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/designer.sh +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/eventswidget.ui +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/eventswidget_ui.py +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/filterwidget.ui +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/filterwidget_ui.py +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/fitsheaderswidget.ui +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/fitsheaderswidget_ui.py +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/focuswidget.ui +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/focuswidget_ui.py +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/mainwindow.ui +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/mainwindow_ui.py +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/modewidget.ui +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/modewidget_ui.py +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/resources/arrow-alt-circle-down-solid.svg +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/resources/arrow-alt-circle-left-solid.svg +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/resources/arrow-alt-circle-right-solid.svg +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/resources/arrow-alt-circle-up-solid.svg +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/resources/arrow-up-right-from-square-solid.svg +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/resources/arrows-to-eye-solid.svg +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/resources/chart-line-solid.svg +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/resources/circle-question-solid.svg +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/resources/cloud-sun-solid.svg +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/resources/edit-solid.svg +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/resources/search-solid.svg +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/resources/undo-solid.svg +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/resources.qrc +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/resources_rc.py +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/roboticwidget.ui +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/roboticwidget_ui.py +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/roofwidget.ui +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/roofwidget_ui.py +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/schedulewidget.ui +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/schedulewidget_ui.py +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/shellwidget.ui +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/shellwidget_ui.py +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/spectrographwidget.ui +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/spectrographwidget_ui.py +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/telescopewidget.ui +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/telescopewidget_ui.py +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/temperaturesplotwidget.ui +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/temperaturesplotwidget_ui.py +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/temperatureswidget.ui +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/temperatureswidget_ui.py +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/videograbwidget.ui +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/videograbwidget_ui.py +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/weatherwidget.ui +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/qt/weatherwidget_ui.py +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/roboticwidget.py +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/roofwidget.py +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/schedulewidget.py +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/shellwidget.py +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/spectrographwidget.py +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/statuswidget.py +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/structuredconfigwidget.py +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/telescopewidget.py +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/temperaturesplotwidget.py +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/temperatureswidget.py +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/utils.py +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/videograbwidget.py +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/watchedlabel.py +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pyobs_gui/weatherwidget.py +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/pysidedeploy.spec +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/specs/2026-08-20-gui-remote-call-error-handling.md +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/specs/2026-08-21-gui-widget-startup-responsiveness.md +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/specs/2026-08-28-gui-main-vs-sidebar-widgets.md +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/specs/2026-08-28-structuredconfig-widget.md +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/specs/2026-08-31-irobotic-widgets.md +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/specs/2026-09-01-gui-video-widget-split.md +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/specs/2026-09-14-fitswidget-toolbar-overflow.md +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/specs/2026-09-14-stacked-widget-scroll-fallback.md +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/spike_standalone/main.py +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/spike_standalone/pysidedeploy.spec +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/test/acquisition.yaml +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/test/autofocus.yaml +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/test/camera.yaml +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/test/full.yaml +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/test/guiding.yaml +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/test/mode.yaml +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/test/robotic.yaml +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/test/roof.yaml +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/test/spectrograph.yaml +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/test/structuredconfig.yaml +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/test/telescope.yaml +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/test/telescope_acl.yaml +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/test/telescope_acl_denied.yaml +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/test/video.yaml +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/tests/__init__.py +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/tests/conftest.py +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/tests/test_accounts.py +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/tests/test_camerawidget.py +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/tests/test_loginwindow.py +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/tests/test_mainwindow_startup.py +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/tests/test_modewidget.py +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/tests/test_multiwidget_pages.py +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/tests/test_roboticwidget.py +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/tests/test_schedulewidget.py +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/tests/test_spectrographwidget.py +0 -0
- {pyobs_gui-2.5.2 → pyobs_gui-2.6.0}/tests/test_structuredconfigwidget.py +0 -0
- {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.
|
|
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:
|
|
15
|
-
Requires-Dist:
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"]
|