spacexerq 6 днів тому
батько
коміт
1c906a0bdc
3 змінених файлів з 215 додано та 6 видалено
  1. 185 3
      apps/gui/src/tabs/scanner_tab.py
  2. 29 2
      apps/gui/src/tabs/seq_interp_tab.py
  3. 1 1
      update.bat

+ 185 - 3
apps/gui/src/tabs/scanner_tab.py

@@ -7,8 +7,11 @@ from __future__ import annotations
 
 import copy
 import json
+import os
+import subprocess
+import sys
 
-from PySide6.QtCore import Qt, QTimer, Signal
+from PySide6.QtCore import Qt, QThread, QTimer, Signal
 from PySide6.QtGui import QFont, QColor
 from PySide6.QtWidgets import (
     QWidget, QSplitter, QVBoxLayout, QHBoxLayout,
@@ -77,6 +80,90 @@ _STEP_TEMPLATES: dict[str, dict] = {
 }
 
 
+def _spec_dir() -> str:
+    """Absolute path to services/spectrometer regardless of CWD."""
+    # scanner_tab.py is at apps/gui/src/tabs/ — repo root is 4 levels up
+    here = os.path.dirname(os.path.abspath(__file__))
+    root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(here))))
+    return os.path.join(root, "services", "spectrometer")
+
+
+class _SpectrometerRestartWorker(QThread):
+    """
+    Kills pico-tcp + Django runserver then relaunches both.
+    Runs entirely off the UI thread.
+    """
+    log    = Signal(str)
+    done   = Signal(bool, str)   # (success, message)
+
+    def run(self) -> None:
+        spec = _spec_dir()
+        venv = os.path.join(spec, "mvenv", "Scripts", "python.exe")
+        pico = os.path.join(spec, "bin", "pico-tcp.exe")
+
+        try:
+            # 1. Kill stale pico-tcp
+            self.log.emit("Stopping pico-tcp.exe…")
+            subprocess.run(
+                ["taskkill", "/f", "/im", "pico-tcp.exe"],
+                capture_output=True,
+            )
+
+            # 2. Kill Django runserver on port 8000 (find PID via netstat)
+            self.log.emit("Stopping Django runserver…")
+            try:
+                r = subprocess.run(
+                    ["powershell", "-Command",
+                     "Get-NetTCPConnection -LocalPort 8000 -State Listen "
+                     "-ErrorAction SilentlyContinue | "
+                     "Select-Object -ExpandProperty OwningProcess"],
+                    capture_output=True, text=True, timeout=10,
+                )
+                for pid_str in r.stdout.strip().splitlines():
+                    pid_str = pid_str.strip()
+                    if pid_str.isdigit():
+                        subprocess.run(
+                            ["taskkill", "/f", "/pid", pid_str],
+                            capture_output=True,
+                        )
+                        self.log.emit(f"  Killed PID {pid_str}")
+            except Exception as e:
+                self.log.emit(f"  (port kill skipped: {e})")
+
+            import time as _t
+            _t.sleep(1)
+
+            # 3. Run migrations (silent)
+            self.log.emit("Running migrations…")
+            subprocess.run(
+                [venv, "manage.py", "migrate", "--noinput"],
+                cwd=spec, capture_output=True, timeout=30,
+            )
+
+            # 4. Start pico-tcp
+            if os.path.isfile(pico):
+                self.log.emit("Starting pico-tcp.exe…")
+                subprocess.Popen(
+                    [pico],
+                    creationflags=subprocess.CREATE_NEW_CONSOLE,
+                )
+            else:
+                self.log.emit("  pico-tcp.exe not found — skipped")
+
+            # 5. Start Django runserver
+            self.log.emit("Starting Django runserver (0.0.0.0:8000)…")
+            subprocess.Popen(
+                [venv, "manage.py", "runserver", "0.0.0.0:8000", "--noreload"],
+                cwd=spec,
+                creationflags=subprocess.CREATE_NEW_CONSOLE,
+            )
+
+            self.done.emit(True, "Spectrometer restarted")
+
+        except Exception as exc:
+            self.done.emit(False, str(exc))
+
+
 # ── Scenario builder dialog ───────────────────────────────────────────────────
 
 class ScenarioBuilderDialog(QDialog):
@@ -349,8 +436,9 @@ class ScannerTab(QWidget):
         self._conn_state: str = "offline"
         self._steps_data: list = []
 
