diff --git a/acamd/acam_interface.cpp b/acamd/acam_interface.cpp index f8ef8eeb..cce50353 100644 --- a/acamd/acam_interface.cpp +++ b/acamd/acam_interface.cpp @@ -1441,6 +1441,8 @@ namespace Acam { const bool is_acquired = this->target.is_acquired.load(); const int nacquired = this->target.nacquired; const int attempts = this->target.attempts; + const std::string filter = this->motion.get_current_filtername(); + const std::string cover = this->motion.get_current_coverpos(); // unless forced, only publish if there was a change in any one of these // @@ -1448,12 +1450,16 @@ namespace Acam { acquire_mode == this->last_status.acquire_mode && is_acquired == this->last_status.is_acquired && nacquired == this->last_status.nacquired && - attempts == this->last_status.attempts ) return; + attempts == this->last_status.attempts && + filter == this->last_status.filter && + cover == this->last_status.cover ) return; this->last_status.acquire_mode = acquire_mode; this->last_status.is_acquired = is_acquired; this->last_status.nacquired = nacquired; this->last_status.attempts = attempts; + this->last_status.filter = filter; + this->last_status.cover = cover; // assemble the telemetry into a json message // @@ -1465,6 +1471,8 @@ namespace Acam { jmessage_out[Key::Acamd::ATTEMPTS] = this->target.attempts; jmessage_out[Key::Acamd::SEEING] = this->astrometry.get_seeing(); jmessage_out[Key::Acamd::BACKGROUND] = this->astrometry.get_background(); + jmessage_out[Key::Acamd::FILTER] = filter; + jmessage_out[Key::Acamd::COVER] = cover; jmessage_out[Key::PUBTIME] = get_time_us(); try { @@ -3878,6 +3886,8 @@ logwrite( function, message.str() ); iface.guide_manager.filter="error"; } + iface.publish_status(); // push filter change to subscribers + return; } /***** Acam::Interface::dothread_set_filter *********************************/ diff --git a/acamd/acam_interface.h b/acamd/acam_interface.h index 9793d863..1034e836 100644 --- a/acamd/acam_interface.h +++ b/acamd/acam_interface.h @@ -516,6 +516,8 @@ namespace Acam { bool is_acquired = false; int nacquired = 0; int attempts = 0; + std::string filter = ""; + std::string cover = ""; } last_status; public: diff --git a/acamd/acam_server.cpp b/acamd/acam_server.cpp index 85d5444c..50a389cc 100644 --- a/acamd/acam_server.cpp +++ b/acamd/acam_server.cpp @@ -913,6 +913,7 @@ namespace Acam { if ( cmd == ACAMD_FILTER ) { ret = this->interface.motion.filter( args, retstring ); if (ret==NO_ERROR) this->interface.guider_settings_control(); // update Guider GUI display igores ret + if (ret==NO_ERROR) this->interface.publish_status(); // push filter change to subscribers } else @@ -920,6 +921,7 @@ namespace Acam { // if ( cmd == ACAMD_COVER ) { ret = this->interface.motion.cover( args, retstring ); + if (ret==NO_ERROR) this->interface.publish_status(); // push cover change to subscribers } else diff --git a/sequencerd/sequence.cpp b/sequencerd/sequence.cpp index 69923bfc..5f004244 100644 --- a/sequencerd/sequence.cpp +++ b/sequencerd/sequence.cpp @@ -2455,13 +2455,13 @@ namespace Sequencer { /***** Sequencer::Sequence::abort_process *********************************/ /** * @brief tries to abort everything happening - * @details Sets SEQ_ABORTING via RAII for the duration of the abort, - * then on exit: - * - if aborting during RUNNING or PAUSED, restores SEQ_READY - * - if aborting during STARTING or STOPPING, sets SEQ_FAILED + * @details Sets SEQ_ABORTING for the duration of the abort, then on exit + * selects a one-hot terminal seqstate: + * - aborting during STARTING or STOPPING sets SEQ_FAILED * (indeterminate lifecycle state; requires startup/shutdown * to clear) - * - otherwise leaves seqstate unchanged on exit + * - otherwise the terminal state reflects actual readiness: + * SEQ_READY when all daemons are ready, else SEQ_NOTREADY * */ void Sequence::abort_process() { @@ -2538,17 +2538,22 @@ namespace Sequencer { } } - // Exit SEQ_ABORTING to a strict one-hot terminal state chosen from the - // snapshot taken at entry. If neither condition applies (e.g. abort - // invoked while READY/NOTREADY/FAILED) we leave the state at NOTREADY - // so callers never see SEQ_ABORTING linger and no prior bit is retained. + // Exit SEQ_ABORTING to a strict one-hot terminal state. A lifecycle abort + // (STARTING/STOPPING) leaves hardware in an indeterminate state, so it must + // go to SEQ_FAILED (cleared only by a subsequent startup/shutdown). + // Otherwise the abort has only stopped activity, so the terminal state is + // the system's actual readiness: SEQ_READY when every subsystem is ready, + // else SEQ_NOTREADY. This mirrors the readiness contract used by startup() + // (the are_all_set gate) and broadcast_daemonstate(), so an abort issued + // while already READY with all daemons up returns to READY -- and one + // issued after a daemon dropped correctly settles on NOTREADY. // - if ( abort_during_run ) { - this->seq_state_manager.set_only( {Sequencer::SEQ_READY} ); - } - else if ( abort_during_lifecycle ) { + if ( abort_during_lifecycle ) { this->seq_state_manager.set_only( {Sequencer::SEQ_FAILED} ); } + else if ( this->daemon_manager.are_all_set() ) { + this->seq_state_manager.set_only( {Sequencer::SEQ_READY} ); + } else { this->seq_state_manager.set_only( {Sequencer::SEQ_NOTREADY} ); } diff --git a/utils/seqgui/broadcast_log.py b/utils/seqgui/broadcast_log.py index ce1d10a8..d5d0522e 100644 --- a/utils/seqgui/broadcast_log.py +++ b/utils/seqgui/broadcast_log.py @@ -15,6 +15,9 @@ def __init__(self, parent=None): def init_ui(self): self.setReadOnly(True) self.setMaximumBlockCount(2000) + # Don't wrap long lines; show a horizontal scrollbar instead (as needed), + # mirroring the vertical one. + self.setLineWrapMode(QPlainTextEdit.NoWrap) font = QFont("Monospace") font.setStyleHint(QFont.TypeWriter) font.setPointSize(9) diff --git a/utils/seqgui/panels.py b/utils/seqgui/panels.py index 95e97603..1d1f9612 100644 --- a/utils/seqgui/panels.py +++ b/utils/seqgui/panels.py @@ -1,7 +1,8 @@ from PyQt5.QtCore import Qt from PyQt5.QtGui import QFont from PyQt5.QtWidgets import ( - QFrame, QGridLayout, QHBoxLayout, QLabel, QSizePolicy, QVBoxLayout, QWidget + QGridLayout, QGroupBox, QHBoxLayout, QLabel, QProgressBar, QSizePolicy, + QVBoxLayout ) # Wait-state JSON keys (mirrors Sequencer::wait_state_names in sequence.h) @@ -38,6 +39,8 @@ KEY_ACQUIRE_MODE = "acquire_mode" KEY_IS_ACQUIRED = "is_acquired" KEY_SEEING = "seeing" +KEY_FILTER = "filter" +KEY_COVER = "cover" KEY_FINEACQUIRE_LOCKED = "fineacquire_locked" KEY_FINEACQUIRE_RUNNING = "fineacquire_running" KEY_AUTOEXPOSE_RUNNING = "autoexpose_running" @@ -57,6 +60,13 @@ COLOR_BG_DARK = "#1e1e1e" # panel background COLOR_BG_MID = "#2a2a2a" # widget background +# Type scale -- keep the UI on a small, deliberate set of sizes +FONT_PT_STATE = 20 # the big STATE badge +FONT_PT_READOUT = 11 # numeric readouts (seeing) +FONT_PT_LABEL = 10 # default labels / badges +FONT_PT_SMALL = 8 # legends / progress text +FONT_MONO = "Monospace" # numeric readouts, so digits don't jitter + def color_for_state(state): """ Return a CSS hex color for a seqstate label string. """ @@ -70,33 +80,6 @@ def color_for_state(state): return COLOR_GRAY -def section_label(text): - """ Return a small bold section-header label. """ - lbl = QLabel(text) - lbl.setStyleSheet("font-weight: bold; color: #aaaaaa; font-size: 10pt;") - return lbl - - -def hline(): - """ Return a thin horizontal separator. """ - line = QFrame() - line.setFrameShape(QFrame.HLine) - line.setFrameShadow(QFrame.Sunken) - line.setStyleSheet("color: #333333;") - line.setMaximumHeight(2) - return line - - -def vline(): - """ Return a thin vertical separator for use in horizontal layouts. """ - line = QFrame() - line.setFrameShape(QFrame.VLine) - line.setFrameShadow(QFrame.Sunken) - line.setStyleSheet("color: #333333;") - line.setMaximumWidth(2) - return line - - class Badge(QLabel): """ Tri-state colored pill: not-ready (dim), ready (green), busy (blink). """ @@ -108,7 +91,7 @@ def init_ui(self): self.setAlignment(Qt.AlignCenter) font = self.font() font.setBold(True) - font.setPointSize(10) + font.setPointSize(FONT_PT_LABEL) self.setFont(font) self.setMinimumWidth(82) self.setFixedHeight(28) @@ -136,7 +119,7 @@ def set_blink(self, phase): self._set("#cccccc", COLOR_GREEN_DIM) -class SubsystemPanel(QWidget): +class SubsystemPanel(QGroupBox): """ Tri-state grid of daemon badges plus a sequencerd online indicator. """ def __init__(self, parent=None): @@ -147,10 +130,10 @@ def __init__(self, parent=None): self.init_ui() def init_ui(self): + self.setTitle("SUBSYSTEMS") layout = QVBoxLayout(self) layout.setContentsMargins(8, 6, 8, 6) layout.setSpacing(4) - layout.addWidget(section_label("SUBSYSTEMS")) grid = QGridLayout() grid.setHorizontalSpacing(5) @@ -164,7 +147,7 @@ def init_ui(self): layout.addLayout(grid) legend = QLabel("dim = not ready green = ready blink = busy") - legend.setStyleSheet("color: #555555; font-size: 8pt;") + legend.setStyleSheet(f"color: #555555; font-size: {FONT_PT_SMALL}pt;") layout.addWidget(legend) def set_sequencerd_online(self): @@ -201,7 +184,7 @@ def blink_tick(self, phase): self.badges[name].set_blink(phase) -class StatePanel(QWidget): +class StatePanel(QGroupBox): """ Left panel: STATE badge and (when active) TCSOP / USER alert. """ def __init__(self, parent=None): @@ -211,18 +194,18 @@ def __init__(self, parent=None): self.init_ui() def init_ui(self): - self.setMinimumWidth(160) + self.setTitle("STATE") + self.setMinimumWidth(130) self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding) layout = QVBoxLayout(self) layout.setContentsMargins(8, 6, 8, 6) layout.setSpacing(8) - layout.addWidget(section_label("STATE")) self.state_label = QLabel("") self.state_label.setAlignment(Qt.AlignCenter) font = QFont() - font.setPointSize(20) + font.setPointSize(FONT_PT_STATE) font.setBold(True) self.state_label.setFont(font) self.state_label.setFixedHeight(60) @@ -307,8 +290,8 @@ def blink_tick(self, phase): ) -class CameraPanel(QWidget): - """ Exposing / Reading out indicators. """ +class CameraPanel(QGroupBox): + """ Exposure / readout progress bars whose labels track camerad state. """ def __init__(self, parent=None): super().__init__(parent) @@ -317,66 +300,103 @@ def __init__(self, parent=None): self.init_ui() def init_ui(self): + self.setTitle("CAMERA") layout = QVBoxLayout(self) layout.setContentsMargins(8, 6, 8, 6) layout.setSpacing(4) - layout.addWidget(section_label("CAMERA")) - row = QHBoxLayout() - row.setSpacing(8) - self.lbl_exposing = self._make_indicator("Exposing") - self.lbl_readout = self._make_indicator("Reading out") - row.addWidget(self.lbl_exposing) - row.addWidget(self.lbl_readout) - row.addStretch(1) - layout.addLayout(row) + # Exposure / readout progress bars are filled from the UDP async stream; + # their row labels recolor (idle -> active) from the camerad ZMQ + # exposing/inreadout flags, so the two transports cross-check each other. + prog = QGridLayout() + prog.setHorizontalSpacing(8) + prog.setVerticalSpacing(4) + prog.setContentsMargins(0, 2, 0, 0) + self.lbl_exposure = self._make_bar_label("Exposure") + self.lbl_readout = self._make_bar_label("Readout") + self.exposure_bar = self._make_progress_bar() + self.readout_bar = self._make_progress_bar() + self.exposure_value = self._make_value_label() + self.readout_value = self._make_value_label() + prog.addWidget(self.lbl_exposure, 0, 0) + prog.addWidget(self.exposure_bar, 0, 1) + prog.addWidget(self.exposure_value, 0, 2) + prog.addWidget(self.lbl_readout, 1, 0) + prog.addWidget(self.readout_bar, 1, 1) + prog.addWidget(self.readout_value, 1, 2) + prog.setColumnStretch(1, 1) + layout.addLayout(prog) @staticmethod - def _make_indicator(text): + def _make_progress_bar(): + bar = QProgressBar() + bar.setRange(0, 100) + bar.setValue(0) + bar.setFixedHeight(16) + bar.setTextVisible(False) # percent shown only while active + bar.setStyleSheet( + "QProgressBar {" + f" background-color: {COLOR_BG_MID}; color: {COLOR_GRAY}; " + " border: 1px solid #444; border-radius: 4px; text-align: center; " + f" font-family: '{FONT_MONO}'; font-size: {FONT_PT_SMALL}pt;" + "}" + f"QProgressBar::chunk {{ background-color: {COLOR_GREEN}; " + " border-radius: 3px; }" + ) + return bar + + @staticmethod + def _make_bar_label(text): lbl = QLabel(text) - lbl.setAlignment(Qt.AlignCenter) font = lbl.font() font.setBold(True) - font.setPointSize(10) lbl.setFont(font) - lbl.setFixedHeight(28) - lbl.setMinimumWidth(110) - lbl.setStyleSheet( - f"color: {COLOR_INACTIVE}; background-color: {COLOR_BG_MID}; " - "border: 1px solid #444; border-radius: 5px; padding: 0 8px;" - ) + lbl.setStyleSheet(f"color: {COLOR_GRAY};") return lbl @staticmethod - def _set_idle(lbl): + def _make_value_label(): + """ Right-aligned monospaced readout to the right of a bar, so the + number stays legible (never drawn over the green chunk). """ + lbl = QLabel("") + lbl.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + lbl.setMinimumWidth(44) lbl.setStyleSheet( - f"color: {COLOR_INACTIVE}; background-color: {COLOR_BG_MID}; " - "border: 1px solid #444; border-radius: 5px; padding: 0 8px;" + f"color: #cccccc; font-family: '{FONT_MONO}'; font-size: {FONT_PT_LABEL}pt;" ) + return lbl @staticmethod - def _set_blink(lbl, phase): - if phase: - lbl.setStyleSheet( - f"color: #000000; background-color: {COLOR_GREEN}; " - "border: 1px solid #22aa22; border-radius: 5px; padding: 0 8px;" - ) - else: - lbl.setStyleSheet( - f"color: {COLOR_GREEN}; background-color: {COLOR_GREEN_DIM}; " - "border: 1px solid #22aa22; border-radius: 5px; padding: 0 8px;" - ) + def _set_label_active(lbl, active): + """ Recolor a bar label: bright green when active, dim gray when idle. """ + lbl.setStyleSheet(f"color: {COLOR_GREEN if active else COLOR_GRAY};") + + def set_exposure_progress(self, percent, seconds): + """ Exposure bar shows percent; the value shows seconds remaining. """ + self.exposure_bar.setValue(percent) + self.exposure_value.setText(f"{seconds}s") + + def set_readout_progress(self, percent, eta): + """ Readout bar shows percent; the value shows the ETA in seconds once a + pixel-rate estimate exists, otherwise falls back to percent. """ + self.readout_bar.setValue(percent) + self.readout_value.setText(f"{eta}s" if eta >= 0 else f"{percent}%") def set_camerad(self, data): - """ Update exposing / readout active flags from a camerad payload. """ + """ Update exposing / readout state from a camerad payload: recolor the + row labels and clear a bar when its phase ends. """ if KEY_EXPOSING in data: self.exposing_active = bool(data[KEY_EXPOSING]) + self._set_label_active(self.lbl_exposure, self.exposing_active) if not self.exposing_active: - self._set_idle(self.lbl_exposing) + self.exposure_bar.setValue(0) + self.exposure_value.setText("") if KEY_INREADOUT in data: self.readout_active = bool(data[KEY_INREADOUT]) + self._set_label_active(self.lbl_readout, self.readout_active) if not self.readout_active: - self._set_idle(self.lbl_readout) + self.readout_bar.setValue(0) + self.readout_value.setText("") def set_camerad_online(self, online): """ Clear indicators when camerad goes offline (defense-in-depth). @@ -385,20 +405,20 @@ def set_camerad_online(self, online): if not online: self.exposing_active = False self.readout_active = False - self._set_idle(self.lbl_exposing) - self._set_idle(self.lbl_readout) - - def blink_tick(self, phase): - """ Drive blink phase for any active camera indicators. """ - if self.exposing_active: - self._set_blink(self.lbl_exposing, phase) - if self.readout_active: - self._set_blink(self.lbl_readout, phase) + self._set_label_active(self.lbl_exposure, False) + self._set_label_active(self.lbl_readout, False) + self.exposure_bar.setValue(0) + self.readout_bar.setValue(0) + self.exposure_value.setText("") + self.readout_value.setText("") -class AcquisitionPanel(QWidget): +class AcquisitionPanel(QGroupBox): """ ACAM + SLICECAM acquisition status display. """ + # equal-width row prefixes so the ACAM / SLICECAM badge columns line up + PREFIX_WIDTH = 72 + def __init__(self, parent=None): super().__init__(parent) self.acquiring_active = False @@ -408,15 +428,15 @@ def __init__(self, parent=None): self.init_ui() def init_ui(self): + self.setTitle("ACQUISITION") layout = QVBoxLayout(self) layout.setContentsMargins(8, 6, 8, 6) - layout.setSpacing(3) - layout.addWidget(section_label("ACQUISITION")) + layout.setSpacing(6) # ACAM row acam_row = QHBoxLayout() acam_row.setSpacing(8) - acam_row.addWidget(QLabel("ACAM:")) + acam_row.addWidget(self._row_prefix("ACAM:")) self.acam_mode_badge = Badge("acquiring") self.acam_guiding_badge = Badge("guiding") @@ -428,16 +448,37 @@ def init_ui(self): acam_row.addSpacing(16) self.seeing = QLabel("seeing: --") self.seeing.setStyleSheet( - f"color: {COLOR_BLUE}; font-weight: bold; font-size: 11pt;" + f"color: {COLOR_BLUE}; font-weight: bold; " + f"font-family: '{FONT_MONO}'; font-size: {FONT_PT_READOUT}pt;" ) acam_row.addWidget(self.seeing) acam_row.addStretch(1) layout.addLayout(acam_row) + # ACAM cover + filter, aligned under the ACAM badges + cfg_row = QHBoxLayout() + cfg_row.setSpacing(8) + cfg_row.addWidget(self._row_prefix("")) + + cfg_row.addWidget(QLabel("cover")) + self.cover_dot = QLabel() + self.cover_dot.setFixedSize(14, 14) + cfg_row.addWidget(self.cover_dot) + + cfg_row.addSpacing(16) + self.filter_label = QLabel("filter: --") + self.filter_label.setStyleSheet( + f"color: {COLOR_GRAY}; font-family: '{FONT_MONO}'; font-size: {FONT_PT_READOUT}pt;" + ) + cfg_row.addWidget(self.filter_label) + cfg_row.addStretch(1) + layout.addLayout(cfg_row) + self._set_cover("--") + # SLICECAM row slice_row = QHBoxLayout() slice_row.setSpacing(8) - slice_row.addWidget(QLabel("SLICECAM:")) + slice_row.addWidget(self._row_prefix("SLICECAM:")) self.lbl_locked = Badge("locked") self.lbl_running = Badge("running") @@ -448,6 +489,27 @@ def init_ui(self): slice_row.addStretch(1) layout.addLayout(slice_row) + def _row_prefix(self, text): + """ Fixed-width row label so the ACAM / SLICECAM badge columns align. """ + lbl = QLabel(text) + lbl.setMinimumWidth(self.PREFIX_WIDTH) + return lbl + + def _set_cover(self, pos): + """ Paint the cover dot: open = green ring, closed = filled dim disc, + home/unknown = neutral outline. `pos` is the raw acamd cover string. """ + p = str(pos).lower() + if p == "open": + fill, edge = "transparent", COLOR_GREEN + elif p.startswith("close"): + fill, edge = COLOR_GRAY, COLOR_GRAY + else: + fill, edge = COLOR_BG_MID, COLOR_INACTIVE + self.cover_dot.setStyleSheet( + f"background-color: {fill}; border: 2px solid {edge}; border-radius: 7px;" + ) + self.cover_dot.setToolTip(f"cover: {pos}") + def _apply_guiding_blue(self): """ Paint the guiding badge with a steady solid-blue style. """ self.acam_guiding_badge.setStyleSheet( @@ -484,6 +546,10 @@ def set_acamd(self, data): self.seeing.setText(f"seeing: {float(data[KEY_SEEING]):.2f}\"") except (TypeError, ValueError): self.seeing.setText("seeing: --") + if KEY_COVER in data: + self._set_cover(data[KEY_COVER]) + if KEY_FILTER in data: + self.filter_label.setText(f"filter: {data[KEY_FILTER]}") def set_slicecamd(self, data): """ Update SLICECAM locked / running flags from slicecamd payload. """ @@ -514,6 +580,8 @@ def set_acamd_online(self, online): self.acam_guiding_badge.set_not_ready() self.acam_acquired_badge.set_not_ready() self.seeing.setText("seeing: --") + self._set_cover("--") + self.filter_label.setText("filter: --") def set_slicecamd_online(self, online): """ Clear SLICECAM indicators when slicecamd goes offline. """ diff --git a/utils/seqgui/seqgui.py b/utils/seqgui/seqgui.py index 4f4ac5b0..a2315d6b 100644 --- a/utils/seqgui/seqgui.py +++ b/utils/seqgui/seqgui.py @@ -2,7 +2,9 @@ import argparse from PyQt5.QtCore import QTimer from PyQt5.QtGui import QFont -from PyQt5.QtWidgets import QApplication, QMainWindow, QWidget, QHBoxLayout, QVBoxLayout +from PyQt5.QtWidgets import ( + QApplication, QMainWindow, QWidget, QGroupBox, QHBoxLayout, QVBoxLayout +) from zmq_service import ( SeqguiZmqService, SeqguiZmqServiceThread, @@ -12,9 +14,11 @@ from panels import ( StatePanel, SubsystemPanel, CameraPanel, AcquisitionPanel, WAIT_TCSOP, WAIT_USER, - hline, vline, section_label, ) from broadcast_log import BroadcastLog +from udp_service import ( + SeqguiUdpService, SeqguiUdpServiceThread, MULTICAST_GROUP, MULTICAST_PORT, +) BROKER_SUB_ENDPOINT = "tcp://localhost:5556" @@ -24,15 +28,19 @@ class SeqguiMainWindow(QMainWindow): - def __init__(self, sub_endpoint=BROKER_SUB_ENDPOINT, pub_endpoint=BROKER_PUB_ENDPOINT): + def __init__(self, sub_endpoint=BROKER_SUB_ENDPOINT, pub_endpoint=BROKER_PUB_ENDPOINT, + udp_group=MULTICAST_GROUP, udp_port=MULTICAST_PORT): super().__init__() self.setWindowTitle("Sequencer Monitor") - self.resize(1000, 554) self.sub_endpoint = sub_endpoint self.pub_endpoint = pub_endpoint + self.udp_group = udp_group + self.udp_port = udp_port self.zmq_service = None self.zmq_service_thread = None + self.udp_service = None + self.udp_service_thread = None # Dark theme applied globally via stylesheet self.setStyleSheet( @@ -42,13 +50,27 @@ def __init__(self, sub_endpoint=BROKER_SUB_ENDPOINT, pub_endpoint=BROKER_PUB_END " background-color: #111111; color: #cccccc;" " border: 1px solid #444;" "}" + "QGroupBox {" + " border: 1px solid #3a3a3a; border-radius: 6px;" + " margin-top: 14px; padding-top: 2px;" + "}" + "QGroupBox::title {" + " subcontrol-origin: margin; subcontrol-position: top left;" + " left: 10px; padding: 0 4px;" + " color: #aaaaaa; font-weight: bold; font-size: 10pt;" + "}" ) # Initialize the UI self.init_ui() + # Open at a fixed, non-resizable size (the status layout's minimum). + self.centralWidget().layout().activate() + self.setFixedSize(self.minimumSizeHint().width(), 554) + # Initialize services self.initialize_services() + self.initialize_udp_service() # Single shared blink timer drives all animated widgets self.blink_phase = False @@ -69,11 +91,9 @@ def init_ui(self): right = QWidget() right_layout = QVBoxLayout(right) right_layout.setContentsMargins(0, 0, 0, 0) - right_layout.setSpacing(0) + right_layout.setSpacing(6) right_layout.addWidget(self.subsys_panel, 0) - right_layout.addWidget(hline()) right_layout.addWidget(self.camera_panel, 0) - right_layout.addWidget(hline()) right_layout.addWidget(self.acq_panel, 0) right_layout.addStretch(1) @@ -81,21 +101,23 @@ def init_ui(self): top = QWidget() top_layout = QHBoxLayout(top) top_layout.setContentsMargins(0, 0, 0, 0) - top_layout.setSpacing(0) + top_layout.setSpacing(6) top_layout.addWidget(self.state_panel, 0) - top_layout.addWidget(vline()) top_layout.addWidget(right, 1) + # MESSAGES card wrapping the broadcast log + msg_box = QGroupBox("MESSAGES") + msg_layout = QVBoxLayout(msg_box) + msg_layout.setContentsMargins(6, 4, 6, 6) + msg_layout.addWidget(self.log) + # Central layout: top status, message log central = QWidget() cl = QVBoxLayout(central) - cl.setContentsMargins(4, 4, 4, 4) - cl.setSpacing(0) + cl.setContentsMargins(6, 6, 6, 6) + cl.setSpacing(6) cl.addWidget(top, 0) - cl.addWidget(hline()) - cl.addSpacing(6) - cl.addWidget(section_label("MESSAGES")) - cl.addWidget(self.log, 1) + cl.addWidget(msg_box, 1) self.setCentralWidget(central) def initialize_services(self): @@ -123,6 +145,24 @@ def initialize_services(self): self.zmq_service_thread = SeqguiZmqServiceThread(self.zmq_service) self.zmq_service_thread.start() + def initialize_udp_service(self): + """ Start the UDP multicast listener that drives the camera progress + bars. Failure to open the socket is non-fatal -- the rest of the + GUI still runs, just without progress updates. """ + self.udp_service = SeqguiUdpService(self.udp_group, self.udp_port) + try: + self.udp_service.connect() + except OSError as e: + self.log.append("ERROR", "seqgui", f"UDP listener failed: {e}") + self.udp_service = None + return + self.udp_service.exposure_progress.connect(self.camera_panel.set_exposure_progress) + self.udp_service.readout_progress.connect(self.camera_panel.set_readout_progress) + self.udp_service.connection_error.connect(self._on_connection_error) + + self.udp_service_thread = SeqguiUdpServiceThread(self.udp_service) + self.udp_service_thread.start() + def _on_waitstate(self, state): """ Fan out a waitstate update to the panels that care about it. """ self.subsys_panel.set_waitstate(state) @@ -147,7 +187,6 @@ def _blink_tick(self): self.blink_phase = not self.blink_phase self.state_panel.blink_tick(self.blink_phase) self.subsys_panel.blink_tick(self.blink_phase) - self.camera_panel.blink_tick(self.blink_phase) self.acq_panel.blink_tick(self.blink_phase) def closeEvent(self, event): @@ -156,6 +195,10 @@ def closeEvent(self, event): self.zmq_service.stop() if self.zmq_service_thread is not None: self.zmq_service_thread.wait(2000) + if self.udp_service is not None: + self.udp_service.stop() + if self.udp_service_thread is not None: + self.udp_service_thread.wait(2000) super().closeEvent(event) @@ -165,11 +208,15 @@ def main(): help="ZMQ broker subscriber endpoint") parser.add_argument("--pub", default=BROKER_PUB_ENDPOINT, help="ZMQ broker publisher endpoint") + parser.add_argument("--group", default=MULTICAST_GROUP, + help="UDP multicast group for camera progress") + parser.add_argument("--port", type=int, default=MULTICAST_PORT, + help="UDP multicast port for camera progress") args = parser.parse_args() app = QApplication(sys.argv) app.setFont(QFont("Sans", 10)) - window = SeqguiMainWindow(args.sub, args.pub) + window = SeqguiMainWindow(args.sub, args.pub, args.group, args.port) window.show() return app.exec_() diff --git a/utils/seqgui/udp_service.py b/utils/seqgui/udp_service.py new file mode 100644 index 00000000..cf84d53b --- /dev/null +++ b/utils/seqgui/udp_service.py @@ -0,0 +1,146 @@ +import math +import socket +import struct +import time +from PyQt5.QtCore import pyqtSignal, QObject, QThread + +# camerad async broadcasts go to this multicast group/port (MESSAGEGROUP / +# MESSAGEPORT in the daemon .cfg files; utils/listener.cpp is the C++ reference). +MULTICAST_GROUP = "239.1.1.234" +MULTICAST_PORT = 1300 + +# Async message prefixes we care about (see camerad/astrocam.cpp): +# EXPTIME: Interface::exposure_progress +# PIXELCOUNT_: Callback::readCallback +PREFIX_EXPTIME = "EXPTIME:" +PREFIX_PIXELCOUNT = "PIXELCOUNT_" + + +def _clamp_pct(value): + """ Clamp a numeric percent to the integer range 0-100. """ + return max(0, min(100, int(round(value)))) + + +class SeqguiUdpService(QObject): + """ Listens to the daemon UDP multicast stream and turns camerad's + EXPTIME/PIXELCOUNT async messages into exposure/readout progress + percentages. """ + + exposure_progress = pyqtSignal(int, int) # percent, seconds remaining + readout_progress = pyqtSignal(int, int) # percent, ETA seconds (-1 unknown) + connection_error = pyqtSignal(str) + + def __init__(self, group=MULTICAST_GROUP, port=MULTICAST_PORT): + super().__init__() + self.group = group + self.port = port + self.sock = None + self.running = True + self._readout = {} # devnum -> percent (0-100) + self._readout_rate = {} # devnum -> (time, pixels, ema_rate, imagesize) + + def connect(self): + """ Open and join the UDP multicast group (mirrors utils/listener.cpp). """ + self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) + self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self.sock.bind(("", self.port)) + mreq = struct.pack("4sl", socket.inet_aton(self.group), socket.INADDR_ANY) + self.sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq) + self.sock.settimeout(0.5) # so the loop can poll self.running + + def stop(self): + """ Request a clean shutdown of the listen loop. """ + self.running = False + + def listen(self): + """ Receive datagrams and dispatch recognized progress messages. """ + if self.sock is None: + return + while self.running: + try: + data, _addr = self.sock.recvfrom(1024) + except socket.timeout: + continue + except OSError: + break + for line in data.decode("utf-8", errors="replace").splitlines(): + self._dispatch(line.strip()) + self._close() + + def _dispatch(self, line): + """ Route one async message line to the matching progress parser. """ + if line.startswith(PREFIX_EXPTIME): + self._parse_exposure(line) + elif line.startswith(PREFIX_PIXELCOUNT): + self._parse_readout(line) + + def _parse_exposure(self, line): + """ EXPTIME: . The bar uses the + precomputed percent; the label shows whole seconds remaining. """ + try: + _head, _, rest = line.partition(":") + fields = rest.split() + remain_ms = int(fields[0]) + percent = int(fields[2]) + except (ValueError, IndexError): + return + seconds = max(0, math.ceil(remain_ms / 1000)) + self.exposure_progress.emit(_clamp_pct(percent), seconds) + + def _parse_readout(self, line): + """ PIXELCOUNT_: . The bar tracks + the slowest (min-percent) device; the label shows an ETA estimated + from each device's smoothed pixel rate (last device to finish wins). + Percent fills the bar immediately; the ETA needs two samples. """ + try: + head, _, rest = line.partition(":") + devnum = int(head[len(PREFIX_PIXELCOUNT):]) + fields = rest.split() + pixels = int(fields[0]) + imagesize = int(fields[1]) + percent = int(fields[2]) + except (ValueError, IndexError): + return + # clear stale state once a prior readout has fully completed + if self._readout and all(v >= 100 for v in self._readout.values()): + self._readout = {} + self._readout_rate = {} + + now = time.monotonic() + self._readout[devnum] = percent + + # update this device's smoothed (EMA) pixel rate from the prior sample + prev = self._readout_rate.get(devnum) + ema = prev[2] if prev else None + if prev: + dt = now - prev[0] + dpix = pixels - prev[1] + if dt > 0 and dpix > 0: + rate = dpix / dt + ema = rate if prev[2] is None else 0.3 * rate + 0.7 * prev[2] + self._readout_rate[devnum] = (now, pixels, ema, imagesize) + + # ETA = time for the slowest-finishing device to complete (max over devs) + etas = [(size - pix) / r + for (_, pix, r, size) in self._readout_rate.values() + if r and r > 0 and pix < size] + eta = math.ceil(max(etas)) if etas else -1 + + self.readout_progress.emit(_clamp_pct(min(self._readout.values())), eta) + + def _close(self): + if self.sock is not None: + try: + self.sock.close() + except OSError: + pass + self.sock = None + + +class SeqguiUdpServiceThread(QThread): + def __init__(self, udp_service): + super().__init__() + self.udp_service = udp_service + + def run(self): + self.udp_service.listen()