From 0ed47e7d4aaee5e6b25d68ee8e01a1f1662a8826 Mon Sep 17 00:00:00 2001 From: appleb_m Date: Tue, 11 Nov 2025 11:43:31 +0100 Subject: [PATCH] DAQ/GUI: feature added - fluorimeter panel and device --- daq/src/aaredaq/daq.py | 15 ++ daq/src/aaredaq/devices.py | 4 + daq/src/aaredaq/server.py | 74 ++++++++- daq/src/mxlibs3/fluorimeter.py | 167 ++++++++++++++++++++ gui/src/aaregui/main_window.py | 23 +++ gui/src/aaregui/panels/fluoresence_panel.py | 148 +++++++++++++++++ gui/src/aaregui/threads/daq_worker.py | 85 ++++++++++ 7 files changed, 515 insertions(+), 1 deletion(-) create mode 100644 daq/src/mxlibs3/fluorimeter.py create mode 100644 gui/src/aaregui/panels/fluoresence_panel.py diff --git a/daq/src/aaredaq/daq.py b/daq/src/aaredaq/daq.py index e39cd4ee..cac4ba14 100644 --- a/daq/src/aaredaq/daq.py +++ b/daq/src/aaredaq/daq.py @@ -1524,3 +1524,18 @@ class AareDAQ: self.__devs.anneal(time_s) finally: self.__cfg.state_busy = False + + def fluorimeter_start(self, erase: bool = False): + self.__devs.fluorimeter.start_acquisition(erase=erase) + + def fluorimeter_stop(self): + self.__devs.fluorimeter.stop_acquisition() + + def fluorimeter_status(self) -> int | None: + return self.__devs.fluorimeter.check_status() + + def fluorimeter_data(self): + return self.__devs.fluorimeter.get_current_data() + + def fluorimeter_background(self): + return self.__devs.fluorimeter.get_current_background() \ No newline at end of file diff --git a/daq/src/aaredaq/devices.py b/daq/src/aaredaq/devices.py index 58b84953..91aa5182 100644 --- a/daq/src/aaredaq/devices.py +++ b/daq/src/aaredaq/devices.py @@ -9,6 +9,7 @@ from mxlibs3 import aerotech, smargon from mxlibs3.area_detector import epicsAD from mxlibs3.enum_pv import EnumPv from mxlibs3.filter_transmission import FilterTransmission +from mxlibs3.fluorimeter import Fluorimeter from mxlibs3.my_motor import MyMotor from mxlibs3.non_standard import NonStandard from mxlibs3.tell_client import TellClient @@ -175,6 +176,9 @@ class BeamlineDevices: self.sample_cam = epicsAD(f"{BEAMLINE}-SAMCAM:") + self.fluorimeter = Fluorimeter(beamline) + + @property def lamp_light(self) -> float: return self.__lamp_light.value diff --git a/daq/src/aaredaq/server.py b/daq/src/aaredaq/server.py index b142dc2e..c6c5e54d 100644 --- a/daq/src/aaredaq/server.py +++ b/daq/src/aaredaq/server.py @@ -2,7 +2,7 @@ import asyncio import io import os, time from typing import Tuple, AsyncGenerator - +import json import cv2 import urllib3 import uvicorn @@ -546,6 +546,78 @@ async def get_all_pgroups(token: str = Depends(oauth2_scheme)): logger.error(f"Failed to list pgroups: {e}") return [] +@app.post("/fluorimeter/start") +async def fluorimeter_start(erase: bool = False, token: str = Depends(oauth2_scheme)) -> str: + auth.check_jwt_rw(cfg, auth.parse_token(token)) + daq.fluorimeter_start(erase=erase) + return "OK" + +@app.post("/fluorimeter/stop") +async def fluorimeter_stop(token: str = Depends(oauth2_scheme)) -> str: + auth.check_jwt_rw(cfg, auth.parse_token(token)) + daq.fluorimeter_stop() + return "OK" + +@app.get("/fluorimeter/status") +async def fluorimeter_status(token: str = Depends(oauth2_scheme)) -> int | None: + auth.check_jwt_ro(cfg, auth.parse_token(token)) + return daq.fluorimeter_status() + +@app.get("/fluorimeter/data") +async def fluorimeter_data(token: str = Depends(oauth2_scheme)) -> list[int] | None: + auth.check_jwt_ro(cfg, auth.parse_token(token)) + return daq.fluorimeter_data() + +@app.get("/fluorimeter/background") +async def fluorimeter_background(token: str = Depends(oauth2_scheme)) -> list[int] | None: + auth.check_jwt_ro(cfg, auth.parse_token(token)) + return daq.fluorimeter_background() + +# Optional: SSE stream for live spectra while acquiring +async def fluorimeter_stream() -> AsyncGenerator[str, None]: + logger.debug("start fluorimeter stream...") + try: + while True: + s = daq.fluorimeter_status() + d = daq.fluorimeter_data() + b = daq.fluorimeter_background() + # Ensure JSON-serializable lists; avoid numpy truthiness + status = "stopped" if s == 0 else "running" + logger.debug(f"fluorimeter status: {status}. got data from fluorimeter: {d[0]} and background: {b[0]}") + data = d.tolist() if hasattr(d, "tolist") else (list(d) if isinstance(d, (tuple, list)) else ([] if d is None else [d])) + bkg = b.tolist() if hasattr(b, "tolist") else (list(b) if isinstance(b, (tuple, list)) else ([] if b is None else [b])) + logger.debug(f"fluorimeter data: {data[0]}. fluorimeter background: {bkg[0]}") + payload = json.dumps({"status": s, "data": data, "background": bkg}, separators=(",", ":")) + yield f"data: {payload}\n\n" + if s == 0: + await asyncio.sleep(0.2) + logger.debug("fluorimeter stream stopped") + break + await asyncio.sleep(0.2) + except asyncio.CancelledError as e: + logger.error(f">>> Fluorimeter stream cancelled: {e}") + return + +@app.get("/sse/fluorimeter") +async def sse_fluorimeter(token: str = Depends(oauth2_scheme)): + auth.check_jwt_ro(cfg, auth.parse_token(token)) + try: + daq.fluorimeter_start(erase=False) + except Exception: + # Ignore if already running or start not needed + pass + return StreamingResponse( + fluorimeter_stream(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "Cache-Control" + } + ) + + LOGGING_CONFIG = { "version": 1, "disable_existing_loggers": False, diff --git a/daq/src/mxlibs3/fluorimeter.py b/daq/src/mxlibs3/fluorimeter.py new file mode 100644 index 00000000..75cab584 --- /dev/null +++ b/daq/src/mxlibs3/fluorimeter.py @@ -0,0 +1,167 @@ +import time +from typing import Callable, cast + +from epics import PV, poll +from epics.ca import pend_io + +from aaredaqlib.beamline import MXBeamline + +from aaredaqlib.logger_config import setup_logger + +logger = setup_logger("aareaDAQ") + +class Fluorimeter(object): + + def __init__(self, beamline: MXBeamline, **kwargs): + BEAMLINE = beamline.value.upper() + + self.__start = PV(f"{BEAMLINE}-ES-SiD:mca1Start") #0 done ,1 start + self.__stop = PV(f"{BEAMLINE}-ES-SiD:mca1Stop") #0 done ,1 stop + self.__erase_and_start = PV(f"{BEAMLINE}-ES-SiD:mca1EraseStart") #0 done,1 start + self.__erase = PV(f"{BEAMLINE}-ES-SiD:mca1Erase") #0 done,1 erase + + self.__preset_mode = PV(f"{BEAMLINE}-ES-SiD:dxp1:PresetMode") # set mode + self.__status = PV(f"{BEAMLINE}-ES-SiD:mca1.ACQG") # 0 done, 1 acquire + + self.__real_time = PV(f"{BEAMLINE}-ES-SiD:mca1.PRTM") #float + self.__live_time = PV(f"{BEAMLINE}-ES-SiD:mca1.PLTM") #float + self.__elapsed_real_time = PV(f"{BEAMLINE}-ES-SiD:mca1.ERTM") #float + self.__elapsed_live_time = PV(f"{BEAMLINE}-ES-SiD:mca1.ELTM") #float + self.__elapsed_trigger_live_time = PV(f"{BEAMLINE}-ES-SiD:dxp1:ElapsedTriggerLiveTime") + + self.__instant_dead_time = PV(f"{BEAMLINE}-ES-SiD:mca1.IDTIM") + self.__average_dead_time = PV(f"{BEAMLINE}-ES-SiD:mca1.DTIM") + + self.__poll_time_rbv = PV(f"{BEAMLINE}-ES-SiD:PollTime_RBV") + self.__poll_time = PV(f"{BEAMLINE}-ES-SiD:PollTime") + + self.__mca = PV(f"{BEAMLINE}-ES-SiD:mca1") + self.__data = PV(f"{BEAMLINE}-ES-SiD:mca1.VAL") + self.__background = PV(f"{BEAMLINE}-ES-SiD:mca1.BG") + + self.__saveFile = PV(f"{BEAMLINE}-ES-SiD:SaveSystemFile") + self.__saveFile_name = PV(f"{BEAMLINE}-ES-SiD:SaveSystem") + self.__saveFile_rbv = PV(f"{BEAMLINE}-ES-SiD:SaveSystem_RBV") #1 ssave, 0 done + self.__roi_1 = self.get_roi(BEAMLINE, 1) #currently SiEsc + self.__roi_2 = self.get_roi(BEAMLINE, 2) #currently MnKa + + def get_roi(self, bl, roi_num:int): + if roi_num not in [1, 2]: + raise ValueError(f"Invalid ROI number: {roi_num}") + roi_name = f"{bl}-ES-SiD:mca1.R{roi_num}NM" + return PV(roi_name) + + def start_acquisition(self, erase: bool = False): + if erase: + self.__erase_and_start.put(1) + else: + self.__start.put(1) + + def stop_acquisition(self): + self.__stop.put(1) + + def erase(self): + self.__erase.put(1) + + def poll_data(self, timeout_s: float = 30.0): + timeout = time.time() + timeout_s + while not self.check_status_done(): + if time.time() > timeout: + self.stop_acquisition() + raise TimeoutError("timeout waiting for data to be acquired.") + self.__mca.get() + poll(0.1) + + def get_current_data(self): + return self.__data.get() + + def get_current_background(self): + return self.__background.get() + + def check_status(self): + return self.__status.get() + + def check_status_done(self): + if self.check_status() == 0: + return True + + def check_status_acquiring(self): + if self.check_status() == 1: + return True + + def wait_till_done(self, timeout_s: float = 30.0): + timeout = time.time() + timeout_s + while not self.check_status_done(): + if time.time() > timeout: + raise TimeoutError("timeout waiting for done.") + poll(0.1) + + def wait_till_acquiring(self, timeout_s: float = 30.0): + timeout = time.time() + timeout_s + while not self.check_status_acquiring(): + if time.time() > timeout: + raise TimeoutError("timeout waiting for acquiring.") + poll(0.1) + + def set_preset_mode(self, mode: int): + """ 0 No preset, 1 Real time, 2 Live time, 3 Events, 4 Triggers""" + if mode not in [0, 1, 2, 3, 4]: + raise ValueError(f"Invalid preset mode: {mode}") + try: + self.__preset_mode.put(mode) + except Exception as e: + logger.error(f"Error setting preset mode to {mode}: {e}") + + def save_file(self, filename:str|None = None, timeout_s: float = 30.0): + if not filename: + filename = f"KETEK_{time.strftime('%Y%m%d_%H%M%S')}.txt" + self.__saveFile_name.put(filename) + try: + self.__saveFile.put(1) + timeout = time.time() + timeout_s + while self.__saveFile_rbv.get() == 1: + if time.time() > timeout: + raise TimeoutError("Timed out waiting for save to complete") + poll(0.1) + except Exception as e: + logger.error(f"Error saving file {filename}: {e}") + + @property + def real_time(self): + return self.__real_time.get() + + @real_time.setter + def real_time(self, value): + self.__real_time.put(value) + + @property + def live_time(self): + return self.__live_time.get() + + @live_time.setter + def live_time(self, value): + self.__live_time.put(value) + + def elapsed_real_time(self): + return self.__elapsed_real_time.get() + + def elapsed_live_time(self): + return self.__elapsed_live_time.get() + + def elapsed_trigger_live_time(self): + return self.__elapsed_trigger_live_time.get() + + def instant_dead_time(self): + return self.__instant_dead_time.get() + + def average_dead_time(self): + return self.__average_dead_time.get() + + @property + def poll_time(self): + return self.__poll_time_rbv.get() + + @poll_time.setter + def poll_time(self, value): + self.__poll_time.put(value) + diff --git a/gui/src/aaregui/main_window.py b/gui/src/aaregui/main_window.py index b86991b1..64d58cf7 100644 --- a/gui/src/aaregui/main_window.py +++ b/gui/src/aaregui/main_window.py @@ -36,6 +36,7 @@ from aaregui.widgets.camera_image import SampleCameraImageLabel from aaregui.widgets.no_wheel_scroll_area import NoWheelScrollArea from aaregui.widgets.status_bar import StatusBar from aaregui.widgets.video_image import VideoGraphicsView +from aaregui.panels.fluoresence_panel import FluorescencePanel logger = setup_logger("aareGUI") @@ -186,6 +187,13 @@ class MainWindow(QMainWindow): self.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, self.face_panel_dock) self.face_panel_dock.hide() + self.fluor_panel = FluorescencePanel() + self.fluor_panel_dock = QDockWidget("Fluorescence", self) + self.fluor_panel_dock.setWidget(self.fluor_panel) + self.fluor_panel_dock.setAllowedAreas(Qt.DockWidgetArea.RightDockWidgetArea | Qt.DockWidgetArea.LeftDockWidgetArea) + self.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, self.fluor_panel_dock) + self.fluor_panel_dock.hide() + # Create and add the dock to your main window self.log_dock = LogDock("Console Log", self) self.addDockWidget(Qt.BottomDockWidgetArea, self.log_dock) @@ -347,6 +355,14 @@ class MainWindow(QMainWindow): self.status_bar.get_all_pgroups.connect(self.daq.get_all_pgroups) self.daq.staff_pgroups_loaded.connect(self.status_bar.staff_pgroups_loaded) + self.fluor_panel.start.connect(self.daq.fluorimeter_start) + self.fluor_panel.start_erase.connect(self.daq.fluorimeter_start_erase) + self.fluor_panel.stop.connect(self.daq.fluorimeter_stop) + self.fluor_panel.snapshot.connect(self.daq.fluorimeter_request_snapshot) + self.fluor_panel.live.connect(self.daq.fluorimeter_start_erase) + self.fluor_panel.live.connect(self.daq.start_fluorimeter_stream) + self.daq.fluorimeter_update.connect(self.fluor_panel.update_plot) + def create_menu_bar(self): """Create a menu bar with File->Quit and Help->About.""" # Main menu bar @@ -392,6 +408,13 @@ class MainWindow(QMainWindow): self.face_panel_dock.visibilityChanged.connect(show_face_panel_action.setChecked) view_menu.addAction(show_face_panel_action) + show_fluor_panel_action = QAction("Show fluorescence", self) + show_fluor_panel_action.setCheckable(True) + show_fluor_panel_action.setChecked(False) + show_fluor_panel_action.triggered.connect(lambda checked: self.fluor_panel_dock.setVisible(checked)) + self.fluor_panel_dock.visibilityChanged.connect(show_fluor_panel_action.setChecked) + view_menu.addAction(show_fluor_panel_action) + show_log_action = QAction("Show Log", self) show_log_action.setCheckable(True) show_log_action.setChecked(False) diff --git a/gui/src/aaregui/panels/fluoresence_panel.py b/gui/src/aaregui/panels/fluoresence_panel.py new file mode 100644 index 00000000..2c263bbb --- /dev/null +++ b/gui/src/aaregui/panels/fluoresence_panel.py @@ -0,0 +1,148 @@ +from PySide6.QtCore import Slot, QTimer, Signal +from PySide6.QtWidgets import QWidget, QGridLayout, QPushButton +from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas +from matplotlib.figure import Figure +import numpy as np + +from aaregui.widgets.number_line_edit import NumberLineEdit +from aaregui.widgets.title_label import TitleLabel +from aaredaqlib.logger_config import setup_logger + +logger = setup_logger(__name__) + +class FluorescencePanel(QWidget): + + start = Signal() + start_erase = Signal() + stop = Signal() + snapshot = Signal() + live = Signal() + + def __init__(self, parent=None): + super().__init__(parent) + + grid = QGridLayout(self) + grid.addWidget(TitleLabel("Fluorescence", self), 0, 0, 1, 4) + + self.btn_start = QPushButton("Start") + self.btn_start_erase = QPushButton("Erase+Start") + self.btn_stop = QPushButton("Stop") + self.btn_live = QPushButton("Start Live") + self.btn_snapshot = QPushButton("Snapshot") + for i, b in enumerate((self.btn_start, self.btn_start_erase, self.btn_stop, self.btn_live, self.btn_snapshot), start=1): + grid.addWidget(b, 1, i - 1) + + self.fig = Figure(figsize=(5, 3), tight_layout=True) + self.ax = self.fig.add_subplot(111) + self.canvas = FigureCanvas(self.fig) + grid.addWidget(self.canvas, 2, 0, 1, 4) + + # Wire buttons + self.btn_start.clicked.connect(self.start.emit) + self.btn_start_erase.clicked.connect(self.start_erase.emit) + self.btn_stop.clicked.connect(self.stop.emit) + self.btn_snapshot.clicked.connect(self.snapshot.emit) + self.btn_live.clicked.connect(self.live.emit) + + + @Slot(list, list, int) + def update_plot(self, data, background, status): + try: + self.ax.clear() + + if data: + y = np.array(data, dtype=float) + x = np.arange(len(y), dtype=int) + self.ax.plot(x, y, lw=1.0, label="Data") + + if background: + b = np.array(background, dtype=float) + xb = np.arange(len(b), dtype=int) + self.ax.plot(xb, b, lw=1.0, label="Background") + + self.ax.set_xlabel("Channel") + self.ax.set_ylabel("Counts") + if self.ax.has_data(): + self.ax.legend(loc="best") + self.ax.grid(True, alpha=0.3) + self.canvas.draw_idle() + except Exception as e: + logger.error(f"Update plot error: {e}") + + +# class FluorescencePanel(QWidget): +# def __init__(self, daq_worker, parent=None): +# super().__init__(parent) +# self._w = daq_worker +# +# lay = QGridLayout(self) +# self.btn_start = QPushButton("Start") +# self.btn_start_erase = QPushButton("Erase+Start") +# self.btn_stop = QPushButton("Stop") +# self.btn_live = QPushButton("Start Live") +# self.btn_snapshot = QPushButton("Snapshot") +# lay.addWidget(self.btn_start, 0, 0) +# lay.addWidget(self.btn_start_erase, 0, 1) +# lay.addWidget(self.btn_stop, 0, 2) +# lay.addWidget(self.btn_live, 0, 3) +# lay.addWidget(self.btn_snapshot, 0, 4) +# +# self.fig = Figure(figsize=(5, 3), tight_layout=True) +# self.ax = self.fig.add_subplot(111) +# self.canvas = FigureCanvas(self.fig) +# lay.addWidget(self.canvas, 1, 0, 1, 5) +# +# # Polling timer +# self._timer = QTimer(self) +# self._timer.setInterval(200) +# self._timer.timeout.connect(self._request_snapshot) +# +# # Wire buttons +# self.btn_start.clicked.connect(self._w.fluorimeter_start) +# self.btn_start_erase.clicked.connect(self._w.fluorimeter_start_erase) +# self.btn_stop.clicked.connect(self._on_stop_clicked) +# self.btn_snapshot.clicked.connect(self._request_snapshot) +# self.btn_live.clicked.connect(self._on_start_live) +# +# # Subscribe to data updates +# # Expected signal: fluorimeter_update(list data, list background, int status) +# self._w.fluorimeter_update.connect(self.update_plot) +# +# @Slot() +# def _on_start_live(self): +# # Start acquisition if not already running, then begin polling +# self._w.fluorimeter_start() +# self._timer.start() +# +# @Slot() +# def _on_stop_clicked(self): +# self._timer.stop() +# self._w.fluorimeter_stop() +# +# @Slot() +# def _request_snapshot(self): +# # One-shot HTTP GET to fetch data+status+background (worker emits fluorimeter_update) +# self._w.fluorimeter_request_snapshot() +# +# @Slot(list, list, int) +# def update_plot(self, data, background, status): +# # Plot and stop timer automatically when acquisition ends +# self.ax.clear() +# if data: +# y = np.array(data, dtype=float) +# x = np.arange(len(y)) +# self.ax.plot(x, y, lw=1.0, label="Data") +# if background: +# b = np.array(background, dtype=float) +# xb = np.arange(len(b)) +# self.ax.plot(xb, b, lw=1.0, label="Background") +# if self.ax.has_data(): +# self.ax.legend(loc="best") +# self.ax.set_xlabel("Channel") +# self.ax.set_ylabel("Counts") +# self.ax.grid(True, alpha=0.3) +# self.canvas.draw_idle() +# +# # status: 1 acquiring, 0 done, other -> unknown +# if status == 0: +# self._timer.stop() \ No newline at end of file diff --git a/gui/src/aaregui/threads/daq_worker.py b/gui/src/aaregui/threads/daq_worker.py index d32da94c..e26b2ca2 100644 --- a/gui/src/aaregui/threads/daq_worker.py +++ b/gui/src/aaregui/threads/daq_worker.py @@ -1,6 +1,7 @@ import copy import random import time +import json from PySide6.QtCore import Signal, QUrl, Slot, QTimer, QObject, QByteArray from PySide6.QtNetwork import QNetworkAccessManager, QNetworkRequest, QNetworkReply @@ -32,6 +33,7 @@ class DAQWorker(QObject): raster_generated_by_ml = Signal(RasterGridRequest) face_detection_result = Signal(dict) staff_pgroups_loaded = Signal(list) + fluorimeter_update = Signal(list, list, int) def __init__(self, base_url: str | None, token: str, parent=None): super().__init__(parent) @@ -507,4 +509,87 @@ class DAQWorker(QObject): reply = self.__net_manager.post(request, QByteArray(b"")) reply.finished.connect(lambda: self._handle_face_detection_response(reply)) + @Slot() + def fluorimeter_start(self): + self.generic_post("fluorimeter/start") + @Slot() + def fluorimeter_start_erase(self): + self.generic_post("fluorimeter/start?erase=true") + + @Slot() + def fluorimeter_stop(self): + self.generic_post("fluorimeter/stop") + + def _handle_fluorimeter_data(self, reply: QNetworkReply, emit_status: bool = False): + try: + response_data = self.handle_response(reply) + import json + data = json.loads(response_data) if response_data else [] + if emit_status: + # fetch status and bkg in parallel (simple sequential here) + status_req = QNetworkRequest(QUrl(f"{self.__base_url}/fluorimeter/status")) + status_req.setRawHeader(b"Authorization", f"Bearer {self.__token}".encode("utf-8")) + status_reply = self.__net_manager.get(status_req) + status_reply.finished.connect(lambda: self._handle_fluorimeter_status_and_emit(data, status_reply)) + else: + self.fluorimeter_update.emit(data, [], -1) + except Exception as e: + logger.error(f"Fluorimeter data error: {e}") + self.http_error.emit(str(e)) + + def _handle_fluorimeter_status_and_emit(self, data, status_reply: QNetworkReply): + try: + s_payload = self.handle_response(status_reply) + s = int(s_payload) if s_payload not in ("", "null") else -1 + b_req = QNetworkRequest(QUrl(f"{self.__base_url}/fluorimeter/background")) + b_req.setRawHeader(b"Authorization", f"Bearer {self.__token}".encode("utf-8")) + b_reply = self.__net_manager.get(b_req) + b_reply.finished.connect(lambda: self._emit_fluorimeter_with_bkg(data, s, b_reply)) + except Exception as e: + logger.error(f"Fluorimeter status error: {e}") + self.http_error.emit(str(e)) + + def _emit_fluorimeter_with_bkg(self, data, s, b_reply: QNetworkReply): + try: + bkg_json = self.handle_response(b_reply) + import json + bkg = json.loads(bkg_json) if bkg_json else [] + self.fluorimeter_update.emit(data, bkg, s) + except Exception as e: + logger.error(f"Fluorimeter background error: {e}") + self.http_error.emit(str(e)) + + @Slot() + def fluorimeter_request_snapshot(self): + if self.__base_url is None: + return + req = QNetworkRequest(QUrl(f"{self.__base_url}/fluorimeter/data")) + req.setRawHeader(b"Authorization", f"Bearer {self.__token}".encode("utf-8")) + reply = self.__net_manager.get(req) + reply.finished.connect(lambda: self._handle_fluorimeter_data(reply, emit_status=True)) + + # Optional: SSE listener for live updates + def start_fluorimeter_stream(self): + if self.__base_url is None: + return + request = QNetworkRequest(QUrl(f"{self.__base_url}/sse/fluorimeter")) + request.setRawHeader(b"Authorization", f"Bearer {self.__token}".encode("utf-8")) + reply = self.__net_manager.get(request) + reply.readyRead.connect(lambda: self._read_fluorimeter_stream(reply)) + reply.finished.connect(lambda: reply.deleteLater()) + + def _read_fluorimeter_stream(self, reply: QNetworkReply): + try: + chunk = reply.readAll().data().decode("utf-8") + for line in chunk.splitlines(): + if line.startswith("data:"): + payload = line[5:].strip() + if payload: + obj = json.loads(payload) + data = obj.get("data") or [] + bkg = obj.get("background") or [] + status = obj.get("status", -1) + self.fluorimeter_update.emit(data, bkg, status) + except Exception as e: + logger.error(f"SSE parse error: {e}") \ No newline at end of file