|
|
@@ -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:
|