diff --git a/camerad/astrocam.cpp b/camerad/astrocam.cpp index ac80f291..011734d0 100644 --- a/camerad/astrocam.cpp +++ b/camerad/astrocam.cpp @@ -67,6 +67,21 @@ namespace AstroCam { /***** AstroCam::Interface::handletopic_snapshot ****************************/ + /***** AstroCam::Interface::handletopic_snapshot ****************************/ + /** + * @brief what to do when the topic is Topic::SNAPSHOT + * @details This publishes a JSON message containing a snapshot of my + * telemetry info when the subscriber receives the Topic::SNAPSHOT + * topic and the payload contains my name. + * @param[in] jmessage_in subscribed-received JSON message + * + */ + void Interface::handletopic_snapshot( const nlohmann::json &jmessage_in ) { + if ( jmessage_in.contains( Topic::CAMERAD ) ) this->publish_snapshot(); + } + /***** AstroCam::Interface::handletopic_snapshot ****************************/ + + long NewAstroCam::new_expose( std::string nseq_in ) { logwrite( "NewAstroCam::new_expose", nseq_in ); return( NO_ERROR ); diff --git a/pygui/layout_service.py b/pygui/layout_service.py index 94beab8d..cb3f6bfd 100644 --- a/pygui/layout_service.py +++ b/pygui/layout_service.py @@ -6,6 +6,7 @@ from control_tab import ControlTab from instrument_status_tab import InstrumentStatusTab import re +import subprocess class LayoutService: def __init__(self, parent): @@ -193,15 +194,31 @@ def create_system_status_group(self): # Create a mapping for status colors status_map = { - "stopped": QColor(169, 169, 169), # Grey - "idle": QColor(255, 255, 0), # Yellow - "paused": QColor(255, 165, 0), # Orange - "exposing": QColor(0, 255, 0), # Green - "readout": QColor(0, 255, 0), # Green - "acquire": QColor(255, 255, 0), # Yellow - "focus": QColor(255, 255, 0), # Yellow - "calib": QColor(255, 255, 0), # Yellow - "user": QColor(255, 255, 0), # Yellow + "stopped": QColor(169, 169, 169), + "not_ready": QColor(255, 0, 0), + "idle": QColor(255, 255, 0), + "paused": QColor(255, 165, 0), + "exposing": QColor(0, 255, 0), + "readout": QColor(0, 255, 0), + + "moveto": QColor(255, 255, 0), + "acam_acquire": QColor(255, 255, 0), + "slicecam_fineacquire": QColor(255, 255, 0), + + "focus": QColor(255, 255, 0), + "calib": QColor(255, 255, 0), + "camera": QColor(255, 255, 0), + "flexure": QColor(255, 255, 0), + "power": QColor(255, 255, 0), + "slit": QColor(255, 255, 0), + "tcs": QColor(255, 255, 0), + "tcsop": QColor(255, 255, 0), + "user": QColor(255, 255, 0), + + # transitional / backward compatibility + "acam": QColor(255, 255, 0), + "slicecam": QColor(255, 255, 0), + "acquire": QColor(255, 255, 0), } # Create a dictionary to hold the status widgets, which we will enable/disable @@ -223,7 +240,7 @@ def create_system_status_group(self): status_color_rect.setStyleSheet(f"background-color: {color.name()};") # Label showing the status - status_label = QLabel(status.capitalize()) + status_label = QLabel(status.replace("_", " ").title()) status_label.setMargin(0) # Remove extra margin around the label # Layout for each status (color + label) @@ -359,14 +376,73 @@ def create_sequencer_mode_group(self): sequencer_mode_layout.addWidget(self.parent.sequencer_mode_single) sequencer_mode_layout.addWidget(self.parent.sequencer_mode_all) + # Fine acquire toggle + self.parent.fine_acquire_toggle = QPushButton("Fine Acquire: Disabled") + self.parent.fine_acquire_toggle.setCheckable(True) + self.parent.fine_acquire_toggle.setToolTip("Enable or disable sequencer fine acquire") + self.parent.fine_acquire_toggle.setMaximumWidth(200) + self.parent.fine_acquire_toggle.setStyleSheet(""" + QPushButton { + background-color: #D3D3D3; + color: black; + font-weight: bold; + border-radius: 6px; + padding: 6px; + } + QPushButton:checked { + background-color: #4CAF50; + color: white; + } + """) + self.parent.fine_acquire_toggle.toggled.connect(self.on_fine_acquire_toggled) + sequencer_mode_layout.addWidget(self.parent.fine_acquire_toggle) + sequencer_mode_group.setLayout(sequencer_mode_layout) # Set maximum width and height for the sequencer mode group sequencer_mode_group.setMaximumWidth(300) # Maximum width - sequencer_mode_group.setMaximumHeight(100) # Maximum height + sequencer_mode_group.setMaximumHeight(145) # Maximum height return sequencer_mode_group + def on_fine_acquire_toggled(self, checked): + """Enable/disable the sequencer fine-acquire step.""" + action = "enable" if checked else "disable" + command = ["seq", "fineacquire", action] + button = getattr(self.parent, "fine_acquire_toggle", None) + + if button is not None: + button.setText(f"Fine Acquire: {'Enabled' if checked else 'Disabled'}") + + try: + result = subprocess.run( + command, + check=True, + capture_output=True, + text=True, + ) + + if hasattr(self.parent, "message_log") and self.parent.message_log: + self.update_message_log(f"Ran command: {' '.join(command)}") + if result.stdout.strip(): + self.update_message_log(result.stdout.strip()) + + except Exception as exc: + # Revert the UI if the command failed. + if button is not None: + with QSignalBlocker(button): + button.setChecked(not checked) + button.setText(f"Fine Acquire: {'Disabled' if checked else 'Enabled'}") + + QMessageBox.warning( + self.parent, + "Fine Acquire Command Failed", + f"Could not run: {' '.join(command)}\n\n{exc}", + ) + + if hasattr(self.parent, "message_log") and self.parent.message_log: + self.update_message_log(f"Fine acquire command failed: {' '.join(command)} — {exc}") + def create_progress_and_image_group(self): progress_and_image_group = QGroupBox("Progress and Image Info") progress_and_image_layout = QVBoxLayout() diff --git a/pygui/ngps_gui.py b/pygui/ngps_gui.py index 32cd4236..afaeab3d 100644 --- a/pygui/ngps_gui.py +++ b/pygui/ngps_gui.py @@ -54,6 +54,7 @@ def __init__(self): self.current_owner = None self.current_target_list_name = None self.zmq_status_service = None + self.zmq_debug_messages = False # Login status flag self.logged_in = False @@ -178,7 +179,10 @@ def initialize_services(self): self.status_service.shutter_status_signal.connect(self.layout_service.update_shutter_status) # Initialize the ZMQStatusService - self.zmq_status_service = ZmqStatusService(self) + self.zmq_status_service = ZmqStatusService( + self, + emit_debug_messages=self.zmq_debug_messages, + ) self.zmq_status_service.connect() # # Start the ZMQStatusService in a separate thread @@ -189,6 +193,7 @@ def initialize_services(self): self.zmq_status_service.subscribe_to_topic("tcsd") self.zmq_status_service.subscribe_to_topic("acamd") self.zmq_status_service.subscribe_to_topic("seq_waitstate") + self.zmq_status_service.subscribe_to_topic("seq_seqstate") # Connect the message_received signal from ZMQStatusService to the update_message_log slot self.zmq_status_service.new_message_signal.connect(self.layout_service.update_message_log) @@ -490,6 +495,16 @@ def update_etc_target(self, target_data): self.etc_popup.set_target_info(name, ra, dec) + def set_zmq_debug_messages(self, enabled: bool): + """Enable or disable raw ZMQ messages in the GUI message log.""" + self.zmq_debug_messages = bool(enabled) + + if self.zmq_status_service is not None: + self.zmq_status_service.set_debug_messages(enabled) + + state = "enabled" if enabled else "disabled" + self.layout_service.update_message_log(f"ZMQ debug messages {state}.") + if __name__ == '__main__': app = QApplication(sys.argv) diff --git a/pygui/zmq_status_service.py b/pygui/zmq_status_service.py index 819d8eef..f54f778f 100644 --- a/pygui/zmq_status_service.py +++ b/pygui/zmq_status_service.py @@ -3,7 +3,7 @@ import logging import json from PyQt5.QtCore import pyqtSignal, QObject, QThread -from typing import Dict +from typing import Dict, Any, Optional class ZmqStatusService(QObject): # Signal to send a new message @@ -21,19 +21,45 @@ class ZmqStatusService(QObject): system_status_signal = pyqtSignal(str) - def __init__(self, parent, broker_publish_endpoint="tcp://127.0.0.1:5556"): + def __init__( + self, + parent, + broker_publish_endpoint="tcp://127.0.0.1:5556", + emit_debug_messages=False, + ): super().__init__() self.parent = parent # Reference to the parent window or main UI self.broker_publish_endpoint = broker_publish_endpoint - self.context = zmq.Context() # Create the ZeroMQ context + + # Debug/raw-message UI output flag + # False = do not flood the GUI message box + # True = emit raw topic/payload messages through new_message_signal + self.emit_debug_messages = emit_debug_messages + + self.context = zmq.Context() self.socket = None self.is_connected = False self.subscribed_topics = set() # Set of subscribed topics + self._last_seq_lifecycle_status = "stopped" + self._last_seq_wait_status = None # Set up logging self.setup_logging() self.logger.info("StatusService initialized.") + def set_debug_messages(self, enabled: bool): + """ + Enable or disable raw ZMQ messages in the GUI message box. + """ + self.emit_debug_messages = bool(enabled) + + def _emit_debug_message(self, message: str): + """ + Emit raw/debug messages only when debug UI output is enabled. + """ + if self.emit_debug_messages: + self.new_message_signal.emit(message) + def setup_logging(self): """ Set up logging for the status service in a 'logs' folder. """ @@ -104,64 +130,67 @@ def subscribe_to_all(self): self.subscribed_topics.clear() # Clear the current subscriptions self.logger.info("Subscribed to all topics.") + def listen(self): - """ Listen for incoming messages from the broker. """ + """Listen for incoming messages from the broker.""" if not self.is_connected: self.logger.warning("Not connected to broker. Call 'connect()' first.") return try: self.logger.info("Starting to listen for messages from the broker...") + while True: - message = self.socket.recv_multipart() # Receive the message as multipart (topic, payload) - if len(message) == 2: # Ensure there are exactly two parts: topic and payload - topic = message[0].decode('utf-8') # The topic is the first part (byte array -> string) - payload = message[1].decode('utf-8') # The payload is the second part - - self.logger.info(f"Received message: Topic = {topic}, Payload = {payload}") - - # Assuming the payload is a JSON string, parse it into a dictionary - try: - data = json.loads(payload) - # Emit the message to the UI thread - - # If the topic is "acamd" - if topic == "acamd": - self.new_message_signal.emit(f"Topic: {topic}, Payload: {payload}") - - # If the topic is "seq_daemonstate" - if topic == "seq_waitstate": - status = self._status_from_seq_waitstate(data) - self.system_status_signal.emit(status) - - # If the topic is "slitd" - if topic == "slitd": - slit_width = data.get("SLITW", None) - slit_offset = data.get("SLITO", None) - if slit_width is not None and slit_offset is not None: - self.slit_info_signal.emit(slit_width, slit_offset) - - # If the topic is "calibd", update modulator states - if topic == "calibd": - self.new_message_signal.emit(f"Topic: {topic}, Payload: {payload}") - self.update_modulator_states(data) - - # If the topic is "powerinfo", update lamp states - if topic == "powerd": - self.new_message_signal.emit(f"Topic: {topic}, Payload: {payload}") - self.update_lamp_states(data) # Update lamp statesi - - # If the topic is "tcsd", handle TCS information - if topic == "tcsd": - self.update_tcs_info(data) - - except json.JSONDecodeError as e: - self.logger.error(f"Error parsing JSON payload: {e}") - else: + message = self.socket.recv_multipart() + + if len(message) != 2: self.logger.warning("Received malformed message (not two parts).") - + continue + + topic = message[0].decode("utf-8") + payload = message[1].decode("utf-8") + + # Always log to file, even when GUI debug messages are disabled. + self.logger.info(f"Received message: Topic = {topic}, Payload = {payload}") + + try: + data = json.loads(payload) + + if topic == "acamd": + self._emit_debug_message(f"Topic: {topic}, Payload: {payload}") + + elif topic == "seq_seqstate": + self._last_seq_lifecycle_status = self._status_from_seq_seqstate(data) + self._emit_resolved_system_status() + + elif topic == "seq_waitstate": + self._last_seq_wait_status = self._status_from_seq_waitstate(data) + self._emit_resolved_system_status() + + if topic == "slitd": + slit_width = data.get("SLITW", None) + slit_offset = data.get("SLITO", None) + + if slit_width is not None and slit_offset is not None: + self.slit_info_signal.emit(slit_width, slit_offset) + + if topic == "calibd": + self._emit_debug_message(f"Topic: {topic}, Payload: {payload}") + self.update_modulator_states(data) + + if topic == "powerd": + self._emit_debug_message(f"Topic: {topic}, Payload: {payload}") + self.update_lamp_states(data) + + if topic == "tcsd": + self.update_tcs_info(data) + + except json.JSONDecodeError as e: + self.logger.error(f"Error parsing JSON payload from topic '{topic}': {e}") + except Exception as e: self.logger.error(f"Error while listening for messages: {e}") + finally: self.disconnect() @@ -241,17 +270,81 @@ def update_tcs_info(self, data): else: self.logger.warning("AIRMASS data is not available.") - def _status_from_seq_waitstate(self, flags: Dict[str, bool]) -> str: - f = {k: bool(v) for k, v in (flags or {}).items()} - - if f.get("READOUT"): return "readout" - if f.get("EXPOSE"): return "exposing" - if f.get("ACQUIRE"): return "acquire" - if f.get("FOCUS"): return "focus" - if f.get("CALIB"): return "calib" - if f.get("USER"): return "user" - return "idle" - + def _emit_resolved_system_status(self): + """ + If a wait-state is active, show that. + Otherwise show the broader sequencer lifecycle state. + """ + status = self._last_seq_wait_status or self._last_seq_lifecycle_status + if status is not None: + self.system_status_signal.emit(status) + + def _status_from_seq_seqstate(self, data: Dict[str, Any]) -> str: + """ + Parse the overall sequencer lifecycle state. + """ + if not isinstance(data, dict): + return "stopped" + + seqstate = str(data.get("seqstate", "")).strip().upper() + + state_map = { + "NOTREADY": "not_ready", + "READY": "idle", + "IDLE": "idle", + "PAUSED": "paused", + "STOPPED": "stopped", + "ERROR": "stopped", + } + + return state_map.get(seqstate, seqstate.lower() if seqstate else "stopped") + + def _status_from_seq_waitstate(self, flags: Dict[str, Any]) -> Optional[str]: + """ + Return the active wait-state if one is true, else None. + Returning None falls back to seq_seqstate. + """ + if not isinstance(flags, dict): + return None + + # Ignore metadata fields like "source" + f = { + str(k).upper(): bool(v) + for k, v in flags.items() + if str(k).lower() != "source" + } + + # Precedence matters if more than one field is true. + # Put the most user-meaningful states first. + wait_order = [ + ("READOUT", "readout"), + ("EXPOSE", "exposing"), + + # New replacement states for old ACQUIRE + ("MOVETO", "moveto"), + ("ACAM_ACQUIRE", "acam_acquire"), + ("SLICECAM_FINEACQUIRE", "slicecam_fineacquire"), + + ("FOCUS", "focus"), + ("CALIB", "calib"), + ("CAMERA", "camera"), + ("FLEXURE", "flexure"), + ("POWER", "power"), + ("SLIT", "slit"), + ("TCSOP", "tcsop"), + ("TCS", "tcs"), + ("USER", "user"), + + ("ACAM", "acam"), + ("SLICECAM", "slicecam"), + ("ACQUIRE", "acquire"), + ] + + for key, ui_status in wait_order: + if f.get(key, False): + return ui_status + + return None class ZmqStatusServiceThread(QThread): def __init__(self, zmq_status_service):