diff --git a/nucleares_monitor/control_board_monitor.py b/nucleares_monitor/control_board_monitor.py index b044fe9..9f0b4e5 100644 --- a/nucleares_monitor/control_board_monitor.py +++ b/nucleares_monitor/control_board_monitor.py @@ -24,18 +24,51 @@ from dataclasses import dataclass, field from datetime import datetime from typing import Dict, List, Optional, Tuple, Deque import math # NOWE: do decymacji i obliczeń -# --- PyQtGraph availability check (PyQt5 lub PySide6 są OK) --- +import traceback +# --- Qt log & DPI: uspokojenie Qt na multi-monitor (opcjonalne, ale pomaga) --- import os -os.environ.setdefault("QT_LOGGING_RULES", "qt.core.qobject.connect=false") # opcjonalne wyciszenie warningów Qt +os.environ.setdefault("QT_LOGGING_RULES", "qt.core.qobject.connect=false") +os.environ.setdefault("QT_ENABLE_HIGHDPI_SCALING", "0") +os.environ.setdefault("QT_AUTO_SCREEN_SCALE_FACTOR", "0") -import math +DEBUG_LOG = True # możesz wyłączyć na False gdy już będzie stabilnie -# PyQtGraph: wykrywamy razem z jego własną warstwą Qt +def _dbg(msg: str): + if not DEBUG_LOG: + return + try: + from datetime import datetime + with open("cbm_debug.log", "a", encoding="utf-8") as f: + f.write(f"{datetime.now().strftime('%H:%M:%S.%f')} {msg}\n") + except Exception: + pass + +def _dbg_exc(where: str): + _dbg(f"[EXC] {where}\n{traceback.format_exc()}") + +SAFE_GLOBALS = { + "__builtins__": {}, + "abs": abs, "min": min, "max": max, "round": round, + "int": int, "float": float, "pow": pow, "math": math, +} + +def safe_eval(expr: str, symbols: dict): + """Bardzo ograniczony eval – tylko dopuszczone funkcje i przekazane symbole.""" + clean = {k: v for k, v in (symbols or {}).items() if v is not None} + return eval(expr, SAFE_GLOBALS, clean) + +def eval_user_expression(expr: str, x, y, z) -> float: + """Eval wyrażenia użytkownika (np. w schedulerze) na (x,y,z).""" + val = safe_eval(expr, {"x": x, "y": y, "z": z}) + return float(val) + + +# Spójna warstwa Qt z pyqtgraph (nie mieszamy bezpośrednio PyQt5/PySide6) _pyqtgraph_available = False _pyqtgraph_unavailable_reason = "" try: import pyqtgraph as pg - from pyqtgraph.Qt import QtCore, QtWidgets # <<< ważne: ta sama warstwa co pyqtgraph + from pyqtgraph.Qt import QtCore, QtWidgets _pyqtgraph_available = True except Exception as e: _pyqtgraph_unavailable_reason = str(e) @@ -58,6 +91,14 @@ try: except Exception: HAS_MPL = False +# --- Daemon thread spawner ---------------------------------------------------- +def spawn_daemon(name: str, target, *args, **kwargs): + """ + Start background thread as daemon=True. Returns the Thread object. + """ + t = threading.Thread(target=target, args=args, kwargs=kwargs, daemon=True, name=name) + t.start() + return t # ===================== # Global configuration @@ -67,7 +108,7 @@ SERVER_HOST: str = "localhost" SERVER_PORT: int = 8785 REFRESH_INTERVAL_S: int = 1 REQUEST_TIMEOUT_S: float = 5.0 -USER_AGENT: str = "ControlBoardMonitor/1.15 (+tkinter)" +USER_AGENT: str = "ControlBoardMonitor/1.16 (+tkinter)" # Fallback defaults (extracted from your HTML) DEFAULT_VARS: List[str] = ['ALARMS_ACTIVE', 'AMBIENT_TEMPERATURE', 'CHEM_BORON_DOSAGE_ACTUAL', 'CHEM_BORON_DOSAGE_ORDERED', 'CHEM_BORON_FILTER_ACTUAL', 'CHEM_BORON_FILTER_ORDERED', 'CHEM_BORON_PPM', 'CHEM_TRUCK_CONNECTED', 'CHEM_TRUCK_IN_ZONE', 'CHEMICAL_CLEANING_PUMP_DRY_STATUS', 'CHEMICAL_CLEANING_PUMP_OVERLOAD_STATUS', 'CHEMICAL_CLEANING_PUMP_STATUS', 'CHEMICAL_DOSING_PUMP_DRY_STATUS', 'CHEMICAL_DOSING_PUMP_OVERLOAD_STATUS', 'CHEMICAL_DOSING_PUMP_STATUS', 'CHEMICAL_FILTER_PUMP_DRY_STATUS', 'CHEMICAL_FILTER_PUMP_OVERLOAD_STATUS', 'CHEMICAL_FILTER_PUMP_STATUS', 'CONDENSER_CIRCULATION_PUMP_ACTIVE', 'CONDENSER_CIRCULATION_PUMP_ORDERED_SPEED', 'CONDENSER_CIRCULATION_PUMP_OVERLOAD_STATUS', 'CONDENSER_CIRCULATION_PUMP_SPEED', 'CONDENSER_CIRCULATION_PUMP_SWITCH', 'CONDENSER_CONDENSATE_FLOW_RATE', 'CONDENSER_COOLANT_EVAPORATED', 'CONDENSER_EXTRACTION_FLOW_RATE', 'CONDENSER_PRESSURE', 'CONDENSER_TEMPERATURE', 'CONDENSER_VACUUM', 'CONDENSER_VACUUM_PUMP_ACTIVE', 'CONDENSER_VACUUM_PUMP_MODE', 'CONDENSER_VACUUM_PUMP_POWER', 'CONDENSER_VACUUM_RELIEF_VALVE_OPENING', 'CONDENSER_VAPOR_VOLUME', 'CONDENSER_VOLUME', 'COOLANT_CORE_CIRCULATION_PUMP_0_CAPACITY', 'COOLANT_CORE_CIRCULATION_PUMP_0_DRY_STATUS', 'COOLANT_CORE_CIRCULATION_PUMP_0_ORDERED_SPEED', 'COOLANT_CORE_CIRCULATION_PUMP_0_OVERLOAD_STATUS', 'COOLANT_CORE_CIRCULATION_PUMP_0_SPEED', 'COOLANT_CORE_CIRCULATION_PUMP_0_STATUS', 'COOLANT_CORE_CIRCULATION_PUMP_1_CAPACITY', 'COOLANT_CORE_CIRCULATION_PUMP_1_DRY_STATUS', 'COOLANT_CORE_CIRCULATION_PUMP_1_ORDERED_SPEED', 'COOLANT_CORE_CIRCULATION_PUMP_1_OVERLOAD_STATUS', 'COOLANT_CORE_CIRCULATION_PUMP_1_SPEED', 'COOLANT_CORE_CIRCULATION_PUMP_1_STATUS', 'COOLANT_CORE_CIRCULATION_PUMP_2_CAPACITY', 'COOLANT_CORE_CIRCULATION_PUMP_2_DRY_STATUS', 'COOLANT_CORE_CIRCULATION_PUMP_2_ORDERED_SPEED', 'COOLANT_CORE_CIRCULATION_PUMP_2_OVERLOAD_STATUS', 'COOLANT_CORE_CIRCULATION_PUMP_2_SPEED', 'COOLANT_CORE_CIRCULATION_PUMP_2_STATUS', 'COOLANT_CORE_FLOW_IN', 'COOLANT_CORE_FLOW_ORDERED_SPEED', 'COOLANT_CORE_FLOW_OUT', 'COOLANT_CORE_FLOW_REACHED_SPEED', 'COOLANT_CORE_FLOW_SPEED', 'COOLANT_CORE_MAX_PRESSURE', 'COOLANT_CORE_PRESSURE', 'COOLANT_CORE_PRIMARY_LOOP_LEVEL', 'COOLANT_CORE_QUANTITY_CIRCULATION_PUMPS_PRESENT', 'COOLANT_CORE_QUANTITY_FREIGHT_PUMPS_PRESENT', 'COOLANT_CORE_QUANTITY_IN_VESSEL', 'COOLANT_CORE_STATE', 'COOLANT_CORE_VESSEL_TEMPERATURE', 'COOLANT_SEC_0_LIQUID_VOLUME', 'COOLANT_SEC_0_PRESSURE', 'COOLANT_SEC_0_TEMPERATURE', 'COOLANT_SEC_0_VOLUME', 'COOLANT_SEC_1_LIQUID_VOLUME', 'COOLANT_SEC_1_PRESSURE', 'COOLANT_SEC_1_TEMPERATURE', 'COOLANT_SEC_1_VOLUME', 'COOLANT_SEC_2_LIQUID_VOLUME', 'COOLANT_SEC_2_PRESSURE', 'COOLANT_SEC_2_TEMPERATURE', 'COOLANT_SEC_2_VOLUME', 'COOLANT_SEC_CIRCULATION_PUMP_0_CAPACITY', 'COOLANT_SEC_CIRCULATION_PUMP_0_DRY_STATUS', 'COOLANT_SEC_CIRCULATION_PUMP_0_ORDERED_SPEED', 'COOLANT_SEC_CIRCULATION_PUMP_0_OVERLOAD_STATUS', 'COOLANT_SEC_CIRCULATION_PUMP_0_SPEED', 'COOLANT_SEC_CIRCULATION_PUMP_0_STATUS', 'COOLANT_SEC_CIRCULATION_PUMP_1_CAPACITY', 'COOLANT_SEC_CIRCULATION_PUMP_1_DRY_STATUS', 'COOLANT_SEC_CIRCULATION_PUMP_1_ORDERED_SPEED', 'COOLANT_SEC_CIRCULATION_PUMP_1_OVERLOAD_STATUS', 'COOLANT_SEC_CIRCULATION_PUMP_1_SPEED', 'COOLANT_SEC_CIRCULATION_PUMP_1_STATUS', 'COOLANT_SEC_CIRCULATION_PUMP_2_CAPACITY', 'COOLANT_SEC_CIRCULATION_PUMP_2_DRY_STATUS', 'COOLANT_SEC_CIRCULATION_PUMP_2_ORDERED_SPEED', 'COOLANT_SEC_CIRCULATION_PUMP_2_OVERLOAD_STATUS', 'COOLANT_SEC_CIRCULATION_PUMP_2_SPEED', 'COOLANT_SEC_CIRCULATION_PUMP_2_STATUS', 'CORE_BAY_1_HATCH_OPEN', 'CORE_BAY_1_STATE', 'CORE_BAY_2_HATCH_OPEN', 'CORE_BAY_2_STATE', 'CORE_BAY_3_HATCH_OPEN', 'CORE_BAY_3_STATE', 'CORE_BAY_4_HATCH_OPEN', 'CORE_BAY_4_STATE', 'CORE_BAY_5_HATCH_OPEN', 'CORE_BAY_5_STATE', 'CORE_BAY_6_HATCH_OPEN', 'CORE_BAY_6_STATE', 'CORE_BAY_7_HATCH_OPEN', 'CORE_BAY_7_STATE', 'CORE_BAY_8_HATCH_OPEN', 'CORE_BAY_8_STATE', 'CORE_BAY_9_HATCH_OPEN', 'CORE_BAY_9_STATE', 'CORE_CRITICAL_MASS_REACHED', 'CORE_CRITICAL_MASS_REACHED_COUNTER', 'CORE_EXTERNAL_COOLANT_RESERVOIR_VOLUME', 'CORE_FACTOR', 'CORE_FACTOR_CHANGE', 'CORE_FUEL_1_FISSIONABLE', 'CORE_FUEL_1_POWER_FACTOR', 'CORE_FUEL_1_TEMPERATURE', 'CORE_FUEL_2_FISSIONABLE', 'CORE_FUEL_2_POWER_FACTOR', 'CORE_FUEL_2_TEMPERATURE', 'CORE_FUEL_3_FISSIONABLE', 'CORE_FUEL_3_POWER_FACTOR', 'CORE_FUEL_3_TEMPERATURE', 'CORE_FUEL_4_FISSIONABLE', 'CORE_FUEL_4_POWER_FACTOR', 'CORE_FUEL_4_TEMPERATURE', 'CORE_FUEL_5_FISSIONABLE', 'CORE_FUEL_5_POWER_FACTOR', 'CORE_FUEL_5_TEMPERATURE', 'CORE_FUEL_6_FISSIONABLE', 'CORE_FUEL_6_POWER_FACTOR', 'CORE_FUEL_6_TEMPERATURE', 'CORE_FUEL_7_FISSIONABLE', 'CORE_FUEL_7_POWER_FACTOR', 'CORE_FUEL_7_TEMPERATURE', 'CORE_FUEL_8_FISSIONABLE', 'CORE_FUEL_8_POWER_FACTOR', 'CORE_FUEL_8_TEMPERATURE', 'CORE_FUEL_9_FISSIONABLE', 'CORE_FUEL_9_POWER_FACTOR', 'CORE_FUEL_9_TEMPERATURE', 'CORE_FUEL_AVG_FISSIONABLE', 'CORE_FUEL_AVG_POWER_FACTOR', 'CORE_FUEL_AVG_TEMPERATURE', 'CORE_HIGH_STEAM_PRESENT', 'CORE_IMMINENT_FUSION', 'CORE_INTEGRITY', 'CORE_IODINE_CUMULATIVE', 'CORE_IODINE_GENERATION', 'CORE_OPERATION_MODE', 'CORE_POOL_COOLANT_TANK_VOLUME', 'CORE_POOL_PUMP', 'CORE_PRESSURE', 'CORE_PRESSURE_MAX', 'CORE_PRESSURE_OPERATIVE', 'CORE_PRIMARY_CIRCUIT_COOLING_TANK_VOLUME', 'CORE_READY_FOR_START', 'CORE_STATE', 'CORE_STATE_CRITICALITY', 'CORE_STEAM_PRESENT', 'CORE_TEMP', 'CORE_TEMP_MAX', 'CORE_TEMP_MIN', 'CORE_TEMP_OPERATIVE', 'CORE_TEMP_RESIDUAL', 'CORE_WEAR', 'CORE_XENON_CUMULATIVE', 'CORE_XENON_GENERATION', 'EMERGENCY_BATTERIES_MODE', 'EMERGENCY_BATTERIES_POWER_OUTPUT_KW', 'EMERGENCY_GENERATOR_1_FUEL', 'EMERGENCY_GENERATOR_1_MAINTENANCE_NEEDED', 'EMERGENCY_GENERATOR_1_MODE', 'EMERGENCY_GENERATOR_1_PRESSURIZER', 'EMERGENCY_GENERATOR_1_STATUS', 'EMERGENCY_GENERATOR_2_FUEL', 'EMERGENCY_GENERATOR_2_MAINTENANCE_NEEDED', 'EMERGENCY_GENERATOR_2_MODE', 'EMERGENCY_GENERATOR_2_PRESSURIZER', 'EMERGENCY_GENERATOR_2_STATUS', 'EMERGENCY_GENERATOR_POWER_OUTPUT_KW', 'FREIGHT_PUMP_CONDENSER_ACTIVE', 'FREIGHT_PUMP_CONDENSER_SWITCH', 'FREIGHT_PUMP_EXTERNAL_ACTIVE', 'FREIGHT_PUMP_EXTERNAL_SWITCH', 'FREIGHT_PUMP_FEEDWATER_ACTIVE', 'FREIGHT_PUMP_FEEDWATER_SWITCH', 'FREIGHT_PUMP_INTERNAL_ACTIVE', 'FREIGHT_PUMP_INTERNAL_SWITCH', 'FUN_IS_ENABLED', 'GAME_DIFFICULTY', 'GAME_SIM_SPEED', 'GAME_VERSION', 'GENERATOR_0_A', 'GENERATOR_0_BREAKER', 'GENERATOR_0_HERTZ', 'GENERATOR_0_KW', 'GENERATOR_0_V', 'GENERATOR_1_A', 'GENERATOR_1_BREAKER', 'GENERATOR_1_HERTZ', 'GENERATOR_1_KW', 'GENERATOR_1_V', 'GENERATOR_2_A', 'GENERATOR_2_BREAKER', 'GENERATOR_2_HERTZ', 'GENERATOR_2_KW', 'GENERATOR_2_V', 'INSTALLED_LOOPS_JSON', 'INVENTORY_HTML', 'MAINTENANCE_REPORT_HTML', 'MSCV_0_OPENING_ACTUAL', 'MSCV_1_OPENING_ACTUAL', 'MSCV_2_OPENING_ACTUAL', 'POWER_DEMAND_MW', 'POWER_FROM_EXTERNAL_KW', 'POWER_FROM_TURBINE_KW', 'POWER_MAX_THEORETICAL_FINAL_PLANT_OUTPUT_MW', 'POWER_MAX_THEORETICAL_PLANT_OUTPUT_MW', 'RES_ABSORPTION_CAPACITY_MW', 'RES_DIVERT_SURPLUS_FROM_MW', 'RES_EFFECTIVELY_DERIVED_ENERGY_MW', 'RESISTOR_BANK_01_SWITCH', 'RESISTOR_BANK_02_SWITCH', 'RESISTOR_BANK_03_SWITCH', 'RESISTOR_BANK_04_SWITCH', 'RESISTOR_BANKS_JSON', 'RESISTOR_BANKS_MAIN_SWITCH', 'ROD_BANK_POS_0_ACTUAL', 'ROD_BANK_POS_0_ORDERED', 'ROD_BANK_POS_1_ACTUAL', 'ROD_BANK_POS_1_ORDERED', 'ROD_BANK_POS_2_ACTUAL', 'ROD_BANK_POS_2_ORDERED', 'ROD_BANK_POS_3_ACTUAL', 'ROD_BANK_POS_3_ORDERED', 'ROD_BANK_POS_4_ACTUAL', 'ROD_BANK_POS_4_ORDERED', 'ROD_BANK_POS_5_ACTUAL', 'ROD_BANK_POS_5_ORDERED', 'ROD_BANK_POS_6_ACTUAL', 'ROD_BANK_POS_6_ORDERED', 'ROD_BANK_POS_7_ACTUAL', 'ROD_BANK_POS_7_ORDERED', 'ROD_BANK_POS_8_ACTUAL', 'ROD_BANK_POS_8_ORDERED', 'RODS_ALIGNED', 'RODS_DEFORMED', 'RODS_MAX_TEMPERATURE', 'RODS_MOVEMENT_SPEED', 'RODS_MOVEMENT_SPEED_DECREASED_HIGH_TEMPERATURE', 'RODS_POS_ACTUAL', 'RODS_POS_ORDERED', 'RODS_POS_REACHED', 'RODS_QUANTITY', 'RODS_STATUS', 'RODS_TEMPERATURE', 'STEAM_EJECTOR_CONDENSER_RETURN_VALVE_ACTUAL', 'STEAM_EJECTOR_CONDENSER_RETURN_VALVE_ORDERED', 'STEAM_EJECTOR_MOTIVE', 'STEAM_EJECTOR_OPERATIONAL_MOTIVE_VALVE_ACTUAL', 'STEAM_EJECTOR_OPERATIONAL_MOTIVE_VALVE_ORDERED', 'STEAM_EJECTOR_STARTUP_MOTIVE_VALVE_ACTUAL', 'STEAM_EJECTOR_STARTUP_MOTIVE_VALVE_ORDERED', 'STEAM_GEN_0_BOILING_POINT', 'STEAM_GEN_0_EVAPORATED', 'STEAM_GEN_0_INLET', 'STEAM_GEN_0_OUTLET', 'STEAM_GEN_0_RETURN_FLOW_PLUS_CONDENSED', 'STEAM_GEN_0_STATUS', 'STEAM_GEN_0_VENT_SWITCH', 'STEAM_GEN_1_BOILING_POINT', 'STEAM_GEN_1_EVAPORATED', 'STEAM_GEN_1_INLET', 'STEAM_GEN_1_OUTLET', 'STEAM_GEN_1_RETURN_FLOW_PLUS_CONDENSED', 'STEAM_GEN_1_STATUS', 'STEAM_GEN_1_VENT_SWITCH', 'STEAM_GEN_2_BOILING_POINT', 'STEAM_GEN_2_EVAPORATED', 'STEAM_GEN_2_INLET', 'STEAM_GEN_2_OUTLET', 'STEAM_GEN_2_RETURN_FLOW_PLUS_CONDENSED', 'STEAM_GEN_2_STATUS', 'STEAM_GEN_2_VENT_SWITCH', 'STEAM_TURBINE_0_BYPASS_ACTUAL', 'STEAM_TURBINE_0_INSTALLED', 'STEAM_TURBINE_0_PRESSURE', 'STEAM_TURBINE_0_RPM', 'STEAM_TURBINE_0_TEMPERATURE', 'STEAM_TURBINE_0_TORQUE', 'STEAM_TURBINE_1_BYPASS_ACTUAL', 'STEAM_TURBINE_1_INSTALLED', 'STEAM_TURBINE_1_PRESSURE', 'STEAM_TURBINE_1_RPM', 'STEAM_TURBINE_1_TEMPERATURE', 'STEAM_TURBINE_1_TORQUE', 'STEAM_TURBINE_2_BYPASS_ACTUAL', 'STEAM_TURBINE_2_INSTALLED', 'STEAM_TURBINE_2_PRESSURE', 'STEAM_TURBINE_2_RPM', 'STEAM_TURBINE_2_TEMPERATURE', 'STEAM_TURBINE_2_TORQUE', 'TIME', 'TIME_DAY', 'TIME_STAMP', 'VACUUM_RETENTION_TANK_PRESSURE', 'VACUUM_RETENTION_TANK_VOLUME', 'VALVE_M01_OPEN', 'VALVE_M02_OPEN', 'VALVE_M03_OPEN', 'VALVE_PANEL_JSON', 'WEATHER_FORECAST_JSON', 'WEBSERVER_BATCH_GET', 'WEBSERVER_LIST_VARIABLES', 'WEBSERVER_LIST_VARIABLES_JSON', 'WEBSERVER_VIEW_VARIABLES'] @@ -450,31 +491,45 @@ class ActionScheduler(threading.Thread): params = {"variable": name, "value": value} return http_post_query(base, params) + def stop(self) -> None: + self._stop.set() + def run(self) -> None: + # Pętla bez time.sleep; tylko _stop.wait(...) while not self._stop.is_set(): now = time.time() to_run: List[ActionTask] = [] with self._lock: - for t in self.tasks.values(): - if t.enabled and t.next_run <= now: - to_run.append(t) + # wybierz zadania do uruchomienia + for t in list(self.tasks.values()): + if not t.enabled: + continue + if t.interval_s == 0: + # jednorazowe – uruchom dokładnie raz (gdy nie było jeszcze run) + if t.last_run <= 0: + to_run.append(t) + else: + if now >= t.next_run: + to_run.append(t) + + # wykonaj poza lockiem for t in to_run: try: - status, body, _ = self.run_task_once(t) - t.last_run = now - if t.interval_s > 0: - t.next_run = now + t.interval_s + code, msg, _hdrs = self.run_task_once(t) + t.last_run = time.time() + if t.interval_s == 0: + # jednorazowe – wyłącz po wykonaniu + t.enabled = False else: - self.remove_task(t.task_id) + t.next_run = t.last_run + max(0.0, float(t.interval_s)) except Exception: - if t.interval_s > 0: - t.next_run = now + t.interval_s - else: - self.remove_task(t.task_id) - time.sleep(0.25) + # nie blokujemy pętli scheduler’a na wyjątkach z pojedynczego taska + _dbg_exc("Scheduler.run->run_task_once") + + # krótka, przerywalna drzemka + # (nie używamy time.sleep; dzięki temu zamknięcie jest natychmiastowe) + self._stop.wait(0.01) - def stop(self) -> None: - self._stop.set() # ===================== # Poller thread (uses BATCH_GET) @@ -503,22 +558,27 @@ class Poller(threading.Thread): base_url = build_base_url(self.host, self.port) while not self.stop_event.is_set(): cycle_start = time.time() - self.ui_queue.put(("cycle_start", datetime.now().strftime("%H:%M:%S"))) - - status, body, _ = http_get(base_url, {"variable": "WEBSERVER_BATCH_GET"}) - if status == 200: - fmt = detect_batch_payload_type(body) - self.ui_queue.put(("batch_fmt", fmt)) - values_map = parse_batch_values(body) - if values_map: - lower_map = {k.lower(): v for k, v in values_map.items()} - # push full batch to UI for 'y' lookups - self.ui_queue.put(("batch", lower_map)) - for key in list(self.variables_keys): - if key in lower_map: - self.ui_queue.put(("update", key, str(lower_map[key]), 200)) - else: - self.ui_queue.put(("error", key, 206, "Not in BATCH_GET payload")) + try: + self.ui_queue.put(("cycle_start", datetime.now().strftime("%H:%M:%S"))) + # główny odczyt (BATCH lub fallback na pojedyncze) + status, body, _ = http_get(base_url, {"variable": "WEBSERVER_BATCH_GET"}) + if status == 200: + values_map = parse_batch_values(body) + if values_map: + lower_map = {k.lower(): v for k, v in values_map.items()} + self.ui_queue.put(("batch", lower_map)) + for key in list(self.variables_keys): + if key in lower_map: + self.ui_queue.put(("update", key, str(lower_map[key]), 200)) + else: + self.ui_queue.put(("error", key, 206, "Not in BATCH_GET payload")) + else: + for key in list(self.variables_keys): + st, b, _h = http_get(base_url, {"variable": key}) + if st == 200: + self.ui_queue.put(("update", key, b, st)) + else: + self.ui_queue.put(("error", key, st, coerce_preview(b, 200))) else: for key in list(self.variables_keys): st, b, _h = http_get(base_url, {"variable": key}) @@ -526,25 +586,34 @@ class Poller(threading.Thread): self.ui_queue.put(("update", key, b, st)) else: self.ui_queue.put(("error", key, st, coerce_preview(b, 200))) - else: - for key in list(self.variables_keys): - st, b, _h = http_get(base_url, {"variable": key}) - if st == 200: - self.ui_queue.put(("update", key, b, st)) - else: - self.ui_queue.put(("error", key, st, coerce_preview(b, 200))) + except Exception: + # nigdy nie wywalamy wątku na zewnątrz + _dbg_exc("Poller.run(main loop)") + # OBSŁUGA PAUZY – aktywnie czekamy, ale przerywalnie + while self.paused_event.is_set() and not self.stop_event.is_set(): + # czekaj 50ms lub do przerwania + self.stop_event.wait(0.05) + + # Odmierz pozostały czas do końca interwału – też przerywalnie cycle_dt = time.time() - cycle_start - remaining = self.refresh_interval - cycle_dt + remaining = float(self.refresh_interval) - cycle_dt if remaining > 0: - for _ in range(int(remaining * 10)): - if self.stop_event.is_set(): + waited = 0.0 + # czekamy w małych porcjach, by reagować na pause/stop + while waited < remaining and not self.stop_event.is_set(): + if self.paused_event.is_set(): break - while self.paused_event.is_set() and not self.stop_event.is_set(): - time.sleep(0.1) - time.sleep(0.1) + chunk = min(0.1, remaining - waited) + self.stop_event.wait(chunk) + waited += chunk + + # sygnalizacja zakończenia + try: + self.ui_queue.put(("stopped", "")) + except Exception: + _dbg_exc("Poller.run(stop)") - self.ui_queue.put(("stopped", "")) # ===================== # GUI @@ -552,7 +621,7 @@ class Poller(threading.Thread): class App(tk.Tk): def __init__(self) -> None: super().__init__() - self.title("Control Board Monitor — v1.15") + self.title("Control Board Monitor — v1.16") self.geometry("1280x860") # Canonical registry: lower-case key -> VarInfo @@ -563,6 +632,8 @@ class App(tk.Tk): self.poller: Optional[Poller] = None self.variables_keys: List[str] = [] # list of lower-case keys in selection order self.functions_list: List[str] = [] # preserve original casing + self._closing = False + self._stop_event = threading.Event() self.scheduler = ActionScheduler( lambda: build_base_url(self.host_var.get().strip(), int(self.port_var.get())), @@ -607,6 +678,7 @@ class App(tk.Tk): self.filter_var = tk.StringVar(value="") self.filter_var.trace_add("write", lambda *_: self.refresh_tree()) ttk.Entry(search_frame, textvariable=self.filter_var, width=50).pack(side=tk.LEFT, padx=8) + self.display_show_thresholds_var = tk.BooleanVar(value=True) top_frame = ttk.Frame(main) search_frame.pack(in_=top_frame, side=tk.TOP, fill=tk.X) @@ -764,6 +836,41 @@ class App(tk.Tk): self.actions_menu.add_command(label="Run Now", command=self.run_selected_task_now) self.actions_menu.add_command(label="Remove", command=self.remove_selected_task) self.actions_tree.bind("", self.on_actions_right_click) + if _pyqtgraph_available: + self._qt_ensure_app() + self.after(16, self._pump_qt_events) + def _qt_ensure_app(self): + """Utwórz (raz) QApplication – wspólną dla wszystkich okien Qt.""" + try: + from pyqtgraph.Qt import QtWidgets + except Exception: + self._qt_app = None + return + if getattr(self, "_qt_app", None) is None: + self._qt_app = QtWidgets.QApplication.instance() or QtWidgets.QApplication([]) + + def _pump_qt_events(self): + try: + if not getattr(self, "_plot_windows", None): + return # nic do pompowania + if not any(getattr(w, "_backend", "") == "pyqtgraph" for w in self._plot_windows.values()): + return + from pyqtgraph.Qt import QtWidgets + app = getattr(self, "_qt_app", None) or QtWidgets.QApplication.instance() + if app is not None: + app.processEvents() + except Exception as e: + _dbg_exc("_pump_qt_events") + if not getattr(self, "_closing", False): + self.after(16, self._pump_qt_events) + + def _safe_bool(self, tkvar, default=False): + try: + return bool(tkvar.get()) + except Exception: + return default + + def get_stats_for(self, name: Optional[str]) -> Optional[dict]: if not name: return None @@ -1162,7 +1269,9 @@ class App(tk.Tk): ms = int(max(150, float(self.refresh_var.get()) * 1000)) # >= ~6 FPS except Exception: ms = 300 - self._plot_timer = self.after(ms, self._plot_tick) + if self._closing: + return + self._plot_timer = self.after(ms, self._plot_tick) if not self._closing else None def _plot_tick(self): windows = [w for w in getattr(self, "_plot_windows", {}).values() @@ -1172,97 +1281,129 @@ class App(tk.Tk): return for win in windows: - key = getattr(win, "_key", None) - vi = self.vars.get(key) - if not vi: - continue - # ustaw tytuł okna: nazwa + aktualna wartość (bez nadmiernego odświeżania) try: + + key = getattr(win, "_key", None) + vi = self.vars.get(key) + if not vi: + continue + # ustaw tytuł okna: nazwa + aktualna wartość (bez nadmiernego odświeżania) val_num = parse_first_float(vi.last_value) new_title = f"{vi.display_name} — {val_num:.6g}" if val_num is not None else f"{vi.display_name}" if getattr(win, "_last_title", None) != new_title: win.title(new_title) win._last_title = new_title - except Exception: - pass - # pomiń jeśli brak nowych danych od ostatniego rysowania - last_ver = getattr(win, "_last_seen_ver", -1) - if getattr(vi, "hist_ver", 0) == last_ver: - continue + # pomiń jeśli brak nowych danych od ostatniego rysowania + last_ver = getattr(win, "_last_seen_ver", -1) + if getattr(vi, "hist_ver", 0) == last_ver: + continue - # pobierz dane (per-okno długość próbek) - try: - n = int(win._sample_len_var.get()) - except Exception: - n = 200 - vals = list(vi.history)[-n:] if hasattr(vi, "history") else [] - dels = list(vi.history_delta)[-n:] if hasattr(vi, "history_delta") else [] - - # decymacja (ogranicz rysowane punkty do _max_draw_pts) - try: - max_pts = int(getattr(win, "_max_draw_pts", int(win._max_draw_var.get()))) - except Exception: - max_pts = 400 - - if len(vals) > max_pts: - step = max(1, math.ceil(len(vals) / max_pts)) - vals_draw = vals[::step] - xs_v = list(range(0, len(vals), step))[:len(vals_draw)] - else: - vals_draw = vals - xs_v = list(range(len(vals))) - - win._line.set_data(xs_v, vals_draw) - if vals_draw: - vmin, vmax = min(vals_draw), max(vals_draw) - span = (vmax - vmin) or 1.0 - m = 0.08 * span - win._ax_val.set_xlim(0, max(1, len(xs_v) - 1)) - win._ax_val.set_ylim(vmin - m, vmax + m) - - # słupki delty z taką samą decymacją - if len(dels) > max_pts: - step_b = max(1, math.ceil(len(dels) / max_pts)) - dels_draw = dels[::step_b] - xs_b = list(range(0, len(dels), step_b))[:len(dels_draw)] - else: - dels_draw = dels - xs_b = list(range(len(dels))) - - if len(dels_draw) != win._bars_len: - for b in win._bars: - b.remove() - win._bars = win._ax_delta.bar(xs_b, dels_draw) - win._bars_len = len(dels_draw) - else: - for b, h in zip(win._bars, dels_draw): - b.set_height(h) - - if dels_draw: - dmin, dmax = min(dels_draw), max(dels_draw) - span = (dmax - dmin) or 1.0 - m = 0.08 * span - win._ax_delta.set_xlim(0, max(1, len(dels_draw) - 1)) - win._ax_delta.set_ylim(dmin - m, dmax + m) - - win._ax_delta.set_xlabel(f"last {len(vals)} samples") - - # tight_layout tylko po resize - if getattr(win, "_layout_dirty", False): + # pobierz dane (per-okno długość próbek) try: - win._fig.tight_layout() + n = int(win._sample_len_var.get()) except Exception: - pass - win._layout_dirty = False + n = 200 + vals = list(vi.history)[-n:] if hasattr(vi, "history") else [] + dels = list(vi.history_delta)[-n:] if hasattr(vi, "history_delta") else [] + + # decymacja (ogranicz rysowane punkty do _max_draw_pts) + try: + max_pts = int(getattr(win, "_max_draw_pts", int(win._max_draw_var.get()))) + except Exception: + max_pts = 400 + + if len(vals) > max_pts: + step = max(1, math.ceil(len(vals) / max_pts)) + vals_draw = vals[::step] + xs_v = list(range(0, len(vals), step))[:len(vals_draw)] + else: + vals_draw = vals + xs_v = list(range(len(vals))) + + win._line.set_data(xs_v, vals_draw) + if vals_draw: + vmin, vmax = min(vals_draw), max(vals_draw) + span = (vmax - vmin) or 1.0 + m = 0.08 * span + win._ax_val.set_xlim(0, max(1, len(xs_v) - 1)) + win._ax_val.set_ylim(vmin - m, vmax + m) + # --- progi i wskaźnik najbliższego progu (Matplotlib) --- + if getattr(win, "_backend", "") == "matplotlib": + show_thr = bool(getattr(win, "_show_thr_var", tk.BooleanVar(value=True)).get()) + for ln in win._thr_lines.values(): + ln.set_visible(False) + win._tri_val.set_visible(False) + win._tri_delta.set_visible(False) + + if show_thr and vals_draw: + thr = self._effective_thresholds(vi) + vmin, vmax = win._ax_val.get_ylim() + def within(y): return (y is not None) and (vmin <= y <= vmax) + + for thr_name in ("dead_low","low","mid","high","extreme"): + y = thr.get(thr_name) + if within(y): + win._thr_lines[thr_name].set_ydata([y, y]) + win._thr_lines[thr_name].set_visible(True) + + # wskaźnik: najbliższy próg do aktualnej wartości + cur = parse_first_float(vi.last_value) + candidates = [(k, thr[k]) for k in ("dead_low","low","mid","high","extreme") if thr.get(k) is not None] + if cur is not None and candidates: + k_best, y_best = min(candidates, key=lambda kv: abs(kv[1]-cur)) + # kolor zgodny z linią + color_map = { + "dead_low": "#FBC02D", "low": "#FFF59D", "mid": "#2ECC71", + "high": "#FF8A80", "extreme": "#D50000", + } + col = color_map.get(k_best, "#2ECC71") + # trójkąt w dół, jeśli próg < aktualna; w górę, jeśli próg > aktualna + up = (y_best > cur) + char = "▲" if up else "▼" + win._tri_val.set_text(char); win._tri_val.set_color(col); win._tri_val.set_visible(True) + win._tri_delta.set_text(char); win._tri_delta.set_color(col); win._tri_delta.set_visible(True) + + # słupki delty z taką samą decymacją + if len(dels) > max_pts: + step_b = max(1, math.ceil(len(dels) / max_pts)) + dels_draw = dels[::step_b] + xs_b = list(range(0, len(dels), step_b))[:len(dels_draw)] + else: + dels_draw = dels + xs_b = list(range(len(dels))) + + if len(dels_draw) != win._bars_len: + for b in win._bars: + b.remove() + win._bars = win._ax_delta.bar(xs_b, dels_draw) + win._bars_len = len(dels_draw) + else: + for b, h in zip(win._bars, dels_draw): + b.set_height(h) + + if dels_draw: + dmin, dmax = min(dels_draw), max(dels_draw) + span = (dmax - dmin) or 1.0 + m = 0.08 * span + win._ax_delta.set_xlim(0, max(1, len(dels_draw) - 1)) + win._ax_delta.set_ylim(dmin - m, dmax + m) + + win._ax_delta.set_xlabel(f"last {len(vals)} samples") + + # tight_layout tylko po resize + if getattr(win, "_layout_dirty", False): + try: + win._fig.tight_layout() + except Exception: + pass + win._layout_dirty = False - try: win._canvas.draw_idle() + win._last_seen_ver = getattr(vi, "hist_ver", win._last_seen_ver) except Exception: - pass - - win._last_seen_ver = getattr(vi, "hist_ver", win._last_seen_ver) - + _dbg_exc("_plot_tick(one window)") + continue # kolejny tick try: ms = int(max(150, float(self.refresh_var.get()) * 1000)) @@ -1452,6 +1593,9 @@ class App(tk.Tk): win._backend = "matplotlib" tools = ttk.Frame(win); tools.pack(side=tk.TOP, fill=tk.X) + win._show_thr_var = tk.BooleanVar(value=bool(self.display_show_thresholds_var.get())) + ttk.Checkbutton(tools, text="Show thresholds", variable=win._show_thr_var).pack(side=tk.LEFT, padx=(8,6)) + ttk.Label(tools, text="Samples:").pack(side=tk.LEFT, padx=(8,2)) win._sample_len_var = tk.IntVar(value=int(self.default_samples_var.get())) ttk.Spinbox(tools, from_=20, to=5000, increment=10, @@ -1469,9 +1613,24 @@ class App(tk.Tk): ax_val = fig.add_subplot(2,1,1) ax_delta = fig.add_subplot(2,1,2, sharex=ax_val) ax_val.set_ylabel("value"); ax_delta.set_ylabel("Δ"); ax_delta.set_xlabel("samples") + # threshold lines (na osi wartości) + win._thr_lines = { + "dead_low": ax_val.axhline(0, color="#FBC02D", linewidth=1.2, linestyle="--", visible=False), # ciemny żółty + "low": ax_val.axhline(0, color="#FFF59D", linewidth=1.2, linestyle="--", visible=False), # jasny żółty + "mid": ax_val.axhline(0, color="#2ECC71", linewidth=1.0, linestyle=":", visible=False), # zielony (środek) + "high": ax_val.axhline(0, color="#FF8A80", linewidth=1.2, linestyle="--", visible=False), # jasny czerwony + "extreme": ax_val.axhline(0, color="#D50000", linewidth=1.2, linestyle="--", visible=False), # ciemny czerwony + } canvas = FigureCanvasTkAgg(fig, master=win) canvas.draw(); canvas.get_tk_widget().pack(fill=tk.BOTH, expand=True) + # wskazniki 'najbliższego progu' (po lewej; poza obszarem osi) + win._tri_val = ax_val.text(-0.03, -0.10, "▲", transform=ax_val.transAxes, + ha="left", va="top", color="#2ECC71", fontsize=12, + clip_on=False, visible=False) + win._tri_delta = ax_delta.text(-0.03, 1.10, "▲", transform=ax_delta.transAxes, + ha="left", va="bottom", color="#2ECC71", fontsize=12, + clip_on=False, visible=False) (line,) = ax_val.plot([], [], linewidth=1.2, antialiased=False) bars = ax_delta.bar([], [], linewidth=0, antialiased=False) @@ -1514,6 +1673,10 @@ class App(tk.Tk): ttk.Label(box, text="Default max draw pts:").grid(row=1, column=0, sticky="w", pady=(6,0)) md = tk.IntVar(value=int(self.default_maxpts_var.get())) ttk.Spinbox(box, from_=100, to=5000, increment=50, textvariable=md, width=6).grid(row=1, column=1, padx=6, pady=(6,0)) + # Show thresholds on plots + chk_box = ttk.Frame(dlg); chk_box.pack(fill=tk.X, padx=10, pady=(10,0)) + show_thr_local = tk.BooleanVar(value=bool(self.display_show_thresholds_var.get())) + ttk.Checkbutton(chk_box, text="Show thresholds (plots)", variable=show_thr_local).pack(anchor="w") # Buttons bar = ttk.Frame(dlg); bar.pack(fill=tk.X, pady=12, padx=10) @@ -1521,6 +1684,7 @@ class App(tk.Tk): self.display_backend_var.set(back.get()) self.default_samples_var.set(int(ds.get())) self.default_maxpts_var.set(int(md.get())) + self.display_show_thresholds_var.set(bool(show_thr_local.get())) dlg.destroy() tk.messagebox.showinfo("Display defaults", "Zapisz te ustawienia na stałe przez File → Save Configuration.\n" @@ -2011,76 +2175,124 @@ class App(tk.Tk): except Exception: pass return tag - - def _state_color(self, vi, x_value=None) -> str: - """Kolor tła w hex w oparciu o progi (także dynamiczne). - Bezpiecznie obsługuje brak wartości x (None).""" - - # --- efektywne progi (uwzględniają expr_thr_*) --- + def _effective_thresholds(self, vi:"WarInfo") -> dict: + return self._compute_effective_thresholds(vi) + def _compute_effective_thresholds(self, vi: "VarInfo") -> dict[str, float | None]: + """ + Liczy progi efektywne: bierzemy liczby z konfiguracji + nadpisujemy je, jeśli + zdefiniowano formuły expr_thr_*. Zwracamy też 'mid'=(low+high)/2 jeżeli możliwe. + """ t = vi.thresholds stats_x = self.get_stats_for(vi.key) stats_y = self.get_stats_for(getattr(t, "y_source", None)) stats_z = self.get_stats_for(getattr(t, "z_source", None)) - def _eff(thr_num, thr_expr): - if thr_expr: - v = eval_threshold_expr(thr_expr, stats_x, stats_y, stats_z) - if v is not None: - return v - return thr_num + def eff(num, expr): + if expr: + try: + v = eval_threshold_expr(expr, stats_x, stats_y, stats_z) + if v is not None: + return float(v) + except Exception: + _dbg_exc("eval_threshold_expr") + return float(num) if num is not None else None - thr_dead = _eff(getattr(t, "dead_low", None), getattr(t, "expr_thr_dead_low", None)) - thr_low = _eff(getattr(t, "low", None), getattr(t, "expr_thr_low", None)) - thr_high = _eff(getattr(t, "high", None), getattr(t, "expr_thr_high", None)) - thr_ext = _eff(getattr(t, "extreme_high", None), getattr(t, "expr_thr_extreme_high", None)) + dead = eff(getattr(t, "dead_low", None), getattr(t, "expr_thr_dead_low", None)) + low = eff(getattr(t, "low", None), getattr(t, "expr_thr_low", None)) + high = eff(getattr(t, "high", None), getattr(t, "expr_thr_high", None)) + ext = eff(getattr(t, "extreme_high", None), getattr(t, "expr_thr_extreme_high", None)) + mid = (low + high) / 2.0 if (low is not None and high is not None) else None + return {"dead_low": dead, "low": low, "high": high, "extreme": ext, "mid": mid} - # --- x może być None (np. świeży monitor / brak liczby w payloadzie) --- + def evaluate_thresholds(self, vi: VarInfo, value: str) -> None: try: - x = float(x_value) if x_value is not None else parse_first_float(vi.last_value) + x = parse_first_float(value) + thr = self._compute_effective_thresholds(vi) + dead, low, high, ext = thr["dead_low"], thr["low"], thr["high"], thr["extreme"] + + prev = vi.last_state + state = "OPERATING" + + if x is None: + state = "OPERATING" + else: + dl = dead if dead is not None else -float("inf") + lo = low if low is not None else -float("inf") + hi = high if high is not None else float("inf") + ex = ext if ext is not None else float("inf") + + if x < dl: + state = "DEAD_LOW" + elif x < lo: + state = "LOW" + elif x < hi: + state = "OPERATING" + elif x < ex: + state = "HIGH" + else: + state = "EXTREME_HIGH" + + if state != prev: + self.on_state_change(vi, prev, state, x) + vi.last_state = state + except Exception: - x = None - if x is None: - # neutralny szary – brak danych + _dbg_exc("evaluate_thresholds") + def _state_color(self, vi, x_value=None) -> str: + """Kolor tła w hex na bazie progów efektywnych; odporne na None.""" + try: + thr = self._compute_effective_thresholds(vi) + thr_dead = thr["dead_low"] + thr_low = thr["low"] + thr_high = thr["high"] + thr_ext = thr["extreme"] + + try: + x = float(x_value) if x_value is not None else parse_first_float(vi.last_value) + except Exception: + x = None + if x is None: + return "#E9ECEF" # brak danych + + if thr_dead is None: + thr_dead = -float("inf") + if thr_low is None and thr_high is None: + thr_low, thr_high = x - 1e-6, x + 1e-6 + if thr_low is None: + thr_low = thr_dead if thr_dead != -float("inf") else (x - 1e-6) + if thr_high is None: + thr_high = max(thr_low, x + 1e-6) + if thr_ext is None: + thr_ext = float("inf") + + def _hex(c): return "#{:02X}{:02X}{:02X}".format(*c) + def _lerp(c1, c2, t): + t = 0.0 if t < 0 else 1.0 if t > 1 else t + return ( + int(c1[0] + (c2[0]-c1[0])*t), + int(c1[1] + (c2[1]-c1[1])*t), + int(c1[2] + (c2[2]-c1[2])*t), + ) + + Y_LO, Y_HI = (255, 244, 178), (255, 149, 0) + G_OK = (46, 204, 113) + R_LO, R_HI = (255, 138, 128), (213, 0, 0) + + if x <= thr_low: + denom = (thr_low - thr_dead) if (thr_low > thr_dead) else 1.0 + t = (thr_low - x) / max(denom, 1e-9) + return _hex(_lerp(Y_LO, Y_HI, t)) + + if x <= thr_high: + return _hex(G_OK) + + denom = (thr_ext - thr_high) if (thr_ext > thr_high) else 1.0 + t = (x - thr_high) / max(denom, 1e-9) + return _hex(_lerp(R_LO, R_HI, t)) + except Exception: + _dbg_exc("_state_color") return "#E9ECEF" - # --- fallback progów kiedy część jest pusta --- - if thr_dead is None: - thr_dead = -float("inf") - if thr_low is None and thr_high is None: - # brak LOW/HIGH -> otocz x wąskim korytarzem, by dać 'zielony' - thr_low, thr_high = x - 1e-6, x + 1e-6 - if thr_low is None: - thr_low = thr_dead if thr_dead != -float("inf") else (x - 1e-6) - if thr_high is None: - thr_high = max(thr_low, x + 1e-6) - if thr_ext is None: - thr_ext = float("inf") - - # --- prosty lerp koloru w RGB --- - def _hex(c): return "#{:02X}{:02X}{:02X}".format(*c) - def _lerp(c1, c2, t): - t = 0.0 if t < 0 else 1.0 if t > 1 else t - return (int(c1[0] + (c2[0]-c1[0])*t), - int(c1[1] + (c2[1]-c1[1])*t), - int(c2[2] + (c2[2]-c1[2])*t)) # (uwaga: to tylko komentarz – kod niżej bez zmian) - - Y_LO = (255, 244, 178) - Y_HI = (255, 149, 0) - G_OK = (46, 204, 113) - R_LO = (255, 138, 128) - R_HI = (213, 0, 0) - - if x <= thr_low: - denom = max(1e-9, (thr_low - thr_dead)) if thr_low > thr_dead else 1.0 - t = (thr_low - x) / denom - return _hex(_lerp(Y_LO, Y_HI, t)) - - if x <= thr_high: - return _hex(G_OK) - - denom = max(1e-9, (thr_ext - thr_high)) if thr_ext > thr_high else 1.0 - t = (x - thr_high) / denom - return _hex(_lerp(R_LO, R_HI, t)) def refresh_tree(self) -> None: filt = self.filter_var.get().strip().lower() @@ -2301,6 +2513,7 @@ class App(tk.Tk): "backend": self.display_backend_var.get(), "samples": int(self.default_samples_var.get()), "max_draw_pts": int(self.default_maxpts_var.get()), + "show_thresholds": bool(self.display_show_thresholds_var.get()), } path = filedialog.asksaveasfilename(defaultextension=".json", filetypes=[("JSON","*.json")], title="Save Configuration") @@ -2343,6 +2556,10 @@ class App(tk.Tk): self.default_maxpts_var.set(int(dd.get("max_draw_pts", 400))) except Exception: pass + try: + self.display_show_thresholds_var.set(bool(dd.get("show_thresholds", True))) + except Exception: + pass # replace scheduled tasks for t in [t.task_id for t in self.scheduler.list_tasks()]: @@ -2429,22 +2646,63 @@ class App(tk.Tk): x_mode=d.get("x_mode","raw")) def on_close(self) -> None: + # zatrzymaj Pollera + self._closing = True + # zatrzymaj okna/timery Qt (jeśli są) try: - if self.poller and self.poller.is_alive(): - self.stop_event.set() + for _k, tup in list(getattr(self, "_qt_windows", {}).items()): + try: + win, timer = tup + except Exception: + continue + try: + timer.stop(); timer.deleteLater() + except Exception: + pass + try: + win.close() + except Exception: + pass + self._qt_windows.clear() except Exception: pass + try: - self.scheduler.stop() + if getattr(self, "poller", None) and self.poller.is_alive(): + self.stop_event.set() # natychmiast przerwie wait() w pętli + self.paused_event.clear() + self.poller.join(timeout=1.5) except Exception: pass + + # zatrzymaj scheduler try: - if self._plot_timer is not None: + if getattr(self, "scheduler", None) and self.scheduler.is_alive(): + self.scheduler.stop() # ustawia wewnętrzny _stop Event + self.scheduler.join(timeout=1.5) + except Exception: + pass + + # zatrzymaj timer do plotów, jeśli działa + try: + if getattr(self, "_plot_timer", None): self.after_cancel(self._plot_timer) - self._plot_timer = None except Exception: pass + + # zamknij okna wykresów + try: + for win in list(self._plot_windows.values()): + try: + win.destroy() + except Exception: + pass + except Exception: + pass + self.destroy() + + def _open_canvas_window(self, key: str): vi = self.vars[key] win = tk.Toplevel(self) @@ -2559,6 +2817,8 @@ class App(tk.Tk): win._canvas.create_rectangle(x, y, x+bw, ch-8, outline="", fill="#8888ff") # kolejny tick + if self._closing or not win.winfo_exists(): + return try: ms = int(max(120, float(self.refresh_var.get())*1000)) except Exception: @@ -2566,20 +2826,19 @@ class App(tk.Tk): win.after(ms, _tick) _tick() + def _open_pyqtgraph_window(self, key: str): if not _pyqtgraph_available: - tk.messagebox.showwarning("PyQtGraph", f"Niedostępny: {_pyqtgraph_unavailable_reason or 'brak pyqtgraph/Qt'}") + tk.messagebox.showwarning("PyQtGraph", "PyQtGraph nie jest dostępny – użyj Matplotlib lub Canvas.") + return + self._qt_ensure_app() + if key not in self.vars: + tk.messagebox.showwarning("Plot", f"Zmienna '{key}' nie jest monitorowana.") return - - import pyqtgraph as pg - from pyqtgraph.Qt import QtCore, QtWidgets - - # Jedna aplikacja Qt na cały proces (tworzymy 1x) - if not hasattr(self, "_qt_app") or self._qt_app is None: - self._qt_app = pg.mkQApp("Control Board Monitor — plots") vi = self.vars[key] + # ... wcześniej: vi = self.vars[key] glw = pg.GraphicsLayoutWidget(title=f"{vi.display_name}") p1 = glw.addPlot(row=0, col=0); p1.showGrid(x=True, y=True) p2 = glw.addPlot(row=1, col=0); p2.showGrid(x=True, y=True) @@ -2588,20 +2847,38 @@ class App(tk.Tk): bars = p2.plot([], [], pen=None, symbol=None, fillLevel=0, brush=(100, 100, 255, 160), stepMode=False) + # linie progów – tworzone raz + glw._thr_lines = { + "dead_low": pg.InfiniteLine(pos=0, angle=0, pen=pg.mkPen("#FBC02D", width=1), movable=False), + "low": pg.InfiniteLine(pos=0, angle=0, pen=pg.mkPen("#FFF59D", width=1), movable=False), + "mid": pg.InfiniteLine(pos=0, angle=0, pen=pg.mkPen("#2ECC71", width=1, style=QtCore.Qt.DotLine), movable=False), + "high": pg.InfiniteLine(pos=0, angle=0, pen=pg.mkPen("#FF8A80", width=1), movable=False), + "extreme": pg.InfiniteLine(pos=0, angle=0, pen=pg.mkPen("#D50000", width=1), movable=False), + } + for _ln in glw._thr_lines.values(): + _ln.setVisible(False); p1.addItem(_ln) + + glw._tri = pg.TextItem("", color=(46,204,113)); glw._tri.setVisible(False); p1.addItem(glw._tri) + + # okno glw.resize(820, 520) glw.setWindowTitle(f"{vi.display_name} — pyqtgraph") glw.show() - # identyfikatory dla układarki glw._key = key glw._display_name = vi.display_name - # Rejestr okien Qt do układania + # rejestr okien if not hasattr(self, "_plot_windows_qt"): self._plot_windows_qt = [] self._plot_windows_qt.append(glw) - # Usunięcie z rejestru przy zamknięciu + # sprzątanie def _on_close(evt): + try: + t = getattr(glw, "_timer", None) + if t: t.stop(); t.deleteLater() + except Exception: + pass try: self._plot_windows_qt.remove(glw) except Exception: @@ -2609,47 +2886,106 @@ class App(tk.Tk): evt.accept() glw.closeEvent = _on_close - # Parametry rysowania - samples = int(self.default_samples_var.get()) - maxpts = int(self.default_maxpts_var.get()) - - # Timer Qt **z rodzicem** (wiąże z wątkiem GUI; nie zniknie przez GC) - glw._timer = QtCore.QTimer(glw) + # parametry rysowania + try: + samples = int(self.default_samples_var.get()) + except Exception: + samples = 100 + try: + maxpts = int(self.default_maxpts_var.get()) + except Exception: + maxpts = 500 def decimate(arr, m): - if len(arr) <= m: + if len(arr) <= m: return list(range(len(arr))), arr step = max(1, int(math.ceil(len(arr) / m))) - arr2 = arr[::step]; xs = list(range(0, len(arr), step))[:len(arr2)] + arr2 = arr[::step] + xs = list(range(0, len(arr), step))[:len(arr2)] return xs, arr2 - def update(): - if key not in self.vars: - return - vi_local = self.vars[key] - vals = list(vi_local.history)[-samples:] - dels = list(vi_local.history_delta)[-samples:] - xs_v, vals_d = decimate(vals, maxpts) - xs_b, dels_d = decimate(dels, maxpts) - curve.setData(xs_v, vals_d) - bars.setData(xs_b, dels_d) - - # Tytuł z aktualną wartością + # reentrancy guard + log + glw._in_update = False + def _log_qt(msg): try: - val_num = parse_first_float(vi_local.last_value) - glw.setWindowTitle(f"{vi_local.display_name} — {val_num:.6g}" if val_num is not None else vi_local.display_name) + with open("qt_plot_err.log", "a", encoding="utf-8") as f: + f.write(msg + "\n") except Exception: pass + def update(): + if glw._in_update: + return + glw._in_update = True + try: + vi_local = self.vars.get(key) + if not vi_local: + return + + vals = list(vi_local.history)[-samples:] + dels = list(vi_local.history_delta)[-samples:] + xs_v, vals_d = decimate(vals, maxpts) + xs_b, dels_d = decimate(dels, maxpts) + curve.setData(xs_v, vals_d) + bars.setData(xs_b, dels_d) + + # progi i wskaźnik + for _ln in glw._thr_lines.values(): + _ln.setVisible(False) + glw._tri.setVisible(False) + + show_thr = self._safe_bool(self.display_show_thresholds_var, False) + if show_thr and vals_d: + thr = self._effective_thresholds(vi_local) + + # UWAGA: bierz zakres z danych, nie z viewRange (viewRange potrafi być zmienne w trakcie resize) + ymin, ymax = min(vals_d), max(vals_d) + def within(y): return (y is not None) and (ymin <= y <= ymax) + + for thr_name in ("dead_low","low","mid","high","extreme"): + y = thr.get(thr_name) + if within(y): + glw._thr_lines[thr_name].setPos(y) + glw._thr_lines[thr_name].setVisible(True) + + cur = parse_first_float(vi_local.last_value) + candidates = [(n, thr[n]) for n in ("dead_low","low","mid","high","extreme") if thr.get(n) is not None] + if cur is not None and candidates: + k_best, y_best = min(candidates, key=lambda kv: abs(kv[1]-cur)) + color_map = {"dead_low": (251,192,45), "low": (255,245,157), "mid": (46,204,113), + "high": (255,138,128), "extreme": (213,0,0)} + col = color_map.get(k_best, (46,204,113)) + up = (y_best > cur); char = "▲" if up else "▼" + glw._tri.setColor(col); glw._tri.setText(char) + # ustaw w bezpiecznej pozycji przy lewej krawędzi, w obrębie danych + x_left = xs_v[0] if xs_v else 0 + y_pos = ymin if up else ymax + glw._tri.setPos(x_left, y_pos) + glw._tri.setVisible(True) + + # tytuł z aktualną wartością + try: + val_num = parse_first_float(vi_local.last_value) + glw.setWindowTitle(f"{vi_local.display_name} — {val_num:.6g}" if val_num is not None else vi_local.display_name) + except Exception: + pass + + except Exception as e: + _log_qt(f"[update] {type(e).__name__}: {e}") + finally: + glw._in_update = False + + # jeden QTimer – jako child okna + glw._timer = QtCore.QTimer(glw) try: ms = max(80, int(float(self.refresh_var.get()) * 1000)) except Exception: ms = 160 - glw._timer.timeout.connect(update) glw._timer.start(ms) update() + def count_plot_windows(self) -> int: n_tk = len([w for w in getattr(self, "_plot_windows", {}).values() if getattr(w, "winfo_exists", lambda: False)()]) n_qt = len([w for w in getattr(self, "_plot_windows_qt", []) if getattr(w, "isVisible", lambda: False)()]) @@ -2923,6 +3259,15 @@ def eval_user_expression(expr: str, x: float, y: float | None = None, z: float | def main() -> None: app = App() + import atexit + def _graceful_shutdown(): + try: + # jeżeli App jeszcze żyje – wywołaj on_close + # (dopasuj, jeśli trzymasz referencję gdzie indziej) + app.on_close() + except Exception: + pass + atexit.register(_graceful_shutdown) app.mainloop() if __name__ == "__main__": diff --git a/nucleares_monitor/version.nfo b/nucleares_monitor/version.nfo index 49d5957..e715196 100644 --- a/nucleares_monitor/version.nfo +++ b/nucleares_monitor/version.nfo @@ -1 +1 @@ -0.1 +1.16