Browse Source

update prelaunch status for plug/hw mode

spacexerq 2 weeks ago
parent
commit
5a1753ea97

+ 143 - 1
apps/gui/src/app_window.py

@@ -8,14 +8,16 @@ provides a unified, flat navigation strip.
 from __future__ import annotations
 
 import os
+import threading
 
-from PySide6.QtCore import Qt, QSize, QTimer
+from PySide6.QtCore import Qt, QObject, QSize, QTimer, Signal
 from PySide6.QtWidgets import (
     QApplication,
     QButtonGroup,
     QFrame,
     QLabel,
     QMainWindow,
+    QMessageBox,
     QPushButton,
     QSizePolicy,
     QStatusBar,
@@ -25,6 +27,7 @@ from PySide6.QtWidgets import (
 )
 
 from src import i18n, theme
+from src.clients.orchestrator_client import OrchestratorClient, OrchestratorError
 from src.tabs.fid_tab import FidTab
 from src.tabs.scanner_tab import ScannerTab
 from src.tabs.scanning_tab import ScanningTab
@@ -111,6 +114,44 @@ QPushButton:hover:!checked {{
 """
 
 
+def _mode_btn_css(dark: bool, mode: str) -> str:
+    if mode == "real":
+        color  = "#2ecc71"
+        border = "#27ae60"
+    else:
+        color  = "#f39c12"
+        border = "#e67e00"
+    return f"""
+QPushButton {{
+    background: transparent;
+    color: {color};
+    border: 1px solid {border};
+    border-radius: 3px;
+    padding: 0px 10px;
+    font-size: 11px;
+    font-weight: bold;
+    min-height: 22px;
+    max-height: 22px;
+    letter-spacing: 0.5px;
+}}
+QPushButton:hover {{
+    background: {color}22;
+}}
+QPushButton:disabled {{
+    color: #555577;
+    border-color: #333355;
+}}
+"""
+
+
+class _ModeSignalBridge(QObject):
+    """Carries results of background mode HTTP calls back to the Qt main thread."""
+
+    mode_fetched = Signal(str)  # mode string on successful GET /mode
+    mode_set     = Signal(str)  # mode string on successful POST /mode
+    mode_error   = Signal(str)  # error message (empty = silent startup failure)
+
+
 def _spec_btn_blink_css(dark: bool) -> str:
     active_txt = "#ffffff" if dark else "#1a1a2e"
     accent     = "#f0c040" if dark else "#c09000"
@@ -196,14 +237,28 @@ class LFMRIWindow(QMainWindow):
         self._spec_blink_timer.setInterval(700)
         self._spec_blink_timer.timeout.connect(self._tick_spec_blink)
 
+        # Mode selector state
+        self._orchestrator_url = orchestrator_url
+        self._current_mode: str = "plug"   # updated by background fetch after startup
+        self._mode_bridge = _ModeSignalBridge(self)
+
         self.menuBar().hide()
         self._build_nav_bar()
+
+        # Connect mode signals after nav bar is built (_mode_btn exists)
+        self._mode_bridge.mode_fetched.connect(self._on_mode_fetched)
+        self._mode_bridge.mode_set.connect(self._on_mode_set)
+        self._mode_bridge.mode_error.connect(self._on_mode_error)
+
         self._build_status_bar()
         self._size_and_center()
 
         # Apply default dark theme to the whole application
         self._apply_theme(theme.is_dark())
 
+        # Fetch current mode from orchestrator once the event loop is running
+        QTimer.singleShot(600, self._start_fetch_mode)
+
         if seq_file and os.path.isfile(seq_file):
             self._seq_tab.load_seq_file(os.path.abspath(seq_file))
 
@@ -246,6 +301,17 @@ class LFMRIWindow(QMainWindow):
         self._nav_spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
         tb.addWidget(self._nav_spacer)
 
+        # Mode indicator / switcher
+        self._mode_btn = QPushButton("…")
+        self._mode_btn.setFixedHeight(22)
+        self._mode_btn.setCursor(Qt.PointingHandCursor)
+        self._mode_btn.setToolTip("Click to switch operating mode")
+        self._mode_btn.clicked.connect(self._on_mode_btn_clicked)
+        tb.addWidget(self._mode_btn)
+
+        self._nav_sep_mode = _VSep(tb)
+        tb.addWidget(self._nav_sep_mode)
+
         # Language toggle (EN / RU)
         self._lang_btn_group = QButtonGroup(self)
         self._lang_btn_group.setExclusive(True)
@@ -308,6 +374,7 @@ class LFMRIWindow(QMainWindow):
         self._nav_toolbar.setStyleSheet(_nav_toolbar_css(dark))
         self._nav_sep1.setStyleSheet(f"background: {sep_clr}; border: none;")
         self._nav_sep2.setStyleSheet(f"background: {sep_clr}; border: none;")
+        self._nav_sep_mode.setStyleSheet(f"background: {sep_clr}; border: none;")
         self._nav_logo_lbl.setStyleSheet(
             f"color: {logo_clr}; font-weight: bold; font-size: 11px; "
             f"background: {bg}; padding: 0 4px;"
@@ -321,6 +388,7 @@ class LFMRIWindow(QMainWindow):
         self._btn_lang_ru.setStyleSheet(lang_css)
         self._btn_theme_dark.setStyleSheet(lang_css)
         self._btn_theme_light.setStyleSheet(lang_css)
+        self._update_mode_btn()
 
         sb_bg    = "#0c0c1a" if dark else "#dce0f0"
         sb_color = "#555577" if dark else "#787faa"
@@ -382,6 +450,7 @@ class LFMRIWindow(QMainWindow):
     def retranslate_ui(self) -> None:
         for i, key in enumerate(_TAB_NAV_KEYS):
             self._nav_tab_buttons[i].setText(i18n.tr(key))
+        self._update_mode_btn()
         cur = self._tabs.currentIndex()
         key = _TAB_NAV_KEYS[cur] if 0 <= cur < len(_TAB_NAV_KEYS) else "-"
         self.statusBar().showMessage(f"{i18n.tr('active_tab')}: {i18n.tr(key)}")
@@ -433,6 +502,79 @@ class LFMRIWindow(QMainWindow):
             _tab_btn_css(theme.is_dark())
         )
 
+    # ------------------------------------------------------------------ #
+    #  Mode selector                                                       #
+    # ------------------------------------------------------------------ #
+
+    def _update_mode_btn(self) -> None:
+        """Refresh mode button label and colour from _current_mode."""
+        text = i18n.tr(f"mode_{self._current_mode}")
+        self._mode_btn.setText(text)
+        self._mode_btn.setStyleSheet(_mode_btn_css(theme.is_dark(), self._current_mode))
+
+    # -- background fetch (startup) ----------------------------------------
+
+    def _start_fetch_mode(self) -> None:
+        threading.Thread(
+            target=self._fetch_mode_bg,
+            daemon=True,
+            name="mode-fetch",
+        ).start()
+
+    def _fetch_mode_bg(self) -> None:
+        try:
+            client = OrchestratorClient(self._orchestrator_url)
+            mode = client.get_mode()
+            self._mode_bridge.mode_fetched.emit(mode)
+        except OrchestratorError:
+            pass  # Orchestrator may not be up yet — keep the default label
+
+    # -- slots (main thread) -----------------------------------------------
+
+    def _on_mode_fetched(self, mode: str) -> None:
+        self._current_mode = mode
+        self._update_mode_btn()
+
+    def _on_mode_set(self, mode: str) -> None:
+        self._current_mode = mode
+        self._mode_btn.setEnabled(True)
+        self._update_mode_btn()
+
+    def _on_mode_error(self, msg: str) -> None:
+        self._mode_btn.setEnabled(True)
+        if msg:
+            QMessageBox.warning(self, i18n.tr("mode_error"), msg)
+
+    # -- click handler (main thread) ---------------------------------------
+
+    def _on_mode_btn_clicked(self) -> None:
+        target = "real" if self._current_mode == "plug" else "plug"
+        confirm_key = f"mode_confirm_to_{target}"
+        reply = QMessageBox.question(
+            self,
+            i18n.tr("mode_confirm_title"),
+            i18n.tr(confirm_key),
+            QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
+            QMessageBox.StandardButton.No,
+        )
+        if reply != QMessageBox.StandardButton.Yes:
+            return
+        self._mode_btn.setEnabled(False)
+        threading.Thread(
+            target=self._set_mode_bg,
+            args=(target,),
+            daemon=True,
+            name="mode-set",
+        ).start()
+
+    def _set_mode_bg(self, mode: str) -> None:
+        try:
+            client = OrchestratorClient(self._orchestrator_url)
+            result = client.set_mode(mode)
+            self._mode_bridge.mode_set.emit(result)
+        except OrchestratorError as exc:
+            self._mode_bridge.mode_error.emit(str(exc))
+
 
 class _VSep(QFrame):
     """Thin vertical separator for the nav toolbar."""

+ 13 - 0
apps/gui/src/clients/orchestrator_client.py

@@ -153,6 +153,19 @@ class OrchestratorClient:
             raise OrchestratorError(r.text, status_code=r.status_code)
         return r.json()["job_id"]
 
+    def get_mode(self) -> str:
+        """Return the current orchestrator mode ('real' or 'plug')."""
+        data = self._get("/mode")
+        return data.get("mode", "plug")
+
+    def set_mode(self, mode: str) -> str:
+        """
+        Switch orchestrator mode to 'real' or 'plug'.
+        Returns the mode that is now active.
+        """
+        data = self._post("/mode", json={"mode": mode})
+        return data.get("mode", mode)
+
     def poll_scan(
         self,
         job_id: str,

+ 14 - 0
apps/gui/src/i18n.py

@@ -113,6 +113,20 @@ _T: dict[str, dict[str, str]] = {
         "ru": "Выберите папку и нажмите Запустить пакет.",
     },
 
+    # --- mode selector ---
+    "mode_plug":            {"en": "PLUG",          "ru": "ЗАГЛУШКА"},
+    "mode_real":            {"en": "REAL",          "ru": "БОЕВОЙ"},
+    "mode_confirm_title":   {"en": "Switch Mode",   "ru": "Смена режима"},
+    "mode_confirm_to_real": {
+        "en": "Switch to REAL (hardware) mode?\n\nPhysical devices will be used for acquisition.",
+        "ru": "Переключиться в БОЕВОЙ режим?\n\nДля сбора данных будет использоваться реальное оборудование.",
+    },
+    "mode_confirm_to_plug": {
+        "en": "Switch to PLUG (stub) mode?\n\nPhysical devices will NOT be used.",
+        "ru": "Переключиться в режим ЗАГЛУШКИ?\n\nРеальное оборудование использоваться не будет.",
+    },
+    "mode_error":           {"en": "Mode switch failed", "ru": "Ошибка смены режима"},
+
     # --- controls_panel ---
     "grp_hw_delays":      {"en": "Hardware Delays / Rasters", "ru": "Задержки / Растры ЖО"},
     "btn_apply_rerun":    {"en": "Apply && Rerun",    "ru": "Применить && Перезапуск"},

+ 37 - 0
services/orchestrator/orchestrator/main.py

@@ -16,6 +16,10 @@ class LoadRequest(BaseModel):
     # Format: {step_name: {param_key: value}}
     # Example: {"start_measurement": {"info": {...}}}
 
+
+class ModeRequest(BaseModel):
+    mode: str  # "real" or "plug"
+
 # Выбор задач: боевой/заглушки
 mode = os.getenv("MODE", "stub")
 if mode == "real":
@@ -25,6 +29,10 @@ else:
 
 TASK_REGISTRY = tasks.TASK_REGISTRY
 
+# Runtime mode tracking (can be changed via POST /mode while the process runs)
+_current_mode: str = "real" if mode == "real" else "plug"
+_mode_lock = threading.Lock()
+
 app = FastAPI(title="Orchestrator with Templates")
 
 
@@ -33,6 +41,35 @@ def health():
     return {"status": "ok"}
 
 
+@app.get("/mode")
+def get_mode_endpoint():
+    """Return the currently active task mode ('real' or 'plug')."""
+    return {"mode": _current_mode}
+
+
+@app.post("/mode")
+def set_mode_endpoint(body: ModeRequest):
+    """
+    Switch the active task registry at runtime.
+
+    Accepts mode = 'real' | 'plug'.
+    All subsequent scenario builds will use the new task implementations.
+    Already-running jobs are not affected.
+    """
+    global TASK_REGISTRY, _current_mode
+    new_mode = body.mode
+    if new_mode not in ("real", "plug"):
+        raise HTTPException(status_code=400, detail="mode must be 'real' or 'plug'")
+    with _mode_lock:
+        if new_mode == "real":
+            from . import tasks_real as new_tasks
+        else:
+            from . import tasks_plug as new_tasks
+        TASK_REGISTRY = new_tasks.TASK_REGISTRY
+        _current_mode = new_mode
+    return {"mode": _current_mode}
+
+
 @app.get("/spectrometer/health")
 def spectrometer_health():
     """Check connectivity to the spectrometer service."""