scan_tab.py 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838
  1. from __future__ import annotations
  2. import json
  3. import os
  4. from typing import Any, Callable, Dict, Optional, Tuple
  5. import requests
  6. from PyQt6.QtWidgets import QFileDialog, QLabel, QMessageBox
  7. from config import ENDPOINTS
  8. from mainui.clients import ApiResult
  9. from mainui.utils import short_json, short_path
  10. from mainui.worker import run_in_thread
  11. class ScanTabMixin:
  12. def _init_scan_measurement_ui(self) -> None:
  13. if not hasattr(self, "scan") or self.scan is None:
  14. return
  15. if not hasattr(self.scan, "pteMeasurementPayload"):
  16. return
  17. t = self._measurement_payload_template()
  18. if hasattr(self.scan, "leMeasurementEngine"):
  19. self.scan.leMeasurementEngine.setText(str(t["engine"]))
  20. if hasattr(self.scan, "leMeasurementInfo"):
  21. self.scan.leMeasurementInfo.setText(str(t["infostr"]))
  22. osc = t["oscilloscope"]
  23. if hasattr(self.scan, "cbOscEnabled"):
  24. self.scan.cbOscEnabled.setChecked(bool(osc["enabled"]))
  25. if hasattr(self.scan, "leOscDeviceModel"):
  26. self.scan.leOscDeviceModel.setText(str(osc["device_model"]))
  27. if hasattr(self.scan, "sbOscSampleRate"):
  28. self.scan.sbOscSampleRate.setValue(int(osc["sample_rate"]))
  29. if hasattr(self.scan, "leOscPoints"):
  30. self.scan.leOscPoints.setText(",".join(str(x) for x in osc["points"]))
  31. if hasattr(self.scan, "leOscChannels"):
  32. self.scan.leOscChannels.setText(",".join(str(x) for x in osc["channels"]))
  33. if hasattr(self.scan, "leOscRanges"):
  34. self.scan.leOscRanges.setText(",".join(str(x) for x in osc["ranges"]))
  35. if hasattr(self.scan, "sbOscNTriggers"):
  36. self.scan.sbOscNTriggers.setValue(int(osc["ntriggers"]))
  37. if hasattr(self.scan, "sbOscAveraging"):
  38. self.scan.sbOscAveraging.setValue(int(osc["averaging"]))
  39. if hasattr(self.scan, "sbOscTriggerChannel"):
  40. self.scan.sbOscTriggerChannel.setValue(int(osc["trigger_channel"]))
  41. if hasattr(self.scan, "sbOscThreshold"):
  42. self.scan.sbOscThreshold.setValue(int(osc["threshold"]))
  43. if hasattr(self.scan, "sbOscDirection"):
  44. self.scan.sbOscDirection.setValue(int(osc["direction"]))
  45. if hasattr(self.scan, "sbOscAutomeasureTime"):
  46. self.scan.sbOscAutomeasureTime.setValue(int(osc["automeasure_time"]))
  47. sync = t["synchronizer"]
  48. if hasattr(self.scan, "cbSyncEnabled"):
  49. self.scan.cbSyncEnabled.setChecked(bool(sync["enabled"]))
  50. if hasattr(self.scan, "leSyncDeviceModel"):
  51. self.scan.leSyncDeviceModel.setText(str(sync["device_model"]))
  52. if hasattr(self.scan, "leSyncFile"):
  53. self.scan.leSyncFile.setText(str(sync["file"]))
  54. if hasattr(self.scan, "leSyncPort"):
  55. self.scan.leSyncPort.setText(str(sync["port"]))
  56. sdr = t["sdr"]
  57. if hasattr(self.scan, "cbSdrEnabled"):
  58. self.scan.cbSdrEnabled.setChecked(bool(sdr["enabled"]))
  59. if hasattr(self.scan, "leSdrDeviceModel"):
  60. self.scan.leSdrDeviceModel.setText(str(sdr["device_model"]))
  61. if hasattr(self.scan, "sbSdrSampleRate"):
  62. self.scan.sbSdrSampleRate.setValue(int(sdr["sample_rate"]))
  63. if hasattr(self.scan, "dsbSdrFreq"):
  64. self.scan.dsbSdrFreq.setValue(float(sdr["freq"]))
  65. if hasattr(self.scan, "dsbSdrAmpl"):
  66. self.scan.dsbSdrAmpl.setValue(float(sdr["ampl"]))
  67. if hasattr(self.scan, "dsbSdrGain"):
  68. self.scan.dsbSdrGain.setValue(float(sdr["gain"]))
  69. if hasattr(self.scan, "leSdrFile"):
  70. self.scan.leSdrFile.setText(str(sdr["file"]))
  71. gru = t["gru"]
  72. if hasattr(self.scan, "cbGruEnabled"):
  73. self.scan.cbGruEnabled.setChecked(bool(gru["enabled"]))
  74. if hasattr(self.scan, "leGruDeviceModel"):
  75. self.scan.leGruDeviceModel.setText(str(gru["device_model"]))
  76. if hasattr(self.scan, "leGruIp"):
  77. self.scan.leGruIp.setText(str(gru["ip"]))
  78. if hasattr(self.scan, "leGruFile"):
  79. self.scan.leGruFile.setText(str(gru["file"]))
  80. self._wire_measurement_payload_preview()
  81. self._refresh_measurement_payload_preview()
  82. if hasattr(self.scan, "lblMeasurementStatus"):
  83. self.scan.lblMeasurementStatus.setText("Measurement status: ready")
  84. def _measurement_payload_template(self) -> Dict[str, Any]:
  85. return {
  86. "oscilloscope": {
  87. "device_model": "",
  88. "sample_rate": 8000000,
  89. "points": [1024],
  90. "channels": [0],
  91. "ranges": [5],
  92. "ntriggers": 1,
  93. "averaging": 1,
  94. "trigger_channel": 0,
  95. "threshold": 0,
  96. "direction": 2,
  97. "automeasure_time": 0,
  98. "enabled": True,
  99. },
  100. "synchronizer": {"device_model": "", "file": "", "port": "", "enabled": False},
  101. "sdr": {
  102. "device_model": "",
  103. "sample_rate": 0,
  104. "freq": 0,
  105. "ampl": 0,
  106. "gain": 0,
  107. "file": "",
  108. "enabled": False,
  109. },
  110. "gru": {"device_model": "", "ip": "", "file": "", "enabled": False},
  111. "engine": "default",
  112. "infostr": "mainui",
  113. }
  114. def _build_measurement_payload(self) -> Optional[Dict[str, Any]]:
  115. payload = self._measurement_payload_template()
  116. if hasattr(self.scan, "leMeasurementEngine"):
  117. payload["engine"] = self.scan.leMeasurementEngine.text().strip() or "default"
  118. if hasattr(self.scan, "leMeasurementInfo"):
  119. payload["infostr"] = self.scan.leMeasurementInfo.text().strip()
  120. osc = payload["oscilloscope"]
  121. if hasattr(self.scan, "cbOscEnabled"):
  122. osc["enabled"] = bool(self.scan.cbOscEnabled.isChecked())
  123. if hasattr(self.scan, "leOscDeviceModel"):
  124. osc["device_model"] = self.scan.leOscDeviceModel.text().strip()
  125. if hasattr(self.scan, "sbOscSampleRate"):
  126. osc["sample_rate"] = int(self.scan.sbOscSampleRate.value())
  127. if hasattr(self.scan, "leOscPoints"):
  128. parsed = self._parse_number_list(self.scan.leOscPoints.text(), "points", int)
  129. if parsed is None:
  130. return None
  131. osc["points"] = parsed
  132. if hasattr(self.scan, "leOscChannels"):
  133. parsed = self._parse_number_list(self.scan.leOscChannels.text(), "channels", int)
  134. if parsed is None:
  135. return None
  136. osc["channels"] = parsed
  137. if hasattr(self.scan, "leOscRanges"):
  138. parsed = self._parse_number_list(self.scan.leOscRanges.text(), "ranges", float)
  139. if parsed is None:
  140. return None
  141. osc["ranges"] = parsed
  142. if hasattr(self.scan, "sbOscNTriggers"):
  143. osc["ntriggers"] = int(self.scan.sbOscNTriggers.value())
  144. if hasattr(self.scan, "sbOscAveraging"):
  145. osc["averaging"] = int(self.scan.sbOscAveraging.value())
  146. if hasattr(self.scan, "sbOscTriggerChannel"):
  147. osc["trigger_channel"] = int(self.scan.sbOscTriggerChannel.value())
  148. if hasattr(self.scan, "sbOscThreshold"):
  149. osc["threshold"] = int(self.scan.sbOscThreshold.value())
  150. if hasattr(self.scan, "sbOscDirection"):
  151. osc["direction"] = int(self.scan.sbOscDirection.value())
  152. if hasattr(self.scan, "sbOscAutomeasureTime"):
  153. osc["automeasure_time"] = int(self.scan.sbOscAutomeasureTime.value())
  154. sync = payload["synchronizer"]
  155. if hasattr(self.scan, "cbSyncEnabled"):
  156. sync["enabled"] = bool(self.scan.cbSyncEnabled.isChecked())
  157. if hasattr(self.scan, "leSyncDeviceModel"):
  158. sync["device_model"] = self.scan.leSyncDeviceModel.text().strip()
  159. if hasattr(self.scan, "leSyncFile"):
  160. sync["file"] = self.scan.leSyncFile.text().strip()
  161. if hasattr(self.scan, "leSyncPort"):
  162. sync["port"] = self.scan.leSyncPort.text().strip()
  163. sdr = payload["sdr"]
  164. if hasattr(self.scan, "cbSdrEnabled"):
  165. sdr["enabled"] = bool(self.scan.cbSdrEnabled.isChecked())
  166. if hasattr(self.scan, "leSdrDeviceModel"):
  167. sdr["device_model"] = self.scan.leSdrDeviceModel.text().strip()
  168. if hasattr(self.scan, "sbSdrSampleRate"):
  169. sdr["sample_rate"] = int(self.scan.sbSdrSampleRate.value())
  170. if hasattr(self.scan, "dsbSdrFreq"):
  171. sdr["freq"] = float(self.scan.dsbSdrFreq.value())
  172. if hasattr(self.scan, "dsbSdrAmpl"):
  173. sdr["ampl"] = float(self.scan.dsbSdrAmpl.value())
  174. if hasattr(self.scan, "dsbSdrGain"):
  175. sdr["gain"] = float(self.scan.dsbSdrGain.value())
  176. if hasattr(self.scan, "leSdrFile"):
  177. sdr["file"] = self.scan.leSdrFile.text().strip()
  178. gru = payload["gru"]
  179. if hasattr(self.scan, "cbGruEnabled"):
  180. gru["enabled"] = bool(self.scan.cbGruEnabled.isChecked())
  181. if hasattr(self.scan, "leGruDeviceModel"):
  182. gru["device_model"] = self.scan.leGruDeviceModel.text().strip()
  183. if hasattr(self.scan, "leGruIp"):
  184. gru["ip"] = self.scan.leGruIp.text().strip()
  185. if hasattr(self.scan, "leGruFile"):
  186. gru["file"] = self.scan.leGruFile.text().strip()
  187. seq_path = (self._sequence_file_path or "").strip()
  188. if seq_path and os.path.isfile(seq_path) and not sync.get("file"):
  189. sync["file"] = seq_path
  190. return payload
  191. def _parse_number_list(
  192. self,
  193. raw_text: str,
  194. field_name: str,
  195. cast: Callable[[str], Any],
  196. ) -> Optional[list]:
  197. text = (raw_text or "").strip()
  198. if not text:
  199. return []
  200. parts = [p.strip() for p in text.replace(";", ",").split(",") if p.strip()]
  201. out: list = []
  202. try:
  203. for p in parts:
  204. out.append(cast(p))
  205. except Exception:
  206. QMessageBox.warning(self, "Measurement payload", f"Invalid {field_name} list.")
  207. return None
  208. return out
  209. def _wire_measurement_payload_preview(self) -> None:
  210. if not hasattr(self, "scan") or self.scan is None:
  211. return
  212. names = (
  213. "leMeasurementEngine",
  214. "leMeasurementInfo",
  215. "cbOscEnabled",
  216. "leOscDeviceModel",
  217. "sbOscSampleRate",
  218. "leOscPoints",
  219. "leOscChannels",
  220. "leOscRanges",
  221. "sbOscNTriggers",
  222. "sbOscAveraging",
  223. "sbOscTriggerChannel",
  224. "sbOscThreshold",
  225. "sbOscDirection",
  226. "sbOscAutomeasureTime",
  227. "cbSyncEnabled",
  228. "leSyncDeviceModel",
  229. "leSyncFile",
  230. "leSyncPort",
  231. "cbSdrEnabled",
  232. "leSdrDeviceModel",
  233. "sbSdrSampleRate",
  234. "dsbSdrFreq",
  235. "dsbSdrAmpl",
  236. "dsbSdrGain",
  237. "leSdrFile",
  238. "cbGruEnabled",
  239. "leGruDeviceModel",
  240. "leGruIp",
  241. "leGruFile",
  242. )
  243. for name in names:
  244. if not hasattr(self.scan, name):
  245. continue
  246. w = getattr(self.scan, name)
  247. sig = getattr(w, "textChanged", None)
  248. if sig is not None:
  249. sig.connect(self._refresh_measurement_payload_preview)
  250. continue
  251. sig = getattr(w, "valueChanged", None)
  252. if sig is not None:
  253. sig.connect(self._refresh_measurement_payload_preview)
  254. continue
  255. sig = getattr(w, "stateChanged", None)
  256. if sig is not None:
  257. sig.connect(self._refresh_measurement_payload_preview)
  258. def _refresh_measurement_payload_preview(self, *_args) -> None:
  259. if not hasattr(self.scan, "pteMeasurementPayload"):
  260. return
  261. payload = self._build_measurement_payload()
  262. if payload is None:
  263. return
  264. self.scan.pteMeasurementPayload.setPlainText(json.dumps(payload, ensure_ascii=False, indent=2))
  265. def _extract_scan_id(self, data: Any) -> str:
  266. if isinstance(data, dict):
  267. for key in ("measurement_id", "id", "scan_id"):
  268. val = data.get(key)
  269. if val is not None and str(val).strip():
  270. return str(val)
  271. return ""
  272. def scan_fetch_measurement_data(self) -> None:
  273. if not self._scan_id:
  274. QMessageBox.warning(self, "Measurement data", "No measurement id. Start scan first.")
  275. return
  276. ep_tpl = ENDPOINTS["scan"].get("data_by_id", "")
  277. if not ep_tpl:
  278. self.log("[SCAN] data endpoint is not configured.")
  279. return
  280. ep = ep_tpl.format(scan_id=self._scan_id)
  281. params: Dict[str, Any] = {}
  282. if hasattr(self.scan, "leDataNum"):
  283. data_num = self.scan.leDataNum.text().strip()
  284. if data_num:
  285. try:
  286. params["data_num"] = int(data_num)
  287. except Exception:
  288. QMessageBox.warning(self, "Measurement data", "data_num must be integer.")
  289. return
  290. if hasattr(self.scan, "leAveragingNum"):
  291. averaging_num = self.scan.leAveragingNum.text().strip()
  292. if averaging_num:
  293. try:
  294. params["averaging_num"] = int(averaging_num)
  295. except Exception:
  296. QMessageBox.warning(self, "Measurement data", "averaging_num must be integer.")
  297. return
  298. def call() -> ApiResult:
  299. return self.hardware_api.get_json(ep, params=params or None)
  300. def on_success(res: ApiResult) -> None:
  301. size = 0
  302. if isinstance(res.data, dict):
  303. size = len(json.dumps(res.data, ensure_ascii=False))
  304. self.log(f"[SCAN] measurement data loaded. bytes~{size}")
  305. if hasattr(self.scan, "lblMeasurementStatus"):
  306. self.scan.lblMeasurementStatus.setText("Measurement status: data loaded")
  307. self._run_api_result(
  308. call,
  309. pre_log=f"[SCAN] requesting measurement data: id={self._scan_id}",
  310. on_success=on_success,
  311. error_msg="[SCAN] measurement data error: {error}",
  312. thread_error_msg="[SCAN] measurement data thread error: {error}",
  313. )
  314. def _current_sequence_params(self) -> dict:
  315. return self._steam_params if self._steam_params else {}
  316. def _service_base_url(self, client: Any) -> str:
  317. return (getattr(client, "base_url", "") or "").rstrip("/")
  318. def _set_service_ping_label(
  319. self,
  320. *,
  321. label_attr: str,
  322. service_name: str,
  323. base_url: str,
  324. ok: Optional[bool],
  325. msg: str = "",
  326. include_error_tail: bool = False,
  327. ) -> None:
  328. if not hasattr(self.scan, label_attr):
  329. return
  330. label = getattr(self.scan, label_attr)
  331. if ok is None:
  332. label.setText(f"* {service_name}: checking ({base_url})")
  333. label.setStyleSheet("color: #808080;")
  334. return
  335. if ok:
  336. label.setText(f"* {service_name}: online ({base_url})")
  337. label.setStyleSheet("color: #2e7d32;")
  338. return
  339. tail = f" - {msg}" if include_error_tail and msg else ""
  340. label.setText(f"* {service_name}: offline ({base_url}){tail}")
  341. label.setStyleSheet("color: #c62828;")
  342. def _ping_service(
  343. self,
  344. *,
  345. client: Any,
  346. set_label_state: Callable[[Optional[bool], str], None],
  347. ) -> None:
  348. set_label_state(None, "")
  349. base = self._service_base_url(client)
  350. def job() -> Tuple[bool, str]:
  351. try:
  352. r = requests.get(base, timeout=2.0)
  353. return True, f"HTTP {r.status_code}"
  354. except Exception as e:
  355. return False, str(e)
  356. def ok(result: Tuple[bool, str]) -> None:
  357. state, msg = result
  358. set_label_state(state, msg if not state else "")
  359. def err(msg: str) -> None:
  360. set_label_state(False, msg)
  361. self._run_async(job, ok, err)
  362. def _run_async(
  363. self,
  364. job: Callable[[], Any],
  365. ok: Callable[[Any], None],
  366. err: Callable[[str], None],
  367. pre_log: Optional[str] = None,
  368. ) -> None:
  369. if pre_log:
  370. self.log(pre_log)
  371. run_in_thread(self, job, ok, err)
  372. def _run_api_result(
  373. self,
  374. call: Callable[[], ApiResult],
  375. *,
  376. pre_log: Optional[str] = None,
  377. on_success: Optional[Callable[[ApiResult], None]] = None,
  378. on_error: Optional[Callable[[ApiResult], None]] = None,
  379. on_thread_error: Optional[Callable[[str], None]] = None,
  380. success_msg: Optional[str] = None,
  381. error_msg: Optional[str] = None,
  382. thread_error_msg: Optional[str] = None,
  383. success_msg_fn: Optional[Callable[[ApiResult], str]] = None,
  384. error_msg_fn: Optional[Callable[[ApiResult], str]] = None,
  385. thread_error_msg_fn: Optional[Callable[[str], str]] = None,
  386. ) -> None:
  387. def ok(res: ApiResult) -> None:
  388. if res.ok:
  389. if success_msg_fn is not None:
  390. self.log(success_msg_fn(res))
  391. elif success_msg:
  392. self.log(success_msg.format(status_code=res.status_code))
  393. if on_success is not None:
  394. on_success(res)
  395. else:
  396. if error_msg_fn is not None:
  397. self.log(error_msg_fn(res))
  398. elif error_msg:
  399. self.log(error_msg.format(error=res.error, status_code=res.status_code))
  400. if on_error is not None:
  401. on_error(res)
  402. def err(msg: str) -> None:
  403. if thread_error_msg_fn is not None:
  404. self.log(thread_error_msg_fn(msg))
  405. elif thread_error_msg:
  406. self.log(thread_error_msg.format(error=msg))
  407. if on_thread_error is not None:
  408. on_thread_error(msg)
  409. self._run_async(call, ok, err, pre_log=pre_log)
  410. def backend_connect(self) -> None:
  411. self._ping_backend_service()
  412. self._ping_interp_service()
  413. self.check_system_health("gradient")
  414. self.check_system_health("rf")
  415. self.check_system_health("adc")
  416. def backend_disconnect(self) -> None:
  417. self._set_backend_ping(False, "disconnected")
  418. self._set_interp_ping(False, "disconnected")
  419. self.log("[BACKEND] Disconnected (UI).")
  420. self._set_led("gradient", None)
  421. self._set_led("rf", None)
  422. self._set_led("adc", None)
  423. def _set_backend_ping(self, ok: Optional[bool], msg: str = "") -> None:
  424. self._set_service_ping_label(
  425. label_attr="lblBackendPing",
  426. service_name="srv-hardware",
  427. base_url=self._service_base_url(self.hardware_api),
  428. ok=ok,
  429. msg=msg,
  430. include_error_tail=False,
  431. )
  432. def _ping_backend_service(self) -> None:
  433. self._ping_service(
  434. client=self.hardware_api,
  435. set_label_state=self._set_backend_ping,
  436. )
  437. def _set_interp_ping(self, ok: Optional[bool], msg: str = "") -> None:
  438. self._set_service_ping_label(
  439. label_attr="lblInterpPing",
  440. service_name="srv-interp",
  441. base_url=self._service_base_url(self.interp_api),
  442. ok=ok,
  443. msg=msg,
  444. include_error_tail=False,
  445. )
  446. def _ping_interp_service(self) -> None:
  447. self._ping_service(
  448. client=self.interp_api,
  449. set_label_state=self._set_interp_ping,
  450. )
  451. def _set_led(self, system: str, ok: Optional[bool]) -> None:
  452. def set_color(lbl: QLabel, color: str):
  453. lbl.setText("*")
  454. lbl.setStyleSheet(f"color: {color}; font-size: 18px;")
  455. if system == "gradient":
  456. lbl_name = "lblGradLed"
  457. elif system == "rf":
  458. lbl_name = "lblRFLed"
  459. elif system == "adc":
  460. lbl_name = "lblADCLed"
  461. else:
  462. return
  463. if not hasattr(self.scan, lbl_name):
  464. return
  465. lbl = getattr(self.scan, lbl_name)
  466. if ok is None:
  467. set_color(lbl, "#808080")
  468. elif ok:
  469. set_color(lbl, "#2e7d32")
  470. else:
  471. set_color(lbl, "#c62828")
  472. def check_system_health(self, system: str) -> None:
  473. ep = ENDPOINTS["systems"][system]["health"]
  474. def job() -> Tuple[str, ApiResult]:
  475. return system, self.hardware_api.get_json(ep)
  476. def ok(result: Tuple[str, ApiResult]) -> None:
  477. sys_name, res = result
  478. if res.ok:
  479. ok_flag = True
  480. msg = "OK"
  481. if isinstance(res.data, dict):
  482. ok_flag = bool(res.data.get("ok", True))
  483. msg = str(res.data.get("message", "OK"))
  484. self._set_led(sys_name, ok_flag)
  485. self.log(f"[{sys_name.upper()}] health: {msg}")
  486. else:
  487. self._set_led(sys_name, False)
  488. self.log(f"[{sys_name.upper()}] health error: {res.error}")
  489. def err(msg: str) -> None:
  490. self._set_led(system, False)
  491. self.log(f"[{system.upper()}] health thread error: {msg}")
  492. self._run_async(job, ok, err)
  493. def system_load(self, system: str) -> None:
  494. path, _ = QFileDialog.getOpenFileName(self, f"Select config for {system}", "", "All files (*)")
  495. if not path:
  496. return
  497. ep = ENDPOINTS["systems"][system]["upload_config"]
  498. def call() -> ApiResult:
  499. return self.hardware_api.post_file(ep, path)
  500. self._run_system_action(
  501. system=system,
  502. call=call,
  503. pre_log=f"[{system.upper()}] uploading config...",
  504. success_msg=f"[{system.upper()}] config uploaded (HTTP {{status_code}})",
  505. error_msg=f"[{system.upper()}] upload error: {{error}}",
  506. thread_error_msg=f"[{system.upper()}] upload thread error: {{error}}",
  507. )
  508. def system_start(self, system: str) -> None:
  509. ep = ENDPOINTS["systems"][system]["start"]
  510. def call() -> ApiResult:
  511. return self.hardware_api.post_json(ep, payload={})
  512. self._run_system_action(
  513. system=system,
  514. call=call,
  515. pre_log=f"[{system.upper()}] starting...",
  516. success_msg=f"[{system.upper()}] started (HTTP {{status_code}})",
  517. error_msg=f"[{system.upper()}] start error: {{error}}",
  518. thread_error_msg=f"[{system.upper()}] start thread error: {{error}}",
  519. )
  520. def _run_system_action(
  521. self,
  522. *,
  523. system: str,
  524. call: Callable[[], ApiResult],
  525. pre_log: str,
  526. success_msg: str,
  527. error_msg: str,
  528. thread_error_msg: str,
  529. ) -> None:
  530. def success_hook(_res: ApiResult) -> None:
  531. self.check_system_health(system)
  532. def error_hook(_res: ApiResult) -> None:
  533. self._set_led(system, False)
  534. def thread_error_hook(_msg: str) -> None:
  535. self._set_led(system, False)
  536. self._run_api_result(
  537. call,
  538. pre_log=pre_log,
  539. success_msg=success_msg,
  540. error_msg=error_msg,
  541. thread_error_msg=thread_error_msg,
  542. on_success=success_hook,
  543. on_error=error_hook,
  544. on_thread_error=thread_error_hook,
  545. )
  546. def scan_prepare(self) -> None:
  547. self.log("[SCAN] prepare requested.")
  548. if hasattr(self.scan, "pteMeasurementPayload"):
  549. payload = self._build_measurement_payload()
  550. if payload is None:
  551. return
  552. self.log(f"[SCAN] measurement payload is valid. keys={len(payload)}")
  553. if hasattr(self.scan, "lblMeasurementStatus"):
  554. self.scan.lblMeasurementStatus.setText("Measurement status: payload validated")
  555. return
  556. loaded_path = (self._sequence_file_path or "").lower()
  557. if loaded_path.endswith(".seq"):
  558. self.log("[SCAN] .seq loaded: skipping JSON params upload.")
  559. else:
  560. if self._current_sequence_params():
  561. self.send_sequence_params_to_backend()
  562. else:
  563. self.log("[SEQ] params upload skipped: no sequence params in UI.")
  564. self.send_sequence_interpret_to_backend()
  565. def scan_load_to_scanner(self) -> None:
  566. default_path = (self._sequence_file_path or "").strip()
  567. default_dir = os.path.dirname(default_path) if default_path else ""
  568. file_path, _ = QFileDialog.getOpenFileName(
  569. self,
  570. "Select .seq file to load to scanner",
  571. default_dir,
  572. "Sequence (*.seq);;All files (*)",
  573. )
  574. if not file_path:
  575. self.log("[SEQ] load to scanner canceled.")
  576. return
  577. self._sequence_file_path = file_path
  578. self._refresh_current_sequence_ui()
  579. self.send_sequence_interpret_to_backend(file_path=file_path)
  580. def send_sequence_params_to_backend(self) -> None:
  581. payload = self._current_sequence_params()
  582. ep = ENDPOINTS["sequence_params"]
  583. def call() -> ApiResult:
  584. return self.hardware_api.post_json(ep, payload)
  585. self._run_api_result(
  586. call,
  587. pre_log="[SEQ] sending params to backend...",
  588. success_msg="[SEQ] params sent (HTTP {status_code})",
  589. error_msg="[SEQ] send error: {error}",
  590. thread_error_msg="[SEQ] send thread error: {error}",
  591. )
  592. def send_sequence_interpret_to_backend(self, file_path: Optional[str] = None) -> None:
  593. ep = ENDPOINTS["sequence_interpret"]["interpret"]
  594. ep_status = ENDPOINTS["sequence_interpret"]["status"]
  595. file_path = (file_path or self._sequence_file_path or "").strip()
  596. if not file_path or not os.path.isfile(file_path):
  597. file_path, _ = QFileDialog.getOpenFileName(
  598. self,
  599. "Select .seq file for interpretation",
  600. "",
  601. "Sequence (*.seq);;All files (*)",
  602. )
  603. if not file_path:
  604. self.log("[SEQ] interpretation skipped: .seq file was not selected.")
  605. return
  606. self._sequence_file_path = file_path
  607. self._refresh_current_sequence_ui()
  608. def job() -> Tuple[ApiResult, ApiResult]:
  609. upload_res = self.interp_api.post_file(ep, file_path, field_name="file")
  610. status_res = self.interp_api.get_json(ep_status) if upload_res.ok else ApiResult(ok=False, status_code=0, error="")
  611. return upload_res, status_res
  612. def ok(result: Tuple[ApiResult, ApiResult]) -> None:
  613. upload_res, status_res = result
  614. if upload_res.ok:
  615. self.log(f"[SEQ] interpret upload sent (HTTP {upload_res.status_code})")
  616. if status_res.ok and isinstance(status_res.data, dict):
  617. tasks = status_res.data.get("tasks")
  618. self.log(f"[SEQ] interpret status: {short_json(tasks if tasks is not None else status_res.data)}")
  619. else:
  620. self.log(f"[SEQ] interpretation error: {upload_res.error}")
  621. def err(msg: str) -> None:
  622. self.log(f"[SEQ] interpretation thread error: {msg}")
  623. self._run_async(job, ok, err, pre_log=f"[SEQ] sending interpretation file: {short_path(file_path)}")
  624. def _refresh_current_sequence_ui(self) -> None:
  625. if not hasattr(self, "scan") or self.scan is None:
  626. return
  627. line = getattr(self.scan, "leCurrentSequence", None)
  628. if line is None:
  629. return
  630. path = (self._sequence_file_path or "").strip()
  631. if path and path.lower().endswith(".seq"):
  632. line.setText(path)
  633. else:
  634. line.clear()
  635. def scan_interpreter_status(self) -> None:
  636. ep_status = ENDPOINTS["sequence_interpret"]["status"]
  637. def call() -> ApiResult:
  638. return self.interp_api.get_json(ep_status)
  639. self._run_api_result(
  640. call,
  641. pre_log="[SEQ] requesting interpreter status...",
  642. success_msg_fn=lambda res: f"[SEQ] interpreter status: {short_json(res.data)}",
  643. error_msg="[SEQ] interpreter status error: {error}",
  644. thread_error_msg="[SEQ] interpreter status thread error: {error}",
  645. )
  646. def scan_start(self) -> None:
  647. self.scan.pbScanProgress.setRange(0, 100)
  648. self.scan.pbScanProgress.setValue(0)
  649. payload = self._build_measurement_payload()
  650. if payload is None:
  651. return
  652. ep = ENDPOINTS["scan"]["start"]
  653. ep_fallback_get = ENDPOINTS["scan"].get("start_fallback_get", ep)
  654. def call() -> ApiResult:
  655. res = self.hardware_api.post_json(ep, payload)
  656. if res.ok:
  657. return res
  658. # Some measurement services start acquisition via GET /api/measurement
  659. return self.hardware_api.get_json(ep_fallback_get)
  660. def success_hook(res: ApiResult) -> None:
  661. self._scan_id = self._extract_scan_id(res.data)
  662. self.log(f"[SCAN] started. scan_id={self._scan_id}")
  663. if hasattr(self.scan, "lblMeasurementStatus"):
  664. sid = self._scan_id if self._scan_id else "unknown"
  665. self.scan.lblMeasurementStatus.setText(f"Measurement status: running (id={sid})")
  666. self.scan_timer.start()
  667. self._run_api_result(
  668. call,
  669. pre_log="[SCAN] starting...",
  670. on_success=success_hook,
  671. error_msg="[SCAN] start error: {error}",
  672. thread_error_msg="[SCAN] start thread error: {error}",
  673. )
  674. def scan_abort(self) -> None:
  675. self.log("[SCAN] abort requested.")
  676. self.scan_stop()
  677. def scan_stop(self) -> None:
  678. self.scan_timer.stop()
  679. ep = ENDPOINTS["scan"].get("stop", "")
  680. if not ep:
  681. self.log("[SCAN] stopped (local timer only).")
  682. return
  683. def call() -> ApiResult:
  684. return self.hardware_api.post_json(ep, payload={"scan_id": self._scan_id})
  685. self._run_api_result(
  686. call,
  687. success_msg="[SCAN] stopped.",
  688. error_msg="[SCAN] stop endpoint unavailable: {error}",
  689. thread_error_msg="[SCAN] stop thread error: {error}",
  690. )
  691. def poll_scan_progress(self) -> None:
  692. state_by_id_tpl = ENDPOINTS["scan"].get("state_by_id", "")
  693. ep_state_legacy = ENDPOINTS["scan"].get("state", "")
  694. ep_progress_legacy = ENDPOINTS["scan"].get("progress", "")
  695. def call() -> ApiResult:
  696. if self._scan_id and state_by_id_tpl:
  697. ep_state = state_by_id_tpl.format(scan_id=self._scan_id)
  698. res = self.hardware_api.get_json(ep_state)
  699. if res.ok:
  700. return res
  701. if ep_state_legacy:
  702. res = self.hardware_api.get_json(ep_state_legacy)
  703. if res.ok:
  704. return res
  705. if ep_progress_legacy:
  706. return self.hardware_api.get_json(ep_progress_legacy, params={"scan_id": self._scan_id})
  707. return ApiResult(ok=False, status_code=0, error="No scan state/progress endpoint configured")
  708. def on_success(res: ApiResult) -> None:
  709. progress = None
  710. message = ""
  711. done = False
  712. if isinstance(res.data, dict):
  713. progress = res.data.get("progress", None)
  714. message = str(res.data.get("message", ""))
  715. done = bool(res.data.get("done", False))
  716. if not message:
  717. status = res.data.get("status")
  718. if status is not None:
  719. message = str(status)
  720. if not done:
  721. done = bool(res.data.get("data_ready", False))
  722. if progress is None:
  723. if done:
  724. progress = 100
  725. elif message:
  726. s = message.lower()
  727. if any(x in s for x in ("run", "progress", "acquir", "measure")):
  728. progress = 50
  729. if isinstance(progress, (int, float)):
  730. self.scan.pbScanProgress.setRange(0, 100)
  731. self.scan.pbScanProgress.setValue(int(progress))
  732. if message:
  733. self.statusbar.showMessage(message, 1500)
  734. if hasattr(self.scan, "lblMeasurementStatus"):
  735. self.scan.lblMeasurementStatus.setText(f"Measurement status: {message}")
  736. if done or (isinstance(progress, (int, float)) and progress >= 100):
  737. self.scan_timer.stop()
  738. self.log("[SCAN] finished.")
  739. if hasattr(self.scan, "lblMeasurementStatus"):
  740. self.scan.lblMeasurementStatus.setText("Measurement status: data_ready")
  741. self._run_api_result(
  742. call,
  743. on_success=on_success,
  744. error_msg="[SCAN] progress error: {error}",
  745. thread_error_msg="[SCAN] progress thread error: {error}",
  746. )