-        self._run_worker  : OrchestratorWorker | None = None
-        self._poll_worker : OrchestratorWorker | None = None
+        self._run_worker     : OrchestratorWorker | None = None
+        self._poll_worker    : OrchestratorWorker | None = None
+        self._restart_worker : _SpectrometerRestartWorker | None = None
 
         self._poll_timer = QTimer(self)
         self._poll_timer.setInterval(_POLL_INTERVAL_MS)
@@ -577,6 +665,7 @@ class ScannerTab(QWidget):
         lay.addWidget(self._build_scenario_group())
         lay.addWidget(self._build_job_group())
         lay.addWidget(self._build_seq_info_group())
+        lay.addWidget(self._build_spectrometer_group())
         lay.addStretch()
 
         scroll = QScrollArea()
@@ -685,6 +774,99 @@ class ScannerTab(QWidget):
         lay.addWidget(self._seq_info_label)
         return grp
 
+    def _build_spectrometer_group(self) -> QWidget:
+        grp = TrGroupBox("grp_spectrometer")
+        grp.setTitle("Спектрометр")
+        lay = QVBoxLayout(grp)
+        lay.setSpacing(6)
+
+        # Status row
+        status_row = QHBoxLayout()
+        self._spec_status_lbl = QLabel("● статус неизвестен")
+        self._spec_status_lbl.setStyleSheet("color: #555577; font-size: 10px;")
+        status_row.addWidget(self._spec_status_lbl, stretch=1)
+
+        btn_check = QPushButton("↻")
+        btn_check.setFixedSize(20, 20)
+        btn_check.setToolTip("Проверить доступность спектрометра")
+        btn_check.setStyleSheet(
+            "QPushButton{background:transparent;color:#555577;"
+            "border:1px solid #2a2a44;border-radius:3px;font-size:11px;}"
+            "QPushButton:hover{color:#aaaacc;border-color:#555577;}"
+        )
+        btn_check.clicked.connect(self._check_spectrometer)
+        status_row.addWidget(btn_check)
+        lay.addLayout(status_row)
+
+        # Restart button
+        self._btn_restart_spec = QPushButton("⟳  Перезапустить спектрометр")
+        self._btn_restart_spec.setToolTip(
+            "Останавливает pico-tcp.exe и Django runserver,\n"
+            "затем запускает оба снова."
+        )
+        self._btn_restart_spec.setMinimumHeight(28)
+        self._btn_restart_spec.setStyleSheet(
+            "QPushButton{background:#1a2a1a;color:#88cc88;"
+            "border:1px solid #336633;border-radius:4px;font-size:11px;}"
+            "QPushButton:hover{background:#1e3a1e;}"
+            "QPushButton:disabled{color:#444;border-color:#333;}"
+        )
+        self._btn_restart_spec.clicked.connect(self._restart_spectrometer)
+        lay.addWidget(self._btn_restart_spec)
+
+        # Progress / log
+        self._spec_restart_log = QLabel("")
+        self._spec_restart_log.setWordWrap(True)
+        self._spec_restart_log.setStyleSheet("color: #888aaa; font-size: 9px;")
+        self._spec_restart_log.setFont(QFont("Courier New", 8))
+        self._spec_restart_log.setVisible(False)
+        lay.addWidget(self._spec_restart_log)
+
+        return grp
+
+    def _check_spectrometer(self) -> None:
+        import httpx
+        try:
+            r = httpx.get(f"{self._spectrometer_url}/api/", timeout=3.0)
+            if r.status_code < 500:
+                self._spec_status_lbl.setText("● онлайн")
+                self._spec_status_lbl.setStyleSheet("color: #4caf50; font-size: 10px;")
+            else:
+                self._spec_status_lbl.setText(f"● HTTP {r.status_code}")
+                self._spec_status_lbl.setStyleSheet("color: #ff9800; font-size: 10px;")
+        except Exception:
+            self._spec_status_lbl.setText("● недоступен")
+            self._spec_status_lbl.setStyleSheet("color: #f44336; font-size: 10px;")
+
+    def _restart_spectrometer(self) -> None:
+        if self._restart_worker and self._restart_worker.isRunning():
+            return
+        self._btn_restart_spec.setEnabled(False)
+        self._spec_restart_log.setText("Перезапуск…")
+        self._spec_restart_log.setVisible(True)
+        self._spec_status_lbl.setText("● перезапуск…")
+        self._spec_status_lbl.setStyleSheet("color: #ff9800; font-size: 10px;")
+
+        self._restart_worker = _SpectrometerRestartWorker(self)
+        self._restart_worker.log.connect(self._on_restart_log)
+        self._restart_worker.done.connect(self._on_restart_done)
+        self._restart_worker.start()
+
+    def _on_restart_log(self, msg: str) -> None:
+        self._spec_restart_log.setText(msg)
+
+    def _on_restart_done(self, success: bool, msg: str) -> None:
+        self._btn_restart_spec.setEnabled(True)
+        self._spec_restart_log.setText(msg)
+        if success:
+            self._spec_status_lbl.setText("● запущен")
+            self._spec_status_lbl.setStyleSheet("color: #ff9800; font-size: 10px;")
+            # Check again after 3 s to confirm Django is up
+            QTimer.singleShot(3000, self._check_spectrometer)
+        else:
+            self._spec_status_lbl.setText("● ошибка")
+            self._spec_status_lbl.setStyleSheet("color: #f44336; font-size: 10px;")
+
     # -- Right panel -------------------------------------------------------
 
     def _build_right_panel(self) -> QWidget:

+ 29 - 2
apps/gui/src/tabs/seq_interp_tab.py

@@ -98,6 +98,7 @@ class SeqInterpTab(QWidget):
         self._hw = None
         self._block_rows: list = []
         self._worker = None
+        self._bg_local_worker = None   # background local pipeline (service mode)
         self._xml_preview_worker = None
         self._pending_table_select: int | None = None
         self._post_info: dict | None = None
@@ -601,13 +602,12 @@ class SeqInterpTab(QWidget):
             # ── Scheme + block table ────────────────────────────────────────
             blocks_slim = result.get("blocks", [])
             if blocks_slim and waveforms.get("blocks_duration"):
-                from src.gui.adapters import build_block_rows as _bbr
                 seq_like  = {"blocks": blocks_slim}
                 sync_like = {k: waveforms[k]
                              for k in ("blocks_duration", "gate_adc",
                                        "gate_rf", "gate_tr_switch")
                              if k in waveforms}
-                self._block_rows = _bbr(seq_like, sync_like)
+                self._block_rows = build_block_rows(seq_like, sync_like)
                 self._table.load_rows(self._block_rows)
                 self._scheme.load_rows(self._block_rows)
 
@@ -643,9 +643,36 @@ class SeqInterpTab(QWidget):
                 self.seq_loaded.emit(self._seq_path)
             self._log(f"Service interpretation complete. "
                       f"Output: {result.get('output_dir', '-')}")
+
+            # Run local interpretation in the background so _seq_data /
+            # _sync_data / _hw get populated and Export becomes available.
+            # We do NOT redraw plots on completion — just enable the button.
+            if self._seq_path:
+                self._log("Running local pipeline for Export support...")
+                bg = LoadInterpWorker(
+                    self._seq_path,
+                    hw_config_path=self._hw_config_path,
+                )
+                bg.finished.connect(self._on_local_interp_for_export)
+                bg.error.connect(lambda e: self._log(f"Local pipeline: {e}"))
+                bg.start()
+                self._bg_local_worker = bg   # keep reference
+
         except Exception as exc:
             self._on_worker_error(f"Result parse error: {exc}")
 
+    def _on_local_interp_for_export(self, seq_data: dict, sync_data: dict, hw) -> None:
+        """
+        Background local interpretation result (service mode only).
+        Does NOT redraw plots — just populates _seq_data/_sync_data/_hw
+        so that Export becomes available.
+        """
+        self._seq_data  = seq_data
+        self._sync_data = sync_data
+        self._hw        = hw
+        self._act_enabled(run=True, export=True)
+        self._log("Local pipeline ready — Export enabled")
+
     def _on_interp_finished(self, seq_data: dict, sync_data: dict, hw) -> None:
         self._seq_data  = seq_data
         self._sync_data = sync_data

+ 1 - 1
update.bat

@@ -10,5 +10,5 @@ REM    .\update.ps1 -RestartSpec          -- also restart spectrometer
 REM    .\update.ps1 -SkipGit              -- skip git pull
 REM    .\update.ps1 -Services orch,seq    -- rebuild specific services only
 REM ============================================================
-powershell -ExecutionPolicy Bypass -File "%~dp0update.ps1"
+powershell.exe -ExecutionPolicy Bypass -File "%~dp0update.ps1"
 pause