Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 58 additions & 3 deletions pyobs_gui/base.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import asyncio
import contextlib
import logging
from typing import TYPE_CHECKING, Any, Callable, Type, TypeVar, overload

Expand Down Expand Up @@ -44,6 +45,13 @@ async def show_remote_error(parent: QtWidgets.QWidget, exception: Exception) ->
await QAsyncMessageBox.warning(parent, "Error", str(exception))


async def cancel_and_drain(task: asyncio.Task[Any]) -> None:
"""Cancel a task and await its unwind, swallowing the resulting CancelledError."""
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task


class BaseWindow:
def __init__(self) -> None:
"""Base class for MainWindow and all widgets."""
Expand Down Expand Up @@ -169,6 +177,14 @@ def __init__(
# has it been initialized?
self._initialized = False

# memoized one-shot init task -- two rapid show/hide/show cycles must not run _init()
# twice concurrently (see _showEvent)
self._init_task: asyncio.Task[Any] | None = None

# keys of _init_once() sub-steps that already completed, so a retried _init() (see
# _showEvent) skips steps that already ran instead of re-subscribing to comm state
self._init_steps_done: set[str] = set()

# methods this GUI is permitted to invoke on self.module; None until fetched or if the
# fetch failed, meaning "treat everything as permitted"
self._permitted_methods: set[str] | None = None
Expand Down Expand Up @@ -251,6 +267,15 @@ async def discard(self) -> None:
in Comm._event_handlers forever, keeping this widget alive and still reacting to
events for as long as the app runs.
"""
# cancel a still-in-flight one-time _init() first: comm's own disconnect-triggered
# subscription cleanup (Comm._client_disconnected) runs before this method is called,
# so a subscribe_state() call inside _init_task that resolves afterwards would leak a
# subscription bound to this now-discarded widget -- the same stale-callback crash
# class this method otherwise guards against
if self._init_task is not None:
await cancel_and_drain(self._init_task)
self._init_task = None

for event_class, handler in self._registered_event_handlers:
await self.comm.unregister_event(event_class, handler)
self._registered_event_handlers.clear()
Expand All @@ -263,14 +288,44 @@ def showEvent(self, event: QtGui.QShowEvent) -> None:
asyncio.create_task(self._showEvent(event))

async def _showEvent(self, event: QtGui.QShowEvent) -> None:
if self._initialized is False and hasattr(self, "_init"):
await self._init()
self._initialized = True
if self._initialized is False:
if self._init_task is None:
self._init_task = asyncio.create_task(self._run_init())
try:
await self._init_task
except Exception:
# transient init failure (e.g. a comm RPC hiccup): log, and allow a retry on
# the next show instead of leaving the widget permanently marked initialized
log.exception("Init of %s failed; will retry on next show.", type(self).__name__)
self._init_task = None

if self._update_func:
# start update task
self._update_task = asyncio.create_task(self._update_loop())

async def _run_init(self) -> None:
await self._init()
self._initialized = True

async def _init(self) -> None:
"""Default no-op init. Widgets with one-time subscription/state setup override this;
_showEvent memoizes the call so it runs exactly once per widget lifetime."""

async def _init_once(self, key: str, coro_func: Callable[..., Coroutine[Any, Any, None]], *args: Any) -> None:
"""Run one sub-step of _init() at most once across retries.

_showEvent retries the whole _init() after a partial failure (e.g. one of several
gathered comm.subscribe_state() calls raising while its siblings already subscribed).
comm.subscribe_state() unconditionally appends to comm's subscription list with no
deduplication, so a sub-step that already succeeded must not run again on retry --
override _init() and wrap each independent, subscribing sub-step in this instead of
calling it directly.
"""
if key in self._init_steps_done:
return
await coro_func(*args)
self._init_steps_done.add(key)

def hideEvent(self, event: QtGui.QHideEvent) -> None:
# run in loop
asyncio.create_task(self._hide_event(event))
Expand Down
52 changes: 42 additions & 10 deletions pyobs_gui/camerawidget.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import asyncio
import logging
from typing import Any
from PySide6 import QtWidgets, QtCore # type: ignore
Expand Down Expand Up @@ -38,6 +39,13 @@
log = logging.getLogger(__name__)


# how long to wait for a module's first state value when initializing each control. The comm
# default is 10 s per interface, which would let a slow-publishing camera hold the page blank
# for ~70 s in the worst case; with this shorter timeout the control just keeps its default
# value and the subscription callback corrects it as soon as state arrives.
_WAIT_FOR_STATE_TIMEOUT = 2.0


class CameraWidget(BaseWidget, Ui_CameraWidget):
signal_update_gui = QtCore.Signal()
signal_new_image = QtCore.Signal(NewImageEvent, str)
Expand Down Expand Up @@ -108,6 +116,26 @@ async def open(
await self.add_to_sidebar(self.create_widget(TemperaturesWidget, module=self.module))

async def _init(self) -> None:
# every interface is initialized independently (caps -> state -> subscribe, in that
# order per interface), so run them concurrently instead of serially: a slow-publishing
# camera used to hold the page blank for up to ~70 s (7 interfaces x the 10 s default
# wait_for_state timeout); now each interface waits at most _WAIT_FOR_STATE_TIMEOUT and
# the others fill in regardless
await asyncio.gather(
self._init_once("window", self._init_window),
self._init_once("binning", self._init_binning),
self._init_once("gain", self._init_gain),
self._init_once("image_format", self._init_image_format),
self._init_once("image_type", self._init_image_type),
self._init_once("exposure", self._init_exposure),
self._init_once("exposure_time", self._init_exposure_time),
self._init_once("data_sequence", self._init_data_sequence),
)

# update GUI
self.signal_update_gui.emit()

async def _init_window(self) -> None:
# window
window_caps = await self.comm.get_capabilities(self.module, IWindow)
if window_caps is not None:
Expand All @@ -117,7 +145,7 @@ async def _init(self) -> None:
self.spinWindowHeight.setMaximum(int(window_caps.full_frame_height))
async with self.comm.safe_proxy(self.module, IWindow) as proxy:
if proxy is not None:
state = await proxy.wait_for_state(IWindow)
state = await proxy.wait_for_state(IWindow, timeout=_WAIT_FOR_STATE_TIMEOUT)
if state is not None:
self.spinWindowLeft.setValue(state.x)
self.spinWindowTop.setValue(state.y)
Expand All @@ -129,6 +157,7 @@ async def _init(self) -> None:
self.spinWindowWidth.setValue(window_caps.full_frame_width)
self.spinWindowHeight.setValue(window_caps.full_frame_height)

async def _init_binning(self) -> None:
# binning
binning_caps = await self.comm.get_capabilities(self.module, IBinning)
if binning_caps is not None:
Expand All @@ -137,20 +166,22 @@ async def _init(self) -> None:
self.comboBinning.addItems(binnings)
async with self.comm.safe_proxy(self.module, IBinning) as proxy:
if proxy is not None:
state = await proxy.wait_for_state(IBinning)
state = await proxy.wait_for_state(IBinning, timeout=_WAIT_FOR_STATE_TIMEOUT)
if state is not None:
self.comboBinning.setCurrentText(f"{state.x}x{state.y}")
await self.comm.subscribe_state(self.module, IBinning, self._update_binning)

async def _init_gain(self) -> None:
# gain
async with self.comm.safe_proxy(self.module, IGain) as proxy:
if proxy is not None:
state = await proxy.wait_for_state(IGain)
state = await proxy.wait_for_state(IGain, timeout=_WAIT_FOR_STATE_TIMEOUT)
if state is not None:
self.spinGain.setValue(state.gain)
self.spinGainOffset.setValue(state.offset)
await self.comm.subscribe_state(self.module, IGain, self._update_gain)

async def _init_image_format(self) -> None:
# image format
image_format_caps = await self.comm.get_capabilities(self.module, IImageFormat)
if image_format_caps is not None:
Expand All @@ -159,44 +190,45 @@ async def _init(self) -> None:
self.comboImageFormat.addItems([f.name for f in image_formats])
async with self.comm.safe_proxy(self.module, IImageFormat) as proxy:
if proxy is not None:
state = await proxy.wait_for_state(IImageFormat)
state = await proxy.wait_for_state(IImageFormat, timeout=_WAIT_FOR_STATE_TIMEOUT)
if state is not None:
self.comboImageFormat.setCurrentText(state.image_format.name)
await self.comm.subscribe_state(self.module, IImageFormat, self._update_image_format)

async def _init_image_type(self) -> None:
# image type
async with self.comm.safe_proxy(self.module, IImageType) as proxy:
if proxy is not None:
state = await proxy.wait_for_state(IImageType)
state = await proxy.wait_for_state(IImageType, timeout=_WAIT_FOR_STATE_TIMEOUT)
if state is not None:
self.comboImageType.setCurrentText(state.image_type.name)
await self.comm.subscribe_state(self.module, IImageType, self._update_image_type)

async def _init_exposure(self) -> None:
# exposure (status, progress, time left)
async with self.comm.safe_proxy(self.module, IExposure) as proxy:
if proxy is not None:
state = await proxy.wait_for_state(IExposure)
state = await proxy.wait_for_state(IExposure, timeout=_WAIT_FOR_STATE_TIMEOUT)
if state is not None:
self.exposure_status = state.status
self.exposure_progress = state.progress
self.exposure_time_left = state.exposure_time_left
await self.comm.subscribe_state(self.module, IExposure, self._update_exposure)

async def _init_exposure_time(self) -> None:
# exposure time
async with self.comm.safe_proxy(self.module, IExposureTime) as proxy:
if proxy is not None:
state = await proxy.wait_for_state(IExposureTime)
state = await proxy.wait_for_state(IExposureTime, timeout=_WAIT_FOR_STATE_TIMEOUT)
if state is not None:
self.spinExpTime.setValue(state.exposure_time)
await self.comm.subscribe_state(self.module, IExposureTime, self._update_exposure_time)

async def _init_data_sequence(self) -> None:
# data sequence
if await self.comm.has_proxy(self.module, IDataSequence):
await self.comm.subscribe_state(self.module, IDataSequence, self._update_sequence)

# update GUI
self.signal_update_gui.emit()

def _update_binning(self, state: BinningState):
self.comboBinning.setCurrentText(f"{state.x}x{state.y}")

Expand Down
Loading
Loading