diff --git a/nucleares_monitor/control_board_monitor.py b/nucleares_monitor/control_board_monitor.py index 1fd30d3..51b5e4a 100644 --- a/nucleares_monitor/control_board_monitor.py +++ b/nucleares_monitor/control_board_monitor.py @@ -11,25 +11,26 @@ What’s new: - Mouse wheel scrolling enabled (selector canvas, main variables tree, actions tree). """ +import ctypes import json import logging import math + +# --- Qt log & DPI: calm Qt on multi-monitor (optional, but helps) --- +import os import queue import re +import signal +import sys import threading -from collections import deque import time import urllib.parse import urllib.request +from collections import deque from dataclasses import dataclass, field from datetime import datetime -from typing import Dict, List, Optional, Tuple, Deque -import signal -import sys +from typing import Deque, Dict, List, Optional, Tuple - -# --- Qt log & DPI: uspokojenie Qt na multi-monitor (opcjonalne, ale pomaga) --- -import os 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") @@ -39,10 +40,11 @@ try: except ImportError: _fh = None # module not available - continue without faulthandler + def _fh_install_signal_dump(sig_name: str): - """Próbuje włączyć dump stacka pod danym sygnałem. - 1) preferuj _fh.register jeśli dostępny, - 2) w przeciwnym razie zwykły signal.signal z fallbackiem na dump_traceback. + """Tries to enable stack dump under given signal. + 1) prefer _fh.register if available, + 2) otherwise regular signal.signal with fallback to dump_traceback. """ if _fh is None: return @@ -50,121 +52,146 @@ def _fh_install_signal_dump(sig_name: str): if sig is None: return - # Najpierw spróbuj natywnego rejestru faulthandlera (jeśli istnieje w tej wersji Pythona) + # First try native faulthandler register (if it exists in this Python version) if hasattr(_fh, "register"): try: _fh.register(sig, file=sys.stderr, all_threads=True) return - except (AttributeError, OSError) as e: - logger.debug(f"Failed to register faulthandler for signal {sig_name}: {e}") + except (AttributeError, OSError): + logger.debug( + f"Failed to register faulthandler for signal {sig_name}", exc_info=True + ) - # Fallback: zwykły handler sygnału, który zrzuci stack wszystkich wątków + # Fallback: regular signal handler that will dump stack of all threads def _dump(_signo, _frame): try: _fh.dump_traceback(file=sys.stderr, all_threads=True) - except (AttributeError, OSError) as e: - logger.debug(f"Failed to dump traceback for signal {sig_name}: {e}") + except (AttributeError, OSError): + logger.debug( + f"Failed to dump traceback for signal {sig_name}", exc_info=True + ) # Log and continue - diagnostics should not crash the app - print(f"Faulthandler dump_traceback failed: {e}", file=sys.stderr) try: signal.signal(sig, _dump) except Exception as e: - # Nie udało się – trudno, po prostu odpuszczamy ten sygnał - print(f"Faulthandler signal registration failed for {sig_name}: {e}", file=sys.stderr) + # Didn't work - too bad, just give up on this signal + print( + f"Faulthandler signal registration failed for {sig_name}: {e}", + file=sys.stderr, + ) + # Configure logging first to support error reporting in other initialization -import logging logging.basicConfig( level=logging.DEBUG, - format='%(asctime)s.%(msecs)03d [%(levelname)s] %(name)s: %(message)s', - datefmt='%H:%M:%S', + format="%(asctime)s.%(msecs)03d [%(levelname)s] %(name)s: %(message)s", + datefmt="%H:%M:%S", handlers=[ - logging.FileHandler('cbm_debug.log', encoding='utf-8'), - logging.StreamHandler() - ] + logging.FileHandler("cbm_debug.log", encoding="utf-8"), + logging.StreamHandler(), + ], ) -logger = logging.getLogger('ControlBoardMonitor') +logger = logging.getLogger("ControlBoardMonitor") -# Włącz faulthandler globalnie (o ile jest) +# Enable faulthandler globally (if available) if _fh is not None: try: _fh.enable(all_threads=True) - except (AttributeError, OSError) as e: - # Nie blokuj uruchomienia aplikacji – to tylko narzędzie diagnostyczne - logger.debug(f"Failed to enable faulthandler: {e}") - # Spróbuj podczepić kilka sensownych sygnałów; ignoruj, jeśli ich nie ma na danej platformie + except (AttributeError, OSError): + # Don't block application startup - this is just a diagnostic tool + logger.debug("Failed to enable faulthandler", exc_info=True) + # Try to hook several sensible signals; ignore if they don't exist on this platform for _sig_name in ("SIGBREAK", "SIGTERM", "SIGINT"): _fh_install_signal_dump(_sig_name) -# --- koniec bezpiecznej inicjalizacji faulthandlera --- +# --- end of safe faulthandler initialization --- + # Legacy compatibility for existing debug functions def DBGL(msg: str): """Legacy debug log function - use logger.debug() instead""" logger.debug(msg) + def DBGEX(where: str): """Legacy debug exception function - use logger.exception() instead""" logger.exception(f"Exception in {where}") + + # 3) global excepthook (main thread) def _global_excepthook(exctype, value, tb): logger.critical("Unhandled exception in main thread", exc_info=(exctype, value, tb)) sys.__excepthook__(exctype, value, tb) + + sys.excepthook = _global_excepthook + # 4) thread excepthook (Python 3.8+) def _thread_excepthook(args): - logger.critical("Unhandled exception in thread", exc_info=(args.exc_type, args.exc_value, args.exc_traceback)) + logger.critical( + "Unhandled exception in thread", + exc_info=(args.exc_type, args.exc_value, args.exc_traceback), + ) + + threading.excepthook = _thread_excepthook -# 5) Qt message handler (jeśli PySide6 / pyqtgraph dostępne) +# 5) Qt message handler (if PySide6 / pyqtgraph available) try: from PySide6 import QtCore + def _qt_msg_handler(mode, ctx, message): logger.debug(f"[QT] {message}") - QtCore.qInstallMessageHandler(_qt_msg_handler) -except ImportError as e: - logger.debug(f"Qt qInstallMessageHandler not available: {e}") -# Spójna warstwa Qt z pyqtgraph (nie mieszamy bezpośrednio PyQt5/PySide6) + QtCore.qInstallMessageHandler(_qt_msg_handler) +except ImportError: + logger.debug("Qt qInstallMessageHandler not available", exc_info=True) + +# Consistent Qt layer with pyqtgraph (don't mix PyQt5/PySide6 directly) _pyqtgraph_available = False _pyqtgraph_unavailable_reason = "" try: import pyqtgraph as pg from pyqtgraph.Qt import QtCore, QtWidgets + _pyqtgraph_available = True -except ImportError as e: - logger.info(f"pyqtgraph not available: {e}") - _pyqtgraph_unavailable_reason = str(e) +except ImportError: + logger.info("pyqtgraph not available", exc_info=True) + _pyqtgraph_unavailable_reason = "Import failed" try: import tkinter as tk - from tkinter import ttk, messagebox -except ImportError as e: - logger.critical(f"Tkinter is required to run this app: {e}") - raise SystemExit("Tkinter is required to run this app.") from e + from tkinter import messagebox, ttk +except ImportError as err: + logger.critical("Tkinter is required to run this app", exc_info=True) + raise SystemExit("Tkinter is required to run this app.") from err # Optional plotting support try: HAS_MPL = True import matplotlib as mpl + mpl.rcParams["path.simplify"] = True mpl.rcParams["agg.path.chunksize"] = 10000 PLOT_SCALE = -200 - from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg - from matplotlib.figure import Figure -except ImportError as e: - logger.info(f"matplotlib not available: {e}") +except ImportError: + logger.info("matplotlib not available", exc_info=True) 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 = threading.Thread( + target=target, args=args, kwargs=kwargs, daemon=True, name=name + ) t.start() return t + # ===================== # Global configuration # ===================== @@ -176,18 +203,435 @@ REQUEST_TIMEOUT_S: float = 5.0 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'] -DEFAULT_FUNCTIONS: List[str] = ['CHEM_BORON_DOSAGE_ORDERED_RATE', 'CHEM_BORON_FILTER_ORDERED_SPEED', 'CONDENSER_CIRCULATION_PUMP_ORDERED_SPEED', 'CONDENSER_CIRCULATION_PUMP_SWITCH', 'CONDENSER_VACUUM_PUMP_MODE', 'CONDENSER_VACUUM_PUMP_START_STOP', 'COOLANT_CORE_CIRCULATION_PUMP_0_ORDERED_SPEED', 'COOLANT_CORE_CIRCULATION_PUMP_1_ORDERED_SPEED', 'COOLANT_CORE_CIRCULATION_PUMP_2_ORDERED_SPEED', 'COOLANT_SEC_CIRCULATION_PUMP_0_ORDERED_SPEED', 'COOLANT_SEC_CIRCULATION_PUMP_1_ORDERED_SPEED', 'COOLANT_SEC_CIRCULATION_PUMP_2_ORDERED_SPEED', 'CORE_BAY_1_FUEL_LOADING', 'CORE_BAY_1_HATCH', 'CORE_BAY_2_FUEL_LOADING', 'CORE_BAY_2_HATCH', 'CORE_BAY_3_FUEL_LOADING', 'CORE_BAY_3_HATCH', 'CORE_BAY_4_FUEL_LOADING', 'CORE_BAY_4_HATCH', 'CORE_BAY_5_FUEL_LOADING', 'CORE_BAY_5_HATCH', 'CORE_BAY_6_FUEL_LOADING', 'CORE_BAY_6_HATCH', 'CORE_BAY_7_FUEL_LOADING', 'CORE_BAY_7_HATCH', 'CORE_BAY_8_FUEL_LOADING', 'CORE_BAY_8_HATCH', 'CORE_BAY_9_FUEL_LOADING', 'CORE_BAY_9_HATCH', 'CORE_EMERGENCY_STOP', 'CORE_END_EMERGENCY_STOP', 'CORE_OPERATION_MODE', 'CORE_POOL_PUMP', 'CORE_SCRAM_BUTTON', 'EMERGENCY_BATTERIES_MODE', 'EMERGENCY_GENERATOR_1_MODE', 'EMERGENCY_GENERATOR_1_START_STOP', 'EMERGENCY_GENERATOR_2_MODE', 'EMERGENCY_GENERATOR_2_START_STOP', 'FREIGHT_PUMP_CONDENSER_SWITCH', 'FREIGHT_PUMP_EXTERNAL_SWITCH', 'FREIGHT_PUMP_FEEDWATER_SWITCH', 'FREIGHT_PUMP_INTERNAL_SWITCH', 'FUN_AO_SABOTAGE_ONCE', 'FUN_AO_SABOTAGE_TIME', 'FUN_BANK_ROBBERY', 'FUN_BREAKER_TRIP', 'FUN_DECREASE_INTEGRITY', 'FUN_FIRE_DRILL', 'FUN_IODINE_SPILL', 'FUN_OIL_SPILL', 'FUN_PUMP_JAM', 'FUN_REQUEST_ENABLE', 'FUN_SHOW_MESSAGE', 'FUN_TOGGLE_RANDOM_SWITCH', 'FUN_TRIGGER_AUDIT', 'FUN_WEATHER_CONTROL', 'FUN_XENON_SPILL', 'MSCV_0_OPENING_ORDERED', 'MSCV_1_OPENING_ORDERED', 'MSCV_2_OPENING_ORDERED', 'RESET_AO', 'RESISTOR_BANK_01_SWITCH', 'RESISTOR_BANK_02_SWITCH', 'RESISTOR_BANK_03_SWITCH', 'RESISTOR_BANK_04_SWITCH', 'RESISTOR_BANKS_MAIN_SWITCH', 'ROD_BANK_POS_0_ORDERED', 'ROD_BANK_POS_1_ORDERED', 'ROD_BANK_POS_2_ORDERED', 'ROD_BANK_POS_3_ORDERED', 'ROD_BANK_POS_4_ORDERED', 'ROD_BANK_POS_5_ORDERED', 'ROD_BANK_POS_6_ORDERED', 'ROD_BANK_POS_7_ORDERED', 'ROD_BANK_POS_8_ORDERED', 'RODS_ALL_POS_ORDERED', 'STEAM_EJECTOR_CONDENSER_RETURN_VALVE', 'STEAM_EJECTOR_OPERATIONAL_MOTIVE_VALVE', 'STEAM_EJECTOR_STARTUP_MOTIVE_VALVE', 'STEAM_GEN_0_VENT_SWITCH', 'STEAM_GEN_1_VENT_SWITCH', 'STEAM_GEN_2_VENT_SWITCH', 'STEAM_TURBINE_0_BYPASS_ORDERED', 'STEAM_TURBINE_1_BYPASS_ORDERED', 'STEAM_TURBINE_2_BYPASS_ORDERED', 'STEAM_TURBINE_TRIP', 'VALVE_CLOSE', 'VALVE_OFF', 'VALVE_OPEN'] +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", +] +DEFAULT_FUNCTIONS: List[str] = [ + "CHEM_BORON_DOSAGE_ORDERED_RATE", + "CHEM_BORON_FILTER_ORDERED_SPEED", + "CONDENSER_CIRCULATION_PUMP_ORDERED_SPEED", + "CONDENSER_CIRCULATION_PUMP_SWITCH", + "CONDENSER_VACUUM_PUMP_MODE", + "CONDENSER_VACUUM_PUMP_START_STOP", + "COOLANT_CORE_CIRCULATION_PUMP_0_ORDERED_SPEED", + "COOLANT_CORE_CIRCULATION_PUMP_1_ORDERED_SPEED", + "COOLANT_CORE_CIRCULATION_PUMP_2_ORDERED_SPEED", + "COOLANT_SEC_CIRCULATION_PUMP_0_ORDERED_SPEED", + "COOLANT_SEC_CIRCULATION_PUMP_1_ORDERED_SPEED", + "COOLANT_SEC_CIRCULATION_PUMP_2_ORDERED_SPEED", + "CORE_BAY_1_FUEL_LOADING", + "CORE_BAY_1_HATCH", + "CORE_BAY_2_FUEL_LOADING", + "CORE_BAY_2_HATCH", + "CORE_BAY_3_FUEL_LOADING", + "CORE_BAY_3_HATCH", + "CORE_BAY_4_FUEL_LOADING", + "CORE_BAY_4_HATCH", + "CORE_BAY_5_FUEL_LOADING", + "CORE_BAY_5_HATCH", + "CORE_BAY_6_FUEL_LOADING", + "CORE_BAY_6_HATCH", + "CORE_BAY_7_FUEL_LOADING", + "CORE_BAY_7_HATCH", + "CORE_BAY_8_FUEL_LOADING", + "CORE_BAY_8_HATCH", + "CORE_BAY_9_FUEL_LOADING", + "CORE_BAY_9_HATCH", + "CORE_EMERGENCY_STOP", + "CORE_END_EMERGENCY_STOP", + "CORE_OPERATION_MODE", + "CORE_POOL_PUMP", + "CORE_SCRAM_BUTTON", + "EMERGENCY_BATTERIES_MODE", + "EMERGENCY_GENERATOR_1_MODE", + "EMERGENCY_GENERATOR_1_START_STOP", + "EMERGENCY_GENERATOR_2_MODE", + "EMERGENCY_GENERATOR_2_START_STOP", + "FREIGHT_PUMP_CONDENSER_SWITCH", + "FREIGHT_PUMP_EXTERNAL_SWITCH", + "FREIGHT_PUMP_FEEDWATER_SWITCH", + "FREIGHT_PUMP_INTERNAL_SWITCH", + "FUN_AO_SABOTAGE_ONCE", + "FUN_AO_SABOTAGE_TIME", + "FUN_BANK_ROBBERY", + "FUN_BREAKER_TRIP", + "FUN_DECREASE_INTEGRITY", + "FUN_FIRE_DRILL", + "FUN_IODINE_SPILL", + "FUN_OIL_SPILL", + "FUN_PUMP_JAM", + "FUN_REQUEST_ENABLE", + "FUN_SHOW_MESSAGE", + "FUN_TOGGLE_RANDOM_SWITCH", + "FUN_TRIGGER_AUDIT", + "FUN_WEATHER_CONTROL", + "FUN_XENON_SPILL", + "MSCV_0_OPENING_ORDERED", + "MSCV_1_OPENING_ORDERED", + "MSCV_2_OPENING_ORDERED", + "RESET_AO", + "RESISTOR_BANK_01_SWITCH", + "RESISTOR_BANK_02_SWITCH", + "RESISTOR_BANK_03_SWITCH", + "RESISTOR_BANK_04_SWITCH", + "RESISTOR_BANKS_MAIN_SWITCH", + "ROD_BANK_POS_0_ORDERED", + "ROD_BANK_POS_1_ORDERED", + "ROD_BANK_POS_2_ORDERED", + "ROD_BANK_POS_3_ORDERED", + "ROD_BANK_POS_4_ORDERED", + "ROD_BANK_POS_5_ORDERED", + "ROD_BANK_POS_6_ORDERED", + "ROD_BANK_POS_7_ORDERED", + "ROD_BANK_POS_8_ORDERED", + "RODS_ALL_POS_ORDERED", + "STEAM_EJECTOR_CONDENSER_RETURN_VALVE", + "STEAM_EJECTOR_OPERATIONAL_MOTIVE_VALVE", + "STEAM_EJECTOR_STARTUP_MOTIVE_VALVE", + "STEAM_GEN_0_VENT_SWITCH", + "STEAM_GEN_1_VENT_SWITCH", + "STEAM_GEN_2_VENT_SWITCH", + "STEAM_TURBINE_0_BYPASS_ORDERED", + "STEAM_TURBINE_1_BYPASS_ORDERED", + "STEAM_TURBINE_2_BYPASS_ORDERED", + "STEAM_TURBINE_TRIP", + "VALVE_CLOSE", + "VALVE_OFF", + "VALVE_OPEN", +] # One global lock to serialize every HTTP call _HTTP_LOCK = threading.Lock() + # ===================== # Helpers # ===================== def build_base_url(host: str, port: int) -> str: return f"http://{host}:{port}/" + def _request(req: urllib.request.Request) -> Tuple[int, str, Dict[str, str]]: logger.debug(f"[HTTP] {req}") with _HTTP_LOCK: @@ -198,44 +642,56 @@ def _request(req: urllib.request.Request) -> Tuple[int, str, Dict[str, str]]: body_bytes = resp.read() try: body = body_bytes.decode("utf-8", errors="replace") - except UnicodeDecodeError as e: - logger.warning(f"Failed to decode response as UTF-8: {e}") + except UnicodeDecodeError: + logger.warning("Failed to decode response as UTF-8", exc_info=True) body = body_bytes.decode("latin-1", errors="replace") headers = {k.lower(): v for k, v in resp.getheaders()} return status, body, headers except urllib.error.HTTPError as e: try: body = e.read().decode("utf-8", errors="replace") - except Exception as e: + except Exception: logger.exception("Failed to decode HTTP error response as UTF-8") body = str(e) return e.code, body, dict(e.headers or {}) except Exception as e: logger.exception("HTTP request failed") - return 0, str(e), {} + def http_get(base_url: str, params: Dict[str, str]) -> Tuple[int, str, Dict[str, str]]: url = base_url + "?" + urllib.parse.urlencode(params) - req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT, "Connection": "close"}) + req = urllib.request.Request( + url, headers={"User-Agent": USER_AGENT, "Connection": "close"} + ) logger.debug(f"[HTTP] GET {url}") return _request(req) -def http_post_query(base_url: str, params: Dict[str, str]) -> Tuple[int, str, Dict[str, str]]: + +def http_post_query( + base_url: str, params: Dict[str, str] +) -> Tuple[int, str, Dict[str, str]]: # EXACT behavior requested: POST with query string, no body. url = base_url + "?" + urllib.parse.urlencode(params) - req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT, "Connection": "close"}, method="POST") + req = urllib.request.Request( + url, headers={"User-Agent": USER_AGENT, "Connection": "close"}, method="POST" + ) logger.debug(f"[HTTP] POST {url}") return _request(req) + def http_get_root(base_url: str) -> Tuple[int, str, Dict[str, str]]: - req = urllib.request.Request(base_url, headers={"User-Agent": USER_AGENT, "Connection": "close"}) + req = urllib.request.Request( + base_url, headers={"User-Agent": USER_AGENT, "Connection": "close"} + ) logger.debug(f"[HTTP] GET ROOT {base_url}") return _request(req) + def coerce_preview(value: str, maxlen: int = 80) -> str: v = value.strip().replace("\r", " ").replace("\n", " ") - return v if len(v) <= maxlen else (v[:maxlen - 1] + "…") + return v if len(v) <= maxlen else (v[: maxlen - 1] + "…") + def parse_function_names_from_html_index(html_text: str) -> List[str]: start = html_text.find("==== POST ====") @@ -251,6 +707,7 @@ def parse_function_names_from_html_index(html_text: str) -> List[str]: uniq.append(n) return uniq + def parse_variable_names_from_html_index(html_text: str) -> List[str]: tokens: List[str] = [] for m in re.finditer(r'href\s*=\s*["\']([^"\']+)["\']', html_text, flags=re.I): @@ -259,7 +716,9 @@ def parse_variable_names_from_html_index(html_text: str) -> List[str]: qs = urllib.parse.parse_qs(parsed.query) for v in qs.get("variable", []) + qs.get("Variable", []): tokens.append(v) - for m in re.finditer(r'(?:Variable|variable)\s*=\s*["\']?([A-Za-z0-9_.:-]+)', html_text, flags=re.I): + for m in re.finditer( + r'(?:Variable|variable)\s*=\s*["\']?([A-Za-z0-9_.:-]+)', html_text, flags=re.I + ): tokens.append(m.group(1)) seen = set() uniq: List[str] = [] @@ -269,13 +728,14 @@ def parse_variable_names_from_html_index(html_text: str) -> List[str]: uniq.append(v) return uniq + # ---- Parse WEBSERVER_LIST_VARIABLES_JSON ---- def parse_weblist_names(body: str) -> Tuple[List[str], List[str]]: """Return (get_list, post_list). Keep discovered case for function names.""" try: data = json.loads(body) - except (json.JSONDecodeError, ValueError) as e: - logger.warning(f"Failed to parse JSON response: {e}") + except (json.JSONDecodeError, ValueError): + logger.warning("Failed to parse JSON response", exc_info=True) return [], [] get_names: List[str] = [] post_names: List[str] = [] @@ -300,19 +760,23 @@ def parse_weblist_names(body: str) -> Tuple[List[str], List[str]]: seen.add(s) out.append(s) return out + return dedup(get_names), dedup(post_names) + def extract_names_preserve_case(value_obj) -> List[str]: out: List[str] = [] + def add(n): if isinstance(n, str) and n and n not in out: out.append(n) + if isinstance(value_obj, list): for it in value_obj: if isinstance(it, str): add(it) elif isinstance(it, dict): - for k in ("name","variable","var","id","func","function"): + for k in ("name", "variable", "var", "id", "func", "function"): if k in it and isinstance(it[k], str): add(it[k]) for k in list(it.keys()): @@ -327,14 +791,15 @@ def extract_names_preserve_case(value_obj) -> List[str]: add(m) return out + # ---- Parse WEBSERVER_BATCH_GET ---- def parse_batch_values(body: str) -> Dict[str, str]: try: data = json.loads(body) - except (json.JSONDecodeError, ValueError) as e: - logger.warning(f"Failed to parse JSON batch values: {e}") + except (json.JSONDecodeError, ValueError): + logger.warning("Failed to parse JSON batch values", exc_info=True) return {} - # Jeśli serwer owinął odpowiedź w {"values": {...}}, wyciągamy środek: + # If server wrapped response in {"values": {...}}, extract the middle: if isinstance(data, dict) and isinstance(data.get("values"), dict): data = data["values"] values: Dict[str, str] = {} @@ -344,10 +809,14 @@ def parse_batch_values(body: str) -> Dict[str, str]: k = str(k) try: values[k] = v if isinstance(v, str) else json.dumps(v) - except (TypeError, ValueError) as e: - logger.warning(f"Failed to serialize batch value for key '{k}': {e}") + except (TypeError, ValueError): + logger.warning( + f"Failed to serialize batch value for key '{k}'", exc_info=True + ) values[k] = str(v) return values + + def detect_batch_payload_type(body: str) -> str: try: data = json.loads(body) @@ -355,14 +824,15 @@ def detect_batch_payload_type(body: str) -> str: return "enveloped(values+errors)" if isinstance(data, dict): return "flat" - except (json.JSONDecodeError, ValueError) as e: - logger.debug(f"Failed to parse JSON for payload type detection: {e}") + except (json.JSONDecodeError, ValueError): + logger.debug("Failed to parse JSON for payload type detection", exc_info=True) pass return "unknown" -def eval_threshold_expr(expr: str, - stats_x: Optional[dict], - stats_y: Optional[dict], - stats_z: Optional[dict]) -> Optional[float]: + + +def eval_threshold_expr( + expr: str, stats_x: Optional[dict], stats_y: Optional[dict], stats_z: Optional[dict] +) -> Optional[float]: """ Eval progu z dostępem do: x, x_avg, dx, dx_avg, y, y_avg, dy, dy_avg, z, z_avg, dz, dz_avg. Zwraca float albo None przy błędzie. @@ -371,6 +841,7 @@ def eval_threshold_expr(expr: str, return None env: dict = {} + def push(prefix: str, d: Optional[dict]): if not d: return @@ -384,13 +855,20 @@ def eval_threshold_expr(expr: str, push("z", stats_z) try: - # jeżeli masz już safe_eval w kodzie – użyj go; inaczej zwykły eval bez builtins - val = safe_eval(expr, env) if 'safe_eval' in globals() else eval(expr, {"__builtins__": {}}, env) + # if you already have safe_eval in code - use it; otherwise regular eval without builtins + val = ( + safe_eval(expr, env) + if "safe_eval" in globals() + else eval(expr, {"__builtins__": {}}, env) + ) return float(val) - except (NameError, SyntaxError, ValueError, TypeError, ZeroDivisionError) as e: - logger.warning(f"Failed to evaluate threshold expression '{expr}': {e}") + except (NameError, SyntaxError, ValueError, TypeError, ZeroDivisionError): + logger.warning( + f"Failed to evaluate threshold expression '{expr}'", exc_info=True + ) return None + # ===================== # Data model # ===================== @@ -438,7 +916,7 @@ class Thresholds: # sources for y and z y_source: Optional[str] = None z_source: Optional[str] = None - expr_x_source_dead_low: str = "raw" # "raw" | "x_avg" | "dx" | "dx_avg" + expr_x_source_dead_low: str = "raw" # "raw" | "x_avg" | "dx" | "dx_avg" expr_x_source_low: str = "raw" expr_x_source_operating: str = "raw" expr_x_source_high: str = "raw" @@ -452,8 +930,8 @@ class Thresholds: @dataclass class VarInfo: - key: str # canonical lower-case key - display_name: str # original case as chosen + key: str # canonical lower-case key + display_name: str # original case as chosen last_value: str = "" last_updated: float = 0.0 last_status: int = 0 @@ -464,7 +942,8 @@ class VarInfo: delta_last: Optional[float] = None history: Deque[float] = field(default_factory=lambda: deque(maxlen=3600)) history_delta: Deque[float] = field(default_factory=lambda: deque(maxlen=3600)) - hist_ver: int = 0 # rośnie przy każdym append do history (optymalizacja pod ploty) + hist_ver: int = 0 # increases with each append to history (optimization for plots) + @dataclass class ActionTask: @@ -484,6 +963,7 @@ class ActionTask: z_src: Optional[str] = None x_mode: str = "raw" # "raw" | "x_avg" | "dx" | "dx_avg" + # ===================== # Scheduler thread # ===================== @@ -510,9 +990,12 @@ class ActionScheduler(threading.Thread): def remove_task(self, task_id: int) -> None: with self._lock: self.tasks.pop(task_id, None) + def remove_tasks_by_tag(self, tag: str) -> None: with self._lock: - to_del = [tid for tid, t in self.tasks.items() if getattr(t, "tag", None) == tag] + to_del = [ + tid for tid, t in self.tasks.items() if getattr(t, "tag", None) == tag + ] for tid in to_del: self.tasks.pop(tid, None) @@ -528,18 +1011,18 @@ class ActionScheduler(threading.Thread): if enabled and t.interval_s > 0: t.next_run = time.time() + t.interval_s - def run_task_once(self, task: "ActionTask") -> Tuple[int, str, Dict[str, str]]: # Compute value if expression is provided value_to_send = task.value if getattr(task, "expr", None): # fetch x/y/z using callback; None -> 0.0 for x - def gv(n): - try: + def gv(n): + try: return self.get_value_cb(n) if n else None - except Exception as e: + except Exception: logger.exception("Scheduler.run_task_once->get_value_cb") return None + # stara wersja: # x = gv(task.x_src); y = gv(task.y_src); z = gv(task.z_src) def pick(stats: Optional[dict], mode: str) -> Optional[float]: @@ -566,13 +1049,14 @@ class ActionScheduler(threading.Thread): try: computed = eval_user_expression(task.expr, x, y, z) value_to_send = str(computed) - except Exception as e: - logger.exception(f"Scheduler.run_task_once->eval_user_expression: {e}") + except Exception: + logger.exception("Scheduler.run_task_once->eval_user_expression") # Return synthetic error without posting - return 0, f"Expression error: {e}", {} + return 0, "Expression error (see logs for details)", {} base = self.get_base_url_cb() params = {"variable": task.name, "value": value_to_send} return http_post_query(base, params) + def run_once(self, name: str, value: str) -> Tuple[int, str, Dict[str, str]]: base = self.get_base_url_cb() params = {"variable": name, "value": value} @@ -605,15 +1089,15 @@ class ActionScheduler(threading.Thread): code, msg, _hdrs = self.run_task_once(t) t.last_run = time.time() if t.interval_s == 0: - # jednorazowe – wyłącz po wykonaniu + # one-time - disable after execution t.enabled = False else: t.next_run = t.last_run + max(0.0, float(t.interval_s)) - except Exception as e: + except Exception: # nie blokujemy pętli scheduler’a na wyjątkach z pojedynczego taska logger.exception("Scheduler.run->run_task_once") - # krótka, przerywalna drzemka + # short, interruptible sleep # (nie używamy time.sleep; dzięki temu zamknięcie jest natychmiastowe) self._stop.wait(0.01) @@ -649,16 +1133,20 @@ class Poller(threading.Thread): cycle_start = time.time() try: 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"}) - except Exception as e: + self.ui_queue.put( + ("cycle_start", datetime.now().strftime("%H:%M:%S")) + ) + # main read (BATCH or fallback to individual) + status, body, _ = http_get( + base_url, {"variable": "WEBSERVER_BATCH_GET"} + ) + except Exception: logger.exception("Poller.run->http_get(BATCH_GET)") status, body, _ = None, None, None if status == 200: try: values_map = parse_batch_values(body) - except Exception as e: + except Exception: logger.exception("Poller.run->parse_batch_values") values_map = None if values_map: @@ -666,9 +1154,13 @@ class Poller(threading.Thread): 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)) + self.ui_queue.put( + ("update", key, str(lower_map[key]), 200) + ) else: - self.ui_queue.put(("error", key, 206, "Not in BATCH_GET payload")) + self.ui_queue.put( + ("error", key, 206, "Not in BATCH_GET payload") + ) else: for key in list(self.variables_keys): try: @@ -676,8 +1168,10 @@ class Poller(threading.Thread): 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 as e: + self.ui_queue.put( + ("error", key, st, coerce_preview(b, 200)) + ) + except Exception: logger.exception(f"Poller.run->http_get({key})") else: for key in list(self.variables_keys): @@ -685,10 +1179,12 @@ class Poller(threading.Thread): 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 as e: + self.ui_queue.put( + ("error", key, st, coerce_preview(b, 200)) + ) + except Exception: # nigdy nie wywalamy wątku na zewnątrz - logger.exception("Poller.run(main loop)") + logger.exception("Poller.run(main loop)") # OBSŁUGA PAUZY – aktywnie czekamy, ale przerywalnie while self.paused_event.is_set() and not self.stop_event.is_set(): @@ -711,7 +1207,7 @@ class Poller(threading.Thread): # sygnalizacja zakończenia try: self.ui_queue.put(("stopped", "")) - except Exception as e: + except Exception: logger.exception("Poller.run(stop)") @@ -735,7 +1231,9 @@ class App(tk.Tk): self.paused_event = threading.Event() self.ui_queue: queue.Queue = queue.Queue() self.poller: Optional[Poller] = None - self.variables_keys: List[str] = [] # list of lower-case keys in selection order + 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() @@ -745,8 +1243,7 @@ class App(tk.Tk): self._qt_windows: Dict[str, tuple] = {} # Track Qt windows for cleanup self.scheduler = ActionScheduler( - self._get_base_url_threadsafe, - self.get_stats_for + self._get_base_url_threadsafe, self.get_stats_for ) self.scheduler.start() @@ -781,45 +1278,68 @@ class App(tk.Tk): self.port_var.trace_add("write", _update_port) ttk.Label(controls, text="Host:").grid(row=0, column=0, sticky="w") - ttk.Entry(controls, textvariable=self.host_var, width=16).grid(row=0, column=1, padx=(0, 10)) + ttk.Entry(controls, textvariable=self.host_var, width=16).grid( + row=0, column=1, padx=(0, 10) + ) ttk.Label(controls, text="Port:").grid(row=0, column=2, sticky="w") - ttk.Entry(controls, textvariable=self.port_var, width=8).grid(row=0, column=3, padx=(0, 10)) + ttk.Entry(controls, textvariable=self.port_var, width=8).grid( + row=0, column=3, padx=(0, 10) + ) ttk.Label(controls, text="Refresh (s):").grid(row=0, column=4, sticky="w") - ttk.Entry(controls, textvariable=self.refresh_var, width=8).grid(row=0, column=5, padx=(0, 10)) + ttk.Entry(controls, textvariable=self.refresh_var, width=8).grid( + row=0, column=5, padx=(0, 10) + ) ttk.Label(controls, text="Avg N:").grid(row=0, column=6, sticky="w") - ttk.Entry(controls, textvariable=self.avg_window_var, width=6).grid(row=0, column=7, padx=(0, 10)) + ttk.Entry(controls, textvariable=self.avg_window_var, width=6).grid( + row=0, column=7, padx=(0, 10) + ) self.status_lbl = ttk.Label(controls, text="Idle", foreground="#666") self.status_lbl.grid(row=0, column=8, padx=(10, 0)) btns = ttk.Frame(controls) btns.grid(row=0, column=9, padx=10, sticky="e") - ttk.Button(btns, text="Select Vars…", command=self.open_selector).grid(row=0, column=0, padx=4) - ttk.Button(btns, text="Reload (disc.)", command=self.reload_discovery).grid(row=0, column=1, padx=4) + ttk.Button(btns, text="Select Vars…", command=self.open_selector).grid( + row=0, column=0, padx=4 + ) + ttk.Button(btns, text="Reload (disc.)", command=self.reload_discovery).grid( + row=0, column=1, padx=4 + ) self.start_btn = ttk.Button(btns, text="Start", command=self.start_polling) self.start_btn.grid(row=0, column=2, padx=4) - self.pause_btn = ttk.Button(btns, text="Pause", command=self.toggle_pause, state="disabled") + self.pause_btn = ttk.Button( + btns, text="Pause", command=self.toggle_pause, state="disabled" + ) self.pause_btn.grid(row=0, column=3, padx=4) main = ttk.Panedwindow(self, orient=tk.VERTICAL) - main.pack(side=tk.TOP, fill=tk.BOTH, expand=True, padx=10, pady=(0,10)) + main.pack(side=tk.TOP, fill=tk.BOTH, expand=True, padx=10, pady=(0, 10)) search_frame = ttk.Frame(self, padding=(0, 6, 0, 6)) ttk.Label(search_frame, text="Filter:").pack(side=tk.LEFT) 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) + 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) columns = ("value", "delta", "davg", "avg", "updated", "status") - self.tree = ttk.Treeview(top_frame, columns=columns, show="tree headings", height=20, selectmode="browse") + self.tree = ttk.Treeview( + top_frame, + columns=columns, + show="tree headings", + height=20, + selectmode="browse", + ) self.tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) vsb = ttk.Scrollbar(top_frame, orient="vertical", command=self.tree.yview) vsb.pack(side=tk.RIGHT, fill=tk.Y) self.tree.configure(yscrollcommand=vsb.set) self._bind_mousewheel(self.tree) + # Thread-safe avg window update callback def _update_avg_window(*_): try: @@ -852,33 +1372,53 @@ class App(tk.Tk): style = ttk.Style(self) try: style.theme_use("clam") - except Exception as e: - logger.warning(f"Failed to set tkinter theme: {e}") + except Exception: + logger.warning("Failed to set tkinter theme", exc_info=True) # Context menu self.tree_menu = tk.Menu(self, tearoff=False) - self.tree_menu.add_command(label="Set thresholds, actions & alarms…", command=self.open_thresholds_dialog) - self.tree_menu.add_command(label="Open plot window…", command=self.open_plot_window) + self.tree_menu.add_command( + label="Set thresholds, actions & alarms…", + command=self.open_thresholds_dialog, + ) + self.tree_menu.add_command( + label="Open plot window…", command=self.open_plot_window + ) self.tree.bind("", self.on_tree_right_click) self.tree.bind("", lambda _e: self.open_plot_window()) bottom = ttk.Frame(main) - act_controls = ttk.Frame(bottom, padding=(0,6,0,6)) + act_controls = ttk.Frame(bottom, padding=(0, 6, 0, 6)) ttk.Label(act_controls, text="Function:").pack(side=tk.LEFT) self.func_name_var = tk.StringVar(value="") - self.func_combo = ttk.Combobox(act_controls, textvariable=self.func_name_var, width=44) + self.func_combo = ttk.Combobox( + act_controls, textvariable=self.func_name_var, width=44 + ) self.func_combo.pack(side=tk.LEFT, padx=6) self._attach_search_filter_to_combobox(self.func_combo, self.functions_list) ttk.Label(act_controls, text="Value:").pack(side=tk.LEFT) self.func_value_var = tk.StringVar(value="1") - ttk.Entry(act_controls, textvariable=self.func_value_var, width=10).pack(side=tk.LEFT, padx=6) - ttk.Button(act_controls, text="Run Once", command=self.run_func_once).pack(side=tk.LEFT, padx=4) - ttk.Button(act_controls, text="Schedule…", command=self.add_schedule_dialog).pack(side=tk.LEFT, padx=4) + ttk.Entry(act_controls, textvariable=self.func_value_var, width=10).pack( + side=tk.LEFT, padx=6 + ) + ttk.Button(act_controls, text="Run Once", command=self.run_func_once).pack( + side=tk.LEFT, padx=4 + ) + ttk.Button( + act_controls, text="Schedule…", command=self.add_schedule_dialog + ).pack(side=tk.LEFT, padx=4) columns2 = ("func", "value", "mode", "interval", "next", "enabled") - self.actions_tree = ttk.Treeview(bottom, columns=columns2, show="headings", height=10, selectmode="browse") - for col, hdr, w in zip(columns2, ["Function", "Value", "Mode", "Interval(s)", "Next Run", "Enabled"], [280, 160, 100, 100, 180, 80], strict=True): + self.actions_tree = ttk.Treeview( + bottom, columns=columns2, show="headings", height=10, selectmode="browse" + ) + for col, hdr, w in zip( + columns2, + ["Function", "Value", "Mode", "Interval(s)", "Next Run", "Enabled"], + [280, 160, 100, 100, 180, 80], + strict=True, + ): self.actions_tree.heading(col, text=hdr) self.actions_tree.column(col, width=w, anchor="center") @@ -897,10 +1437,16 @@ class App(tk.Tk): self.cycle_lbl.pack(side=tk.LEFT) self.protocol("WM_DELETE_WINDOW", self.on_close) - # DOMYŚLNE USTAWIENIA WYŚWIETLANIA (trzymane w tk.Variable, będą też zapisane w konfigu) - self.display_backend_var = tk.StringVar(value="matplotlib") # "matplotlib" | "canvas" | "pyqtgraph" - self.default_samples_var = tk.IntVar(value=200) # domyślna liczba próbek na wykres - self.default_maxpts_var = tk.IntVar(value=400) # ile maks. punktów realnie rysować + # DEFAULT DISPLAY SETTINGS (stored in tk.Variable, will also be saved in config) + self.display_backend_var = tk.StringVar( + value="matplotlib" + ) # "matplotlib" | "canvas" | "pyqtgraph" + self.default_samples_var = tk.IntVar( + value=200 + ) # default number of samples for the plot + self.default_maxpts_var = tk.IntVar( + value=400 + ) # max. number of points to actually draw # Menu „Opcje” -> „Display” menubar = tk.Menu(self) # <-- najpierw twórz menubar @@ -908,24 +1454,41 @@ class App(tk.Tk): opmenu = tk.Menu(menubar, tearoff=0) display_menu = tk.Menu(opmenu, tearoff=0) - display_menu.add_radiobutton(label="Backend: Matplotlib", - variable=self.display_backend_var, value="matplotlib") - display_menu.add_radiobutton(label="Backend: Canvas (lite)", - variable=self.display_backend_var, value="canvas") - display_menu.add_radiobutton(label="Backend: PyQtGraph", - variable=self.display_backend_var, value="pyqtgraph", - state=("normal" if _pyqtgraph_available else "disabled")) + display_menu.add_radiobutton( + label="Backend: Matplotlib", + variable=self.display_backend_var, + value="matplotlib", + ) + display_menu.add_radiobutton( + label="Backend: Canvas (lite)", + variable=self.display_backend_var, + value="canvas", + ) + display_menu.add_radiobutton( + label="Backend: PyQtGraph", + variable=self.display_backend_var, + value="pyqtgraph", + state=("normal" if _pyqtgraph_available else "disabled"), + ) display_menu.add_separator() - display_menu.add_command(label="Display Defaults…", - command=self.open_display_defaults_dialog) + display_menu.add_command( + label="Display Defaults…", command=self.open_display_defaults_dialog + ) # Sklej podmenu Display pod „Opcje” opmenu.add_cascade(label="Display", menu=display_menu) opmenu.add_separator() - opmenu.add_command(label="Arrange plot windows", command=self.arrange_plot_windows) - opmenu.add_command(label="Count plot windows", command=lambda: tk.messagebox.showinfo("Plots", f"Open plots: {self.count_plot_windows()}")) + opmenu.add_command( + label="Arrange plot windows", command=self.arrange_plot_windows + ) + opmenu.add_command( + label="Count plot windows", + command=lambda: tk.messagebox.showinfo( + "Plots", f"Open plots: {self.count_plot_windows()}" + ), + ) - # Reszta menubar + # Rest of menubar filemenu = tk.Menu(menubar, tearoff=False) filemenu.add_command(label="Save Configuration...", command=self.save_config) filemenu.add_command(label="Load Configuration...", command=self.load_config) @@ -934,19 +1497,25 @@ class App(tk.Tk): menubar.add_cascade(label="File", menu=filemenu) funmenu = tk.Menu(menubar, tearoff=False) - funmenu.add_command(label="Run Function Once...", command=self.menu_run_once_dialog) - funmenu.add_command(label="Add Scheduled Function...", command=self.menu_schedule_dialog) + funmenu.add_command( + label="Run Function Once...", command=self.menu_run_once_dialog + ) + funmenu.add_command( + label="Add Scheduled Function...", command=self.menu_schedule_dialog + ) funmenu.add_separator() - funmenu.add_command(label="Open Plot for Selected…", command=self.open_plot_window) + funmenu.add_command( + label="Open Plot for Selected…", command=self.open_plot_window + ) funmenu.add_separator() funmenu.add_command(label="Reload Discovery", command=self.reload_discovery) funmenu.add_command(label="Select Variables…", command=self.open_selector) menubar.add_cascade(label="Function", menu=funmenu) - # Dodaj „Opcje” do menubar na końcu - menubar.add_cascade(label="Opcje", menu=opmenu) + # Add "Options" to menubar at the end + menubar.add_cascade(label="Options", menu=opmenu) - # Podłącz menubar do okna + # Connect menubar to window self.config(menu=menubar) self.functions_list = list(DEFAULT_FUNCTIONS) @@ -976,22 +1545,30 @@ class App(tk.Tk): # Actions context menu self.actions_menu = tk.Menu(self, tearoff=False) - self.actions_menu.add_command(label="Toggle Enable/Disable", command=self.toggle_selected_task) - self.actions_menu.add_command(label="Run Now", command=self.run_selected_task_now) + self.actions_menu.add_command( + label="Toggle Enable/Disable", command=self.toggle_selected_task + ) + 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() + def _qt_ensure_app(self): """Utwórz (raz) QApplication – wspólną dla wszystkich okien Qt.""" try: from pyqtgraph.Qt import QtWidgets - except Exception as e: + except Exception: logger.exception("Qt import in _qt_ensure_app") self._qt_app = None return if getattr(self, "_qt_app", None) is None: - self._qt_app = QtWidgets.QApplication.instance() or QtWidgets.QApplication([]) + self._qt_app = QtWidgets.QApplication.instance() or QtWidgets.QApplication( + [] + ) + def _ensure_qt_pump(self): if getattr(self, "_qt_pump_on", False): return @@ -999,54 +1576,67 @@ class App(tk.Tk): self.after(16, self._pump_qt_events) def _maybe_stop_qt_pump(self): - """Wyłącz pompowanie zdarzeń Qt jeśli żadne okno Qt nie jest już widoczne.""" + """Disable Qt event pumping if no Qt window is visible anymore.""" try: has_visible_qt = False for w in getattr(self, "_plot_windows", {}).values(): if getattr(w, "_backend", "") == "pyqtgraph": - qtwin = getattr(w, "_qt_win", None) or getattr(w, "_qt_widget", None) + qtwin = getattr(w, "_qt_win", None) or getattr( + w, "_qt_widget", None + ) # Najprostsze i najpewniejsze: sprawdzamy widoczność/istnienie - if qtwin is not None and hasattr(qtwin, "isVisible") and qtwin.isVisible(): + if ( + qtwin is not None + and hasattr(qtwin, "isVisible") + and qtwin.isVisible() + ): has_visible_qt = True break if not has_visible_qt: self._qt_pump_on = False - except Exception as e: - logger.exception(f"[qt] stop-pump check error: {e!r}") - # Na wszelki wypadek nie wyłączaj pompy na błędzie + except Exception: + logger.exception("[qt] stop-pump check error") + # Just in case don't disable pump on error def _pump_qt_events(self): - """Pompowanie zdarzeń Qt z pętli Tk – bez blokowania i bez reentrancji.""" + """Qt event pumping from Tk loop - without blocking and without reentrancy.""" try: - # Jeśli nie ma okien QT – nie pompuj + # If no QT windows - don't pump if not getattr(self, "_plot_windows", None): return - if not any(getattr(w, "_backend", "") == "pyqtgraph" for w in self._plot_windows.values()): + if not any( + getattr(w, "_backend", "") == "pyqtgraph" + for w in self._plot_windows.values() + ): return - from pyqtgraph.Qt import QtWidgets, QtCore + from pyqtgraph.Qt import QtCore, QtWidgets + app = getattr(self, "_qt_app", None) or QtWidgets.QApplication.instance() if app is None: return - # Reentrancy guard – jeśli jesteśmy już w środku, odpuść + # Reentrancy guard - if we're already inside, give up if getattr(self, "_qt_pumping", False): return self._qt_pumping = True try: - # Nie blokuj; przetwórz tylko to, co już jest w kolejce + # Don't block; process only what's already in queue app.sendPostedEvents() - app.processEvents(QtCore.QEventLoop.AllEvents | QtCore.QEventLoop.DontWait) + app.processEvents( + QtCore.QEventLoop.AllEvents | QtCore.QEventLoop.DontWait + ) finally: self._qt_pumping = False - except Exception as e: + except Exception: logger.exception("_pump_qt_events") finally: - # Pętla cykliczna – ale tylko jeśli aplikacja nie zamyka się i wciąż mamy okna QT + # Cyclic loop - but only if application is not closing and we still have QT windows if not getattr(self, "_closing", False) and any( - getattr(w, "_backend", "") == "pyqtgraph" for w in getattr(self, "_plot_windows", {}).values() + getattr(w, "_backend", "") == "pyqtgraph" + for w in getattr(self, "_plot_windows", {}).values() ): # ~60 FPS max self.after(16, self._pump_qt_events) @@ -1054,7 +1644,7 @@ class App(tk.Tk): def _safe_bool(self, tkvar, default=False): try: return bool(tkvar.get()) - except Exception as e: + except Exception: logger.exception("get_safe_bool") return default @@ -1067,7 +1657,7 @@ class App(tk.Tk): if not name: return None key = str(name).lower() - + with self._data_lock: vi = self.vars.get(key) @@ -1077,7 +1667,7 @@ class App(tk.Tk): lst = list(dq)[-n:] try: return sum(lst) / float(n) - except Exception as e: + except Exception: logger.exception("get_stats_for avg") return None @@ -1091,7 +1681,7 @@ class App(tk.Tk): "dx": vi.delta_last, "dx_avg": _avg(vi.history_delta, n), } - # fallback dla zmiennych nie-monitorowanych + # fallback for non-monitored variables val = self.get_current_value(name) return {"x": val, "x_avg": None, "dx": None, "dx_avg": None} @@ -1119,7 +1709,7 @@ class App(tk.Tk): if s2 == 200 and b2: values_map = parse_batch_values(b2) if values_map: - seen=set() + seen = set() for k in values_map.keys(): kl = k.lower() if kl not in seen: @@ -1133,9 +1723,13 @@ class App(tk.Tk): s3, b3, _3 = http_get_root(base) if s3 == 200 and b3: if not discovered_vars: - discovered_vars = parse_variable_names_from_html_index(b3) or discovered_vars + discovered_vars = ( + parse_variable_names_from_html_index(b3) or discovered_vars + ) if not discovered_funcs: - discovered_funcs = parse_function_names_from_html_index(b3) or discovered_funcs + discovered_funcs = ( + parse_function_names_from_html_index(b3) or discovered_funcs + ) else: msgs.append(f"Main page HTTP {s3}: {coerce_preview(b3,120)}") @@ -1161,15 +1755,22 @@ class App(tk.Tk): messagebox.showwarning( "Discovery fallback", "Could not fully discover via LIST_VARIABLES_JSON / BATCH_GET.\n" - "Loaded embedded defaults so you can proceed.\n\n" + ("\n".join(msgs[:6]) if msgs else "")) - self.status_lbl.configure(text=f"Loaded defaults ({len(discovered_vars)} vars, {len(discovered_funcs)} funcs)") + "Loaded embedded defaults so you can proceed.\n\n" + + ("\n".join(msgs[:6]) if msgs else ""), + ) + self.status_lbl.configure( + text=f"Loaded defaults ({len(discovered_vars)} vars, {len(discovered_funcs)} funcs)" + ) else: - self.status_lbl.configure(text=f"Discovered: vars {len(discovered_vars)} | funcs {len(discovered_funcs)}") + self.status_lbl.configure( + text=f"Discovered: vars {len(discovered_vars)} | funcs {len(discovered_funcs)}" + ) # --- Select Vars dialog --- def open_selector(self) -> None: union = set([vi.display_name for vi in self.vars.values()]) or set(DEFAULT_VARS) self.show_selector_dialog(sorted(union)) + def _reschedule_state_tasks_for(self, vi: VarInfo) -> None: """ Usuwa wszystkie 'while-in-state' taski dla tej zmiennej i tworzy na nowo @@ -1188,38 +1789,54 @@ class App(tk.Tk): # 2) Zmapuj ustawienia dla stanów: mapping = { "DEAD_LOW": ( - t.action_dead_low, t.value_dead_low, t.action_dead_low_interval, - getattr(t, "expr_dead_low", None), getattr(t, "expr_target_dead_low", None) + t.action_dead_low, + t.value_dead_low, + t.action_dead_low_interval, + getattr(t, "expr_dead_low", None), + getattr(t, "expr_target_dead_low", None), ), "LOW": ( - t.action_low, t.value_low, t.action_low_interval, - getattr(t, "expr_low", None), getattr(t, "expr_target_low", None) + t.action_low, + t.value_low, + t.action_low_interval, + getattr(t, "expr_low", None), + getattr(t, "expr_target_low", None), ), "OPERATING": ( - getattr(t, "action_operating", None), getattr(t, "value_operating", "1"), + getattr(t, "action_operating", None), + getattr(t, "value_operating", "1"), getattr(t, "action_operating_interval", 1.0), - getattr(t, "expr_operating", None), getattr(t, "expr_target_operating", None) + getattr(t, "expr_operating", None), + getattr(t, "expr_target_operating", None), ), "HIGH": ( - t.action_high, t.value_high, t.action_high_interval, - getattr(t, "expr_high", None), getattr(t, "expr_target_high", None) + t.action_high, + t.value_high, + t.action_high_interval, + getattr(t, "expr_high", None), + getattr(t, "expr_target_high", None), ), "EXTREME_HIGH": ( - t.action_extreme_high, t.value_extreme_high, t.action_extreme_high_interval, - getattr(t, "expr_extreme_high", None), getattr(t, "expr_target_extreme_high", None) + t.action_extreme_high, + t.value_extreme_high, + t.action_extreme_high_interval, + getattr(t, "expr_extreme_high", None), + getattr(t, "expr_target_extreme_high", None), ), } - curr_state = vi.last_state # aktualny stan (ustawiany w evaluate_thresholds / on_state_change) :contentReference[oaicite:4]{index=4} + curr_state = ( + vi.last_state + ) # aktualny stan (ustawiany w evaluate_thresholds / on_state_change) :contentReference[oaicite:4]{index=4} if curr_state not in mapping: self.refresh_actions_tree() return name, value, interval_s, expr, expr_target = mapping[curr_state] - # Jeśli użytkownik ustawił 0 => 'run once' przy wejściu w stan: + # If user set 0 => 'run once' when entering state: # Samo przełączenie na 0 ma natychmiast skasować ew. cykliczne zadania – zrobiliśmy to wyżej. - # Nie dodajemy nowego zadania, jeśli nie ma co wykonywać. + # Don't add new task if there's nothing to execute. if not any([name, expr, expr_target]): self.refresh_actions_tree() return @@ -1228,8 +1845,12 @@ class App(tk.Tk): if expr or expr_target: # zadanie 'obliczeniowe' – liczymy x/y/z (x=monitorowana) task = ActionTask( - name=(name or expr_target or ""), # nazwa do tabeli/POST (gdy expr_target) - value=(value if name else "0"), # value nie jest używane gdy expr_target + name=( + name or expr_target or "" + ), # nazwa do tabeli/POST (gdy expr_target) + value=( + value if name else "0" + ), # value nie jest używane gdy expr_target interval_s=float(interval_s), expr=expr, x_src=vi.display_name, @@ -1250,11 +1871,13 @@ class App(tk.Tk): self.refresh_actions_tree() def show_selector_dialog(self, discovered: List[str]) -> None: - seen=set(); disp=[] + seen = set() + disp = [] for n in discovered: kl = n.lower() if kl not in seen: - seen.add(kl); disp.append(n) + seen.add(kl) + disp.append(n) dlg = tk.Toplevel(self) dlg.title("Select Variables to Monitor") @@ -1271,26 +1894,35 @@ class App(tk.Tk): ttk.Entry(top, textvariable=filt_var, width=40).pack(side=tk.LEFT, padx=6) sel_all = ttk.Button(top, text="Select All") sel_none = ttk.Button(top, text="Clear") - sel_all.pack(side=tk.LEFT, padx=4); sel_none.pack(side=tk.LEFT, padx=4) + sel_all.pack(side=tk.LEFT, padx=4) + sel_none.pack(side=tk.LEFT, padx=4) frame = ttk.Frame(dlg) frame.pack(side=tk.TOP, fill=tk.BOTH, expand=True, padx=8, pady=8) canvas = tk.Canvas(frame, borderwidth=0) vsb = ttk.Scrollbar(frame, orient="vertical", command=canvas.yview) inner = ttk.Frame(canvas) - inner.bind("", lambda e: canvas.configure(scrollregion=canvas.bbox("all"))) - canvas.create_window((0,0), window=inner, anchor="nw") + inner.bind( + "", lambda e: canvas.configure(scrollregion=canvas.bbox("all")) + ) + canvas.create_window((0, 0), window=inner, anchor="nw") canvas.configure(yscrollcommand=vsb.set) - canvas.pack(side="left", fill="both", expand=True); vsb.pack(side="right", fill="y") + canvas.pack(side="left", fill="both", expand=True) + vsb.pack(side="right", fill="y") def _mw(event): if event.delta: - canvas.yview_scroll(int(-1*(event.delta/120)), "units") + canvas.yview_scroll(int(-1 * (event.delta / 120)), "units") return "break" + def _mw_up(_e): - canvas.yview_scroll(-1, "units"); return "break" + canvas.yview_scroll(-1, "units") + return "break" + def _mw_down(_e): - canvas.yview_scroll(1, "units"); return "break" + canvas.yview_scroll(1, "units") + return "break" + canvas.bind("", _mw) inner.bind("", _mw) canvas.bind("", _mw_up) @@ -1310,28 +1942,37 @@ class App(tk.Tk): continue key = name.lower() var = tk.BooleanVar(value=temp_selected.get(key, False)) + def bind_trace(k=key, v=var): def _(*_a): temp_selected[k] = v.get() + v.trace_add("write", _) + bind_trace() cb = ttk.Checkbutton(inner, text=name, variable=var) cb.pack(anchor="w", pady=2, padx=4) rows.append((name, key, var)) + rebuild() filt_var.trace_add("write", lambda *_: rebuild()) def select_all(): for _, key, var in rows: - var.set(True); temp_selected[key] = True + var.set(True) + temp_selected[key] = True + def clear_all(): for _, key, var in rows: - var.set(False); temp_selected[key] = False + var.set(False) + temp_selected[key] = False + sel_all.configure(command=select_all) sel_none.configure(command=clear_all) bot = ttk.Frame(dlg, padding=8) bot.pack(side=tk.BOTTOM, fill=tk.X) + def apply_and_close(): selected_keys = [k for k, sel in temp_selected.items() if sel] new_order = [k for k in self.variables_keys if k in selected_keys] @@ -1354,7 +1995,10 @@ class App(tk.Tk): self.poller.variables_keys = self.variables_keys[:] self.refresh_tree() dlg.destroy() - ttk.Button(bot, text="Apply", command=apply_and_close).pack(side=tk.RIGHT, padx=4) + + ttk.Button(bot, text="Apply", command=apply_and_close).pack( + side=tk.RIGHT, padx=4 + ) ttk.Button(bot, text="Cancel", command=dlg.destroy).pack(side=tk.RIGHT, padx=4) # --- Actions UI --- @@ -1373,58 +2017,75 @@ class App(tk.Tk): return val = self.func_value_var.get() status, body, _ = self.scheduler.run_once(fname, val) - messagebox.showinfo("Run Once", f"POST ?variable={fname}&value={val}\nHTTP {status}\n{coerce_preview(body, 300)}") + messagebox.showinfo( + "Run Once", + f"POST ?variable={fname}&value={val}\nHTTP {status}\n{coerce_preview(body, 300)}", + ) def add_schedule_dialog(self): fname = self.func_name_var.get().strip() if not fname: messagebox.showinfo("No function", "Choose a function first.") return - top = tk.Toplevel(self); top.title("Schedule Function"); top.geometry("360x170"); top.transient(self); top.grab_set() - ttk.Label(top, text=f"Function: {fname}").pack(pady=(10,4)) + top = tk.Toplevel(self) + top.title("Schedule Function") + top.geometry("360x170") + top.transient(self) + top.grab_set() + ttk.Label(top, text=f"Function: {fname}").pack(pady=(10, 4)) val_var = tk.StringVar(value=self.func_value_var.get()) ttk.Label(top, text="Value:").pack() ttk.Entry(top, textvariable=val_var, width=16).pack() int_var = tk.DoubleVar(value=1.0) - ttk.Label(top, text="Interval (seconds):").pack(pady=(6,0)) + ttk.Label(top, text="Interval (seconds):").pack(pady=(6, 0)) ttk.Entry(top, textvariable=int_var, width=10).pack() def add_it(): interval = float(int_var.get()) - task = ActionTask(name=fname, value=val_var.get(), interval_s=max(0.0, interval)) - tid = self.scheduler.add_task(task) + task = ActionTask( + name=fname, value=val_var.get(), interval_s=max(0.0, interval) + ) + self.scheduler.add_task(task) self.refresh_actions_tree() top.destroy() + ttk.Button(top, text="Add", command=add_it).pack(pady=10) def toggle_selected_task(self): sel = self.actions_tree.selection() - if not sel: return + if not sel: + return tid = int(sel[0]) tasks = {t.task_id: t for t in self.scheduler.list_tasks()} - t = tasks.get(tid); - if not t: return + t = tasks.get(tid) + if not t: + return self.scheduler.set_enabled(tid, not t.enabled) self.refresh_actions_tree() def run_selected_task_now(self): sel = self.actions_tree.selection() - if not sel: return + if not sel: + return tid = int(sel[0]) tasks = {t.task_id: t for t in self.scheduler.list_tasks()} t = tasks.get(tid) - if not t: return + if not t: + return status, body, _ = self.scheduler.run_task_once(t) - messagebox.showinfo("Run Now", f"POST ?variable={t.name}&value={t.value}\nHTTP {status}\n{coerce_preview(body, 300)}") + messagebox.showinfo( + "Run Now", + f"POST ?variable={t.name}&value={t.value}\nHTTP {status}\n{coerce_preview(body, 300)}", + ) def remove_selected_task(self): sel = self.actions_tree.selection() - if not sel: return + if not sel: + return tid = int(sel[0]) self.scheduler.remove_task(tid) self.refresh_actions_tree() - def get_current_value(self, name: Optional[str]) -> Optional[float]: if not name: return None @@ -1436,6 +2097,7 @@ class App(tk.Tk): if st == 200: return parse_first_float(bd) return None + def refresh_actions_tree(self): # zapamiętaj zaznaczenie/fokus sel_before = self.actions_tree.selection() @@ -1444,27 +2106,47 @@ class App(tk.Tk): # przebuduj self.actions_tree.delete(*self.actions_tree.get_children()) for t in self.scheduler.list_tasks(): - mode = ("Interval" if t.interval_s > 0 else "Once") + (" + Expr" if getattr(t, 'expr', None) else "") - next_s = ("—" if t.interval_s == 0 else datetime.fromtimestamp(t.next_run).strftime("%H:%M:%S")) + mode = ("Interval" if t.interval_s > 0 else "Once") + ( + " + Expr" if getattr(t, "expr", None) else "" + ) + next_s = ( + "—" + if t.interval_s == 0 + else datetime.fromtimestamp(t.next_run).strftime("%H:%M:%S") + ) self.actions_tree.insert( - "", "end", iid=str(t.task_id), - values=(t.name, t.value, mode, t.interval_s, next_s, "Yes" if t.enabled else "No") + "", + "end", + iid=str(t.task_id), + values=( + t.name, + t.value, + mode, + t.interval_s, + next_s, + "Yes" if t.enabled else "No", + ), ) - # odtwórz zaznaczenie/fokus (o ile elementy wciąż istnieją) + # restore selection/focus (as long as elements still exist) if sel_before: kept = [iid for iid in sel_before if self.actions_tree.exists(iid)] if kept: self.actions_tree.selection_set(kept) if focus_before and self.actions_tree.exists(focus_before): - self.actions_tree.focus(focus_before) + self.actions_tree.focus(focus_before) + # --- [DROP-IN] jeden zegar do wszystkich backendów --------------------------- def _ensure_plot_timer(self): """Gwarantuje, że działa pojedynczy timer do odświeżania wszystkich wykresów.""" - if getattr(self, "_plot_timer", None) is None and not getattr(self, "_closing", False): + if getattr(self, "_plot_timer", None) is None and not getattr( + self, "_closing", False + ): try: - ms = int(max(100, float(self.refresh_var.get()) * 1000)) # ~10 FPS max (wg refresh) - except Exception as e: + ms = int( + max(100, float(self.refresh_var.get()) * 1000) + ) # ~10 FPS max (wg refresh) + except Exception: logger.exception("_ensure_plot_timer refresh_var") ms = 200 self._plot_timer = self.after(ms, self._plot_tick) @@ -1477,7 +2159,7 @@ class App(tk.Tk): return list(range(len(seq))), list(seq) step = max(1, int(math.ceil(len(seq) / float(max_pts)))) ys = seq[::step] - xs = list(range(0, len(seq), step))[:len(ys)] + xs = list(range(0, len(seq), step))[: len(ys)] return xs, ys def _plot_tick(self): @@ -1485,7 +2167,11 @@ class App(tk.Tk): Jeden tick: iteruje po wszystkich oknach w self._plot_windows i wywołuje odpowiednie aktualizacje dla backendu okna. """ - wins = list(getattr(self, "_plot_windows", {}).items()) if hasattr(self, "_plot_windows") else [] + wins = ( + list(getattr(self, "_plot_windows", {}).items()) + if hasattr(self, "_plot_windows") + else [] + ) if not wins: self._plot_timer = None return @@ -1505,7 +2191,7 @@ class App(tk.Tk): if qt is not None: try: vis = bool(qt.isVisible()) - except Exception as e: + except Exception: logger.exception("pyqtgraph isVisible") vis = True if vis: @@ -1513,12 +2199,12 @@ class App(tk.Tk): else: # nieznane (pomijamy) pass - except Exception as e: + except Exception: logger.exception("_plot_tick(check window alive)") # okno padło – usuwamy z rejestru try: self._plot_windows.pop(key, None) - except Exception as e: + except Exception: logger.exception("_plot_tick(remove dead window)") pass @@ -1534,13 +2220,17 @@ class App(tk.Tk): # Aktualizacja tytułu (nazwa + ostatnia wartość) – bez nadmiernego spamowania 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}" + 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: try: # Tk if hasattr(win, "title"): win.title(new_title) - except Exception as e: + except Exception: logger.exception("_plot_tick(update title Tk)") pass try: @@ -1549,7 +2239,7 @@ class App(tk.Tk): qtw = getattr(win, "_qt_widget", None) if qtw is not None and hasattr(qtw, "setWindowTitle"): qtw.setWindowTitle(new_title) - except Exception as e: + except Exception: logger.exception("_plot_tick(update title Qt)") pass win._last_title = new_title @@ -1561,55 +2251,70 @@ class App(tk.Tk): # Pobranie danych (per-okno liczba próbek i max rys. punktów) try: - n = int(getattr(win, "_sample_len_var", self.default_samples_var).get()) - except Exception as e: + n = int( + getattr(win, "_sample_len_var", self.default_samples_var).get() + ) + except Exception: logger.exception("_plot_tick(get sample length)") n = int(self.default_samples_var.get()) try: - max_pts = int(getattr(win, "_max_draw_var", self.default_maxpts_var).get()) - except Exception as e: + max_pts = int( + getattr(win, "_max_draw_var", self.default_maxpts_var).get() + ) + except Exception: logger.exception("_plot_tick(get max points)") max_pts = int(self.default_maxpts_var.get()) vals = list(vi.history)[-n:] if hasattr(vi, "history") else [] - dels = list(vi.history_delta)[-n:] if hasattr(vi, "history_delta") else [] + dels = ( + list(vi.history_delta)[-n:] if hasattr(vi, "history_delta") else [] + ) xs_v, vals_draw = self._decimate(vals, max_pts) xs_b, dels_draw = self._decimate(dels, max_pts) # Progi efektywne (odporne na None) - thr = self._effective_thresholds(vi) if hasattr(self, "_effective_thresholds") else {} + thr = ( + self._effective_thresholds(vi) + if hasattr(self, "_effective_thresholds") + else {} + ) # backend-specyficzny update b = getattr(win, "_backend", "") if b == "matplotlib": - self._plot_update_matplotlib(win, xs_v, vals_draw, xs_b, dels_draw, thr) + self._plot_update_matplotlib( + win, xs_v, vals_draw, xs_b, dels_draw, thr + ) elif b == "canvas": self._plot_update_canvas(win, xs_v, vals_draw, xs_b, dels_draw, thr) elif b == "pyqtgraph": - self._plot_update_pyqtgraph(win, xs_v, vals_draw, xs_b, dels_draw, thr) + self._plot_update_pyqtgraph( + win, xs_v, vals_draw, xs_b, dels_draw, thr + ) win._last_seen_ver = getattr(vi, "hist_ver", win._last_seen_ver) - except Exception as e: + except Exception: logger.exception("_plot_tick(one window)") continue # kolejny tick try: ms = int(max(100, float(self.refresh_var.get()) * 1000)) - except Exception as e: + except Exception: logger.exception("_plot_tick(refresh_var)") ms = 200 if not getattr(self, "_closing", False): self._plot_timer = self.after(ms, self._plot_tick) - # --- Poller control / rendering --- def start_polling(self) -> None: if hasattr(self, "poller") and self.poller and self.poller.is_alive(): messagebox.showinfo("Already running", "Polling is already active.") return if not self.variables_keys: - messagebox.showwarning("No variables", "Variable list is empty. Use Select Vars… first.") + messagebox.showwarning( + "No variables", "Variable list is empty. Use Select Vars… first." + ) return self.stop_event.clear() self.paused_event.clear() @@ -1639,6 +2344,7 @@ class App(tk.Tk): self.paused_event.set() self.pause_btn.configure(text="Resume") self.status_lbl.configure(text="Paused") + def _bind_mousewheel(self, widget): # Windows / macOS def _mw(event): @@ -1681,17 +2387,23 @@ class App(tk.Tk): vi.delta_last = curr_num - prev_num try: vi.history_delta.append(vi.delta_last) - except Exception as e: - logger.exception("Scheduler.run_task_once->get_value_cb") + except Exception: + logger.exception( + "Scheduler.run_task_once->get_value_cb" + ) pass else: vi.delta_last = None if curr_num is not None: try: vi.history.append(curr_num) - vi.hist_ver += 1 # NOWE: sygnał dla wykresów, że są nowe dane - except Exception as e: - logger.exception("Scheduler.run_task_once->get_value_cb") + vi.hist_ver += ( + 1 # NOWE: sygnał dla wykresów, że są nowe dane + ) + except Exception: + logger.exception( + "Scheduler.run_task_once->get_value_cb" + ) pass self.vars[key] = vi self.evaluate_thresholds(vi, value) @@ -1714,8 +2426,17 @@ class App(tk.Tk): keys = list(mapping.keys()) # prefer existing display names from self.vars, else use given keys with self._data_lock: - disp = [self.vars.get(k.lower()).display_name if self.vars.get(k.lower()) else k for k in keys] - self.known_variables = sorted(set(list(self.known_variables) + disp), key=str.lower) + disp = [ + ( + self.vars.get(k.lower()).display_name + if self.vars.get(k.lower()) + else k + ) + for k in keys + ] + self.known_variables = sorted( + set(list(self.known_variables) + disp), key=str.lower + ) elif kind == "stopped": self.status_lbl.configure(text="Stopped") elif kind == "batch_fmt": @@ -1741,7 +2462,6 @@ class App(tk.Tk): finally: self.tree_menu.tk_popup(event.x_root, event.y_root) - def open_plot_window(self, var_key: str | None = None): if var_key is None: sel = self.tree.selection() @@ -1750,7 +2470,9 @@ class App(tk.Tk): return iid = sel[0] if not iid.startswith("var:"): - tk.messagebox.showwarning("Plot", "Wybierz wiersz ze zmienną (nie grupę).") + tk.messagebox.showwarning( + "Plot", "Wybierz wiersz ze zmienną (nie grupę)." + ) return key = iid.split("var:", 1)[1] else: @@ -1762,22 +2484,26 @@ class App(tk.Tk): break if key not in self.vars: - tk.messagebox.showwarning("Plot", f"Zmienna '{var_key or key}' nie jest monitorowana.") + tk.messagebox.showwarning( + "Plot", f"Zmienna '{var_key or key}' nie jest monitorowana." + ) return backend = self.display_backend_var.get().lower() if backend == "canvas": - self._open_canvas_window(key); return + self._open_canvas_window(key) + return if backend == "pyqtgraph": if _pyqtgraph_available: - self._open_pyqtgraph_window(key); return - tk.messagebox.showwarning("PyQtGraph", "PyQtGraph nie jest dostępny – użyj Matplotlib lub Canvas.") + self._open_pyqtgraph_window(key) + return + tk.messagebox.showwarning( + "PyQtGraph", "PyQtGraph is not available - use Matplotlib or Canvas." + ) return # domyślnie matplotlib self._open_matplotlib_window(key) - - def open_display_defaults_dialog(self): dlg = tk.Toplevel(self) dlg.title("Display defaults") @@ -1786,39 +2512,63 @@ class App(tk.Tk): dlg.grab_set() # Backend - ttk.Label(dlg, text="Default backend:").pack(anchor="w", padx=10, pady=(10,2)) + ttk.Label(dlg, text="Default backend:").pack(anchor="w", padx=10, pady=(10, 2)) back = tk.StringVar(value=self.display_backend_var.get()) - frm = ttk.Frame(dlg); frm.pack(anchor="w", padx=10) - ttk.Radiobutton(frm, text="Matplotlib", variable=back, value="matplotlib").pack(side=tk.LEFT, padx=(0,10)) - ttk.Radiobutton(frm, text="Canvas (lite)", variable=back, value="canvas").pack(side=tk.LEFT, padx=(0,10)) + frm = ttk.Frame(dlg) + frm.pack(anchor="w", padx=10) + ttk.Radiobutton(frm, text="Matplotlib", variable=back, value="matplotlib").pack( + side=tk.LEFT, padx=(0, 10) + ) + ttk.Radiobutton(frm, text="Canvas (lite)", variable=back, value="canvas").pack( + side=tk.LEFT, padx=(0, 10) + ) state = "normal" if _pyqtgraph_available else "disabled" - ttk.Radiobutton(frm, text="PyQtGraph", variable=back, value="pyqtgraph", state=state).pack(side=tk.LEFT) + ttk.Radiobutton( + frm, text="PyQtGraph", variable=back, value="pyqtgraph", state=state + ).pack(side=tk.LEFT) # Samples / max points - box = ttk.Frame(dlg); box.pack(fill=tk.X, padx=10, pady=(12,2)) + box = ttk.Frame(dlg) + box.pack(fill=tk.X, padx=10, pady=(12, 2)) ttk.Label(box, text="Default samples:").grid(row=0, column=0, sticky="w") ds = tk.IntVar(value=int(self.default_samples_var.get())) - ttk.Spinbox(box, from_=20, to=5000, increment=10, textvariable=ds, width=6).grid(row=0, column=1, padx=6) + ttk.Spinbox( + box, from_=20, to=5000, increment=10, textvariable=ds, width=6 + ).grid(row=0, column=1, padx=6) - ttk.Label(box, text="Default max draw pts:").grid(row=1, column=0, sticky="w", pady=(6,0)) + 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)) + 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") + 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) + bar = ttk.Frame(dlg) + bar.pack(fill=tk.X, pady=12, padx=10) + def apply(): 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" - "Nowe wykresy będą używać tych domyślnych wartości.") + tk.messagebox.showinfo( + "Display defaults", + "Zapisz te ustawienia na stałe przez File → Save Configuration.\n" + "Nowe wykresy będą używać tych domyślnych wartości.", + ) + ttk.Button(bar, text="OK", command=apply).pack(side=tk.RIGHT, padx=6) ttk.Button(bar, text="Cancel", command=dlg.destroy).pack(side=tk.RIGHT) @@ -1830,9 +2580,11 @@ class App(tk.Tk): return iid = sel[0] if not iid.startswith("var:"): - messagebox.showinfo("Select variable", "Select a specific variable row (not a group).") + messagebox.showinfo( + "Select variable", "Select a specific variable row (not a group)." + ) return - key = iid.split("var:",1)[1] + key = iid.split("var:", 1)[1] vi = self.vars.get(key) if not vi: messagebox.showinfo("Missing", "Selected variable is not available.") @@ -1841,26 +2593,35 @@ class App(tk.Tk): dlg = tk.Toplevel(self) dlg.title(f"Thresholds, actions & alarms — {vi.display_name}") dlg.geometry("980x700") - dlg.transient(self); dlg.grab_set() + dlg.transient(self) + dlg.grab_set() t = vi.thresholds cur = parse_first_float(vi.last_value) auto_defaults = {} if cur is not None: - auto_defaults = {"dead_low": round(cur*0.8, 6), "low": round(cur*0.9, 6), "high": round(cur*1.1, 6), "extreme_high": round(cur*1.2, 6)} + auto_defaults = { + "dead_low": round(cur * 0.8, 6), + "low": round(cur * 0.9, 6), + "high": round(cur * 1.1, 6), + "extreme_high": round(cur * 1.2, 6), + } # Scrollable body - outer = ttk.Frame(dlg); outer.pack(fill=tk.BOTH, expand=True) + outer = ttk.Frame(dlg) + outer.pack(fill=tk.BOTH, expand=True) canvas = tk.Canvas(outer, highlightthickness=0) vsb = ttk.Scrollbar(outer, orient="vertical", command=canvas.yview) canvas.configure(yscrollcommand=vsb.set) vsb.pack(side=tk.RIGHT, fill=tk.Y) canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) grid = ttk.Frame(canvas) - win = canvas.create_window((0,0), window=grid, anchor="nw") + win = canvas.create_window((0, 0), window=grid, anchor="nw") + def _on_cfg(event=None): canvas.configure(scrollregion=canvas.bbox("all")) canvas.itemconfigure(win, width=canvas.winfo_width()) + grid.bind("", _on_cfg) canvas.bind("", _on_cfg) @@ -1873,8 +2634,11 @@ class App(tk.Tk): def mk_val(default, keyname, row): dv = auto_defaults.get(keyname) if default is None else default sv = tk.StringVar(value="" if dv is None else str(dv)) - ttk.Entry(grid, textvariable=sv, width=12).grid(row=row, column=1, sticky="w", padx=6) + ttk.Entry(grid, textvariable=sv, width=12).grid( + row=row, column=1, sticky="w", padx=6 + ) return sv + dead_low_var = mk_val(t.dead_low, "dead_low", 0) low_var = mk_val(t.low, "low", 1) high_var = mk_val(t.high, "high", 2) @@ -1885,69 +2649,136 @@ class App(tk.Tk): alarm_low = tk.BooleanVar(value=t.alarm_low) alarm_high = tk.BooleanVar(value=t.alarm_high) alarm_ext = tk.BooleanVar(value=t.alarm_extreme_high) - ttk.Checkbutton(grid, text="Dead Low", variable=alarm_dead_low).grid(row=1, column=3, sticky="w") - ttk.Checkbutton(grid, text="Low", variable=alarm_low).grid(row=2, column=3, sticky="w") - ttk.Checkbutton(grid, text="High", variable=alarm_high).grid(row=3, column=3, sticky="w") - ttk.Checkbutton(grid, text="Extreme High", variable=alarm_ext).grid(row=4, column=3, sticky="w") + ttk.Checkbutton(grid, text="Dead Low", variable=alarm_dead_low).grid( + row=1, column=3, sticky="w" + ) + ttk.Checkbutton(grid, text="Low", variable=alarm_low).grid( + row=2, column=3, sticky="w" + ) + ttk.Checkbutton(grid, text="High", variable=alarm_high).grid( + row=3, column=3, sticky="w" + ) + ttk.Checkbutton(grid, text="Extreme High", variable=alarm_ext).grid( + row=4, column=3, sticky="w" + ) # actions - ttk.Label(grid, text="On enter: call function (optional)").grid(row=6, column=0, sticky="w", pady=(12,4)) - ttk.Label(grid, text="Interval(s) while in state (0 = once)").grid(row=6, column=3, sticky="w") + ttk.Label(grid, text="On enter: call function (optional)").grid( + row=6, column=0, sticky="w", pady=(12, 4) + ) + ttk.Label(grid, text="Interval(s) while in state (0 = once)").grid( + row=6, column=3, sticky="w" + ) - rows = [("Dead Low", "action_dead_low", "value_dead_low", "action_dead_low_interval"), - ("Low", "action_low", "value_low", "action_low_interval"), - ("Operating", "action_operating", "value_operating", "action_operating_interval"), - ("High", "action_high", "value_high", "action_high_interval"), - ("Extreme High", "action_extreme_high", "value_extreme_high", "action_extreme_high_interval")] - action_vars = {}; value_vars = {}; interval_vars = {} + rows = [ + ( + "Dead Low", + "action_dead_low", + "value_dead_low", + "action_dead_low_interval", + ), + ("Low", "action_low", "value_low", "action_low_interval"), + ( + "Operating", + "action_operating", + "value_operating", + "action_operating_interval", + ), + ("High", "action_high", "value_high", "action_high_interval"), + ( + "Extreme High", + "action_extreme_high", + "value_extreme_high", + "action_extreme_high_interval", + ), + ] + action_vars = {} + value_vars = {} + interval_vars = {} for i, (label, action_key, value_key, interval_key) in enumerate(rows): - r = 7+i + r = 7 + i ttk.Label(grid, text=label).grid(row=r, column=0, sticky="w", pady=2) - s = ttk.Combobox(grid, values=self.functions_list or DEFAULT_FUNCTIONS, width=44) + s = ttk.Combobox( + grid, values=self.functions_list or DEFAULT_FUNCTIONS, width=44 + ) s.set(getattr(t, action_key) or "") s.grid(row=r, column=1, sticky="w", padx=6) self._attach_search_filter_to_combobox(s, self.functions_list) action_vars[action_key] = s sv = tk.StringVar(value=getattr(t, value_key)) - ttk.Entry(grid, textvariable=sv, width=12).grid(row=r, column=2, sticky="w", padx=6) + ttk.Entry(grid, textvariable=sv, width=12).grid( + row=r, column=2, sticky="w", padx=6 + ) value_vars[value_key] = sv - iv = tk.DoubleVar(value=getattr(t, interval_key) if getattr(t, interval_key) is not None else 1.0) - ttk.Entry(grid, textvariable=iv, width=10).grid(row=r, column=3, sticky="w", padx=6) + iv = tk.DoubleVar( + value=( + getattr(t, interval_key) + if getattr(t, interval_key) is not None + else 1.0 + ) + ) + ttk.Entry(grid, textvariable=iv, width=10).grid( + row=r, column=3, sticky="w", padx=6 + ) interval_vars[interval_key] = iv # y/z sources - ttk.Label(grid, text="y source (optional)").grid(row=13, column=0, sticky="w", pady=(14,4)) + ttk.Label(grid, text="y source (optional)").grid( + row=13, column=0, sticky="w", pady=(14, 4) + ) y_source_var = tk.StringVar(value=t.y_source or "") - y_combo = ttk.Combobox(grid, values=(self.known_variables or [vi.display_name]), textvariable=y_source_var, width=44) + y_combo = ttk.Combobox( + grid, + values=(self.known_variables or [vi.display_name]), + textvariable=y_source_var, + width=44, + ) y_combo.grid(row=13, column=1, sticky="w", padx=6) self._attach_search_filter_to_combobox(y_combo, self.known_variables) ttk.Label(grid, text="z source (optional)").grid(row=13, column=2, sticky="e") z_source_var = tk.StringVar(value=t.z_source or "") - z_combo = ttk.Combobox(grid, values=(self.known_variables or [vi.display_name]), textvariable=z_source_var, width=32) + z_combo = ttk.Combobox( + grid, + values=(self.known_variables or [vi.display_name]), + textvariable=z_source_var, + width=32, + ) z_combo.grid(row=13, column=3, sticky="w", padx=6) self._attach_search_filter_to_combobox(z_combo, self.known_variables) # expressions - ttk.Label(grid, text="On enter: evaluate expression/snippet (x, y, z available) and optionally POST to a function").grid(row=15, column=0, sticky="w", pady=(14,4), columnspan=3) - expr_rows = [("Dead Low expr", "expr_dead_low", "expr_target_dead_low"), - ("Low expr", "expr_low", "expr_target_low"), - ("Operating expr", "expr_operating", "expr_target_operating"), - ("High expr", "expr_high", "expr_target_high"), - ("Extreme High expr", "expr_extreme_high", "expr_target_extreme_high")] - expr_vars = {}; expr_target_vars = {} + ttk.Label( + grid, + text="On enter: evaluate expression/snippet (x, y, z available) and optionally POST to a function", + ).grid(row=15, column=0, sticky="w", pady=(14, 4), columnspan=3) + expr_rows = [ + ("Dead Low expr", "expr_dead_low", "expr_target_dead_low"), + ("Low expr", "expr_low", "expr_target_low"), + ("Operating expr", "expr_operating", "expr_target_operating"), + ("High expr", "expr_high", "expr_target_high"), + ("Extreme High expr", "expr_extreme_high", "expr_target_extreme_high"), + ] + expr_vars = {} + expr_target_vars = {} for i, (label, keyname, tkey) in enumerate(expr_rows): - r = 16+i + r = 16 + i ttk.Label(grid, text=label).grid(row=r, column=0, sticky="w", pady=2) expr_vars[keyname] = tk.StringVar(value=getattr(t, keyname) or "") - ttk.Entry(grid, textvariable=expr_vars[keyname], width=64).grid(row=r, column=1, sticky="w", padx=6) + ttk.Entry(grid, textvariable=expr_vars[keyname], width=64).grid( + row=r, column=1, sticky="w", padx=6 + ) ttk.Label(grid, text="→ POST to function").grid(row=r, column=2, sticky="e") - s = ttk.Combobox(grid, values=self.functions_list or DEFAULT_FUNCTIONS, width=32) + s = ttk.Combobox( + grid, values=self.functions_list or DEFAULT_FUNCTIONS, width=32 + ) s.set(getattr(t, tkey) or "") s.grid(row=r, column=3, sticky="w", padx=6) self._attach_search_filter_to_combobox(s, self.functions_list) expr_target_vars[tkey] = s ttk.Label(grid, text="x from").grid(row=r, column=4, sticky="e") - src_combo = ttk.Combobox(grid, values=["raw", "x_avg", "dx", "dx_avg"], width=8) + src_combo = ttk.Combobox( + grid, values=["raw", "x_avg", "dx", "dx_avg"], width=8 + ) # mapowanie nazwy wiersza na pole w Thresholds: statekey = { "Dead Low expr": "dead_low", @@ -1962,9 +2793,13 @@ class App(tk.Tk): expr_xsrc_vars[field_name] = src_combo # Bottom bar - btns = ttk.Frame(dlg); btns.pack(fill=tk.X, padx=10, pady=10) + btns = ttk.Frame(dlg) + btns.pack(fill=tk.X, padx=10, pady=10) + def do_save(): - def _parse_threshold_field(var_or_entry) -> tuple[Optional[float], Optional[str]]: + def _parse_threshold_field( + var_or_entry, + ) -> tuple[Optional[float], Optional[str]]: # Przyjmujemy tk.StringVar / tk.Entry / str if hasattr(var_or_entry, "get"): s = var_or_entry.get().strip() @@ -1974,63 +2809,92 @@ class App(tk.Tk): return None, None try: return float(s), None - except Exception as e: - logger.exception("App.__init__ theme_use clam") + except Exception: + logger.exception("Failed to parse float value") # potraktuj jako wyrażenie (np. "x_avg - 5") return None, s - # UŻYJ POPRAWNYCH ZMIENNYCH: + # USE CORRECT VARIABLES: dead_val, dead_expr = _parse_threshold_field(dead_low_var) - low_val, low_expr = _parse_threshold_field(low_var) + low_val, low_expr = _parse_threshold_field(low_var) high_val, high_expr = _parse_threshold_field(high_var) - ext_val, ext_expr = _parse_threshold_field(extreme_high_var) + ext_val, ext_expr = _parse_threshold_field(extreme_high_var) nt = Thresholds( - dead_low=dead_val, low=low_val, high=high_val, extreme_high=ext_val, - expr_thr_dead_low=dead_expr, expr_thr_low=low_expr, - expr_thr_high=high_expr, expr_thr_extreme_high=ext_expr, + dead_low=dead_val, + low=low_val, + high=high_val, + extreme_high=ext_val, + expr_thr_dead_low=dead_expr, + expr_thr_low=low_expr, + expr_thr_high=high_expr, + expr_thr_extreme_high=ext_expr, alarm_dead_low=bool(alarm_dead_low.get()), alarm_low=bool(alarm_low.get()), alarm_high=bool(alarm_high.get()), alarm_extreme_high=bool(alarm_ext.get()), action_dead_low=action_vars["action_dead_low"].get() or None, value_dead_low=value_vars["value_dead_low"].get(), - action_dead_low_interval=float(interval_vars["action_dead_low_interval"].get() or 1.0), + action_dead_low_interval=float( + interval_vars["action_dead_low_interval"].get() or 1.0 + ), action_low=action_vars["action_low"].get() or None, value_low=value_vars["value_low"].get(), - action_low_interval=float(interval_vars["action_low_interval"].get() or 1.0), + action_low_interval=float( + interval_vars["action_low_interval"].get() or 1.0 + ), action_operating=action_vars["action_operating"].get() or None, value_operating=value_vars["value_operating"].get(), - action_operating_interval=float(interval_vars["action_operating_interval"].get() or 1.0), + action_operating_interval=float( + interval_vars["action_operating_interval"].get() or 1.0 + ), action_high=action_vars["action_high"].get() or None, value_high=value_vars["value_high"].get(), - action_high_interval=float(interval_vars["action_high_interval"].get() or 1.0), + action_high_interval=float( + interval_vars["action_high_interval"].get() or 1.0 + ), action_extreme_high=action_vars["action_extreme_high"].get() or None, value_extreme_high=value_vars["value_extreme_high"].get(), - action_extreme_high_interval=float(interval_vars["action_extreme_high_interval"].get() or 1.0), + action_extreme_high_interval=float( + interval_vars["action_extreme_high_interval"].get() or 1.0 + ), expr_dead_low=(expr_vars["expr_dead_low"].get().strip() or None), expr_low=(expr_vars["expr_low"].get().strip() or None), expr_operating=(expr_vars["expr_operating"].get().strip() or None), expr_high=(expr_vars["expr_high"].get().strip() or None), - expr_extreme_high=(expr_vars["expr_extreme_high"].get().strip() or None), - expr_target_dead_low=(expr_target_vars["expr_target_dead_low"].get().strip() or None), - expr_target_low=(expr_target_vars["expr_target_low"].get().strip() or None), - expr_target_operating=(expr_target_vars["expr_target_operating"].get().strip() or None), - expr_target_high=(expr_target_vars["expr_target_high"].get().strip() or None), - expr_target_extreme_high=(expr_target_vars["expr_target_extreme_high"].get().strip() or None), + expr_extreme_high=( + expr_vars["expr_extreme_high"].get().strip() or None + ), + expr_target_dead_low=( + expr_target_vars["expr_target_dead_low"].get().strip() or None + ), + expr_target_low=( + expr_target_vars["expr_target_low"].get().strip() or None + ), + expr_target_operating=( + expr_target_vars["expr_target_operating"].get().strip() or None + ), + expr_target_high=( + expr_target_vars["expr_target_high"].get().strip() or None + ), + expr_target_extreme_high=( + expr_target_vars["expr_target_extreme_high"].get().strip() or None + ), y_source=(y_source_var.get().strip() or None), z_source=(z_source_var.get().strip() or None), - expr_x_source_dead_low = expr_xsrc_vars["expr_x_source_dead_low"].get(), - expr_x_source_low = expr_xsrc_vars["expr_x_source_low"].get(), - expr_x_source_operating = expr_xsrc_vars["expr_x_source_operating"].get(), - expr_x_source_high = expr_xsrc_vars["expr_x_source_high"].get(), - expr_x_source_extreme_high = expr_xsrc_vars["expr_x_source_extreme_high"].get(), + expr_x_source_dead_low=expr_xsrc_vars["expr_x_source_dead_low"].get(), + expr_x_source_low=expr_xsrc_vars["expr_x_source_low"].get(), + expr_x_source_operating=expr_xsrc_vars["expr_x_source_operating"].get(), + expr_x_source_high=expr_xsrc_vars["expr_x_source_high"].get(), + expr_x_source_extreme_high=expr_xsrc_vars[ + "expr_x_source_extreme_high" + ].get(), ) vi.thresholds = nt # Re-enter current state to refresh scheduled actions/intervals try: cur_x = parse_first_float(vi.last_value) self.on_state_change(vi, vi.last_state, vi.last_state, cur_x) - except Exception as e: + except Exception: logger.exception("App.open_thresholds_dialog->do_save on_state_change") pass dlg.destroy() @@ -2039,79 +2903,15 @@ class App(tk.Tk): ttk.Button(btns, text="Save", command=do_save).pack(side=tk.RIGHT, padx=6) ttk.Button(btns, text="Cancel", command=dlg.destroy).pack(side=tk.RIGHT) - def evaluate_thresholds(self, vi: VarInfo, value: str) -> None: - x = parse_first_float(value) - - t = vi.thresholds - # Dynamic 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)) - - thr_dead = t.dead_low - thr_low = t.low - thr_high = t.high - thr_ext = t.extreme_high - - if t.expr_thr_dead_low: - v = eval_threshold_expr(t.expr_thr_dead_low, stats_x, stats_y, stats_z) - if v is not None: thr_dead = v - if t.expr_thr_low: - v = eval_threshold_expr(t.expr_thr_low, stats_x, stats_y, stats_z) - if v is not None: thr_low = v - if t.expr_thr_high: - v = eval_threshold_expr(t.expr_thr_high, stats_x, stats_y, stats_z) - if v is not None: thr_high = v - if t.expr_thr_extreme_high: - v = eval_threshold_expr(t.expr_thr_extreme_high, stats_x, stats_y, stats_z) - if v is not None: thr_ext = v - - # Auto-defaults based on current value if thresholds are empty - cur = parse_first_float(vi.last_value) - auto_defaults = {} - if cur is not None: - auto_defaults = { - "dead_low": round(cur * 0.8, 6), - "low": round(cur * 0.9, 6), - "high": round(cur * 1.1, 6), - "extreme_high": round(cur * 1.2, 6), - } - - prev = vi.last_state - state = "UNKNOWN" - - no_thr = all(v is None for v in (thr_dead, thr_low, thr_high, thr_ext)) - if x is None or no_thr: - state = "OPERATING" - else: - dl = thr_dead if thr_dead is not None else -float("inf") - lo = thr_low if thr_low is not None else -float("inf") - hi = thr_high if thr_high is not None else float("inf") - ex = thr_ext if thr_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 - - - def on_state_change(self, vi: VarInfo, prev_state: str, new_state: str, x: Optional[float]) -> None: + def on_state_change( + self, vi: VarInfo, prev_state: str, new_state: str, x: Optional[float] + ) -> None: # stop any repeating task associated with previous state tid = self.state_tasks.pop((vi.key, prev_state), None) if tid is not None: try: self.scheduler.remove_task(tid) - except Exception as e: + except Exception: logger.exception("App.on_state_change remove_task") pass # stop any repeating expr task for previous state @@ -2119,24 +2919,16 @@ class App(tk.Tk): if etid is not None: try: self.scheduler.remove_task(etid) - except Exception as e: + except Exception: logger.exception("App.on_state_change remove_task expr") pass # then handle enter actions for new_state self.on_enter_state(vi, new_state, x) + def on_enter_state(self, vi: VarInfo, state: str, x: Optional[float]) -> None: t = vi.thresholds # Auto-defaults based on current value if thresholds are empty - cur = parse_first_float(vi.last_value) - auto_defaults = {} - if cur is not None: - auto_defaults = { - "dead_low": round(cur * 0.8, 6), - "low": round(cur * 0.9, 6), - "high": round(cur * 1.1, 6), - "extreme_high": round(cur * 1.2, 6), - } alarm_map = { "DEAD_LOW": t.alarm_dead_low, @@ -2148,48 +2940,71 @@ class App(tk.Tk): if alarm_map.get(state): try: import winsound - freq = {"DEAD_LOW": 350, "LOW": 550, "HIGH": 800, "EXTREME_HIGH": 1000}.get(state, 600) + + freq = { + "DEAD_LOW": 350, + "LOW": 550, + "HIGH": 800, + "EXTREME_HIGH": 1000, + }.get(state, 600) dur = 400 if state in ("DEAD_LOW", "EXTREME_HIGH") else 300 winsound.Beep(freq, dur) - except Exception as e: + except Exception: logger.exception("App.on_enter_state winsound.Beep") - try: self.bell() - except Exception as e: logger.exception("App.on_enter_state bell") + try: + self.bell() + except Exception: + logger.exception("App.on_enter_state bell") expr_key = { - "DEAD_LOW": "expr_dead_low", "LOW": "expr_low", - "HIGH": "expr_high", "EXTREME_HIGH": "expr_extreme_high" + "DEAD_LOW": "expr_dead_low", + "LOW": "expr_low", + "HIGH": "expr_high", + "EXTREME_HIGH": "expr_extreme_high", }.get(state) target_key = { - "DEAD_LOW": "expr_target_dead_low", "LOW": "expr_target_low", - "HIGH": "expr_target_high", "EXTREME_HIGH": "expr_target_extreme_high" + "DEAD_LOW": "expr_target_dead_low", + "LOW": "expr_target_low", + "HIGH": "expr_target_high", + "EXTREME_HIGH": "expr_target_extreme_high", }.get(state) expr = getattr(t, expr_key) if expr_key else None target_func = getattr(t, target_key) if target_key else None # Determine y/z current values (prefer explicit sources) - y_val = None; z_val = None - y_source = (t.y_source or '').strip() or None - z_source = (t.z_source or '').strip() or None + y_val = None + z_val = None + y_source = (t.y_source or "").strip() or None + z_source = (t.z_source or "").strip() or None if not y_source: if target_func: y_source = target_func else: - action_map = {"DEAD_LOW": t.action_dead_low, "LOW": t.action_low, "HIGH": t.action_high, "EXTREME_HIGH": t.action_extreme_high} + action_map = { + "DEAD_LOW": t.action_dead_low, + "LOW": t.action_low, + "HIGH": t.action_high, + "EXTREME_HIGH": t.action_extreme_high, + } y_source = action_map.get(state) - for label, src_name in (('y', y_source), ('z', z_source)): - if not src_name: continue + for label, src_name in (("y", y_source), ("z", z_source)): + if not src_name: + continue key = src_name.lower() val = None - if hasattr(self, 'latest_values') and key in self.latest_values: + if hasattr(self, "latest_values") and key in self.latest_values: val = parse_first_float(self.latest_values.get(key)) else: - base = build_base_url(self.host_var.get().strip(), int(self.port_var.get())) + base = build_base_url( + self.host_var.get().strip(), int(self.port_var.get()) + ) st, bd, _h = http_get(base, {"variable": key}) if st == 200: val = parse_first_float(bd) - if label == 'y': y_val = val - else: z_val = val + if label == "y": + y_val = val + else: + z_val = val x_variants = self.get_stats_for(vi.key) or {} mode_map = { "DEAD_LOW": t.expr_x_source_dead_low, @@ -2208,13 +3023,20 @@ class App(tk.Tk): try: computed = eval_user_expression(expr, x_for_expr, y_val, z_val) if target_func: - base = build_base_url(self.host_var.get().strip(), int(self.port_var.get())) + base = build_base_url( + self.host_var.get().strip(), int(self.port_var.get()) + ) params = {"variable": target_func, "value": str(computed)} http_post_query(base, params) else: - self.status_lbl.configure(text=f"Expr computed for {vi.display_name} [{state}], no target function set") + self.status_lbl.configure( + text=f"Expr computed for {vi.display_name} [{state}], no target function set" + ) except Exception as e: - messagebox.showwarning("Expression error", f"{vi.display_name}: expression failed on enter {state}: {e}") + messagebox.showwarning( + "Expression error", + f"{vi.display_name}: expression failed on enter {state}: {e}", + ) act_map = { "DEAD_LOW": (t.action_dead_low, t.value_dead_low), @@ -2237,7 +3059,9 @@ class App(tk.Tk): if interval <= 0.0: self.scheduler.add_task(ActionTask(name=act, value=val, interval_s=0.0)) else: - tid = self.scheduler.add_task(ActionTask(name=act, value=val, interval_s=max(0.0, interval))) + tid = self.scheduler.add_task( + ActionTask(name=act, value=val, interval_s=max(0.0, interval)) + ) self.state_tasks[(vi.key, state)] = tid # Expression scheduling while in state @@ -2264,15 +3088,27 @@ class App(tk.Tk): if expr_interval <= 0.0: # compute once on enter (already computed above in expressions block), # but ensure it posts now via scheduler so behavior is consistent - task = ActionTask(name=expr_target, value="", interval_s=0.0, expr=expr_str, - x_src=vi.key, y_src=t.y_source, z_src=t.z_source) + task = ActionTask( + name=expr_target, + value="", + interval_s=0.0, + expr=expr_str, + x_src=vi.key, + y_src=t.y_source, + z_src=t.z_source, + ) self.scheduler.add_task(task) else: # schedule repeating expression task task = ActionTask( - name=expr_target, value="", interval_s=max(0.0, float(expr_interval)), - expr=expr_str, x_src=vi.key, y_src=t.y_source, z_src=t.z_source, - x_mode=mode_map.get(state, "raw") + name=expr_target, + value="", + interval_s=max(0.0, float(expr_interval)), + expr=expr_str, + x_src=vi.key, + y_src=t.y_source, + z_src=t.z_source, + x_mode=mode_map.get(state, "raw"), ) tid = self.scheduler.add_task(task) @@ -2286,7 +3122,9 @@ class App(tk.Tk): iid = f"grp:{group}" self._group_ids[group] = iid if not self.tree.exists(iid): - self.tree.insert("", "end", iid=iid, text=group, values=("", "", ""), open=True) + self.tree.insert( + "", "end", iid=iid, text=group, values=("", "", ""), open=True + ) return iid def _ensure_var(self, group_iid: str, key: str, display_name: str) -> str: @@ -2296,7 +3134,9 @@ class App(tk.Tk): iid = f"var:{key}" self._var_ids[key] = iid if not self.tree.exists(iid): - self.tree.insert(group_iid, "end", iid=iid, text=display_name, values=("", "", "")) + self.tree.insert( + group_iid, "end", iid=iid, text=display_name, values=("", "", "") + ) return iid def _get_color_tag(self, hex_color: str) -> str: @@ -2307,12 +3147,14 @@ class App(tk.Tk): self._color_tags[hex_color] = tag try: self.tree.tag_configure(tag, background=hex_color) - except Exception as e: + except Exception: logger.exception("App._get_color_tag") pass return tag - def _effective_thresholds(self, vi:"VarInfo") -> dict: + + def _effective_thresholds(self, vi: "VarInfo") -> 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 @@ -2332,23 +3174,32 @@ class App(tk.Tk): logger.debug(f"[_thr] expr {expr} for {vi.key} -> {v}") if v is not None: return float(v) - except Exception as e: + except Exception: logger.exception("eval_threshold_expr") return float(num) if num is not None else 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 - logger.debug(f"[_thr] key={vi.key} done -> dead={dead} low={low} high={high} ext={ext} mid={mid}") + 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 + logger.debug( + f"[_thr] key={vi.key} done -> dead={dead} low={low} high={high} ext={ext} mid={mid}" + ) return {"dead_low": dead, "low": low, "high": high, "extreme": ext, "mid": mid} def evaluate_thresholds(self, vi: VarInfo, value: str) -> None: try: x = parse_first_float(value) thr = self._compute_effective_thresholds(vi) - dead, low, high, ext = thr["dead_low"], thr["low"], thr["high"], thr["extreme"] + dead, low, high, ext = ( + thr["dead_low"], + thr["low"], + thr["high"], + thr["extreme"], + ) prev = vi.last_state state = "OPERATING" @@ -2357,9 +3208,9 @@ class App(tk.Tk): 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") + 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" @@ -2376,20 +3227,25 @@ class App(tk.Tk): self.on_state_change(vi, prev, state, x) vi.last_state = state - except Exception as e: + except Exception: logger.exception("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_low = thr["low"] thr_high = thr["high"] - thr_ext = thr["extreme"] + thr_ext = thr["extreme"] try: - x = float(x_value) if x_value is not None else parse_first_float(vi.last_value) - except Exception as e: + x = ( + float(x_value) + if x_value is not None + else parse_first_float(vi.last_value) + ) + except Exception: logger.exception("_state_color parse_first_float") x = None if x is None: @@ -2406,17 +3262,19 @@ class App(tk.Tk): if thr_ext is None: thr_ext = float("inf") - def _hex(c): return "#{:02X}{:02X}{:02X}".format(*c) + 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), + 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) + G_OK = (46, 204, 113) R_LO, R_HI = (255, 138, 128), (213, 0, 0) if x <= thr_low: @@ -2430,17 +3288,20 @@ class App(tk.Tk): 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 as e: + except Exception: logger.exception("_state_color") return "#E9ECEF" - def refresh_tree(self) -> None: filt = self.filter_var.get().strip().lower() groups: Dict[str, List[VarInfo]] = {} - for key, vi in self.vars.items(): + for _, vi in self.vars.items(): text = vi.display_name - if filt and filt not in text.lower() and (not vi.last_value or filt not in vi.last_value.lower()): + if ( + filt + and filt not in text.lower() + and (not vi.last_value or filt not in vi.last_value.lower()) + ): continue prefix = text.split("_", 1)[0] if "_" in text else "MISC" groups.setdefault(prefix, []).append(vi) @@ -2460,7 +3321,9 @@ class App(tk.Tk): updated_str = "—" if vi.last_updated: - updated_str = datetime.fromtimestamp(vi.last_updated).strftime("%H:%M:%S") + updated_str = datetime.fromtimestamp(vi.last_updated).strftime( + "%H:%M:%S" + ) if vi.error: value_preview = f"[ERR] {vi.error}" @@ -2472,7 +3335,7 @@ class App(tk.Tk): if vi.delta_last is not None: try: delta_str = f"{vi.delta_last:+.6g}" - except Exception as e: + except Exception: logger.exception("_state_color delta_last") delta_str = str(vi.delta_last) @@ -2480,7 +3343,7 @@ class App(tk.Tk): davg_str = "N/A" try: n = max(1, int(self.avg_window_var.get())) - except Exception as e: + except Exception: n = AVERAGE_WINDOW_N if hasattr(vi, "history_delta") and vi.history_delta: dvals = list(vi.history_delta)[-n:] @@ -2488,7 +3351,7 @@ class App(tk.Tk): try: davg_val = sum(dvals) / float(n) davg_str = f"{davg_val:.6g}" - except Exception as e: + except Exception: logger.exception("_state_color davg_last") davg_str = "N/A" @@ -2500,7 +3363,7 @@ class App(tk.Tk): try: avg_val = sum(vals) / float(n) avg_str = f"{avg_val:.6g}" - except Exception as e: + except Exception: logger.exception("_state_color avg_last") avg_str = "N/A" @@ -2508,34 +3371,51 @@ class App(tk.Tk): color = self._state_color(vi, x) tag = self._get_color_tag(color) - self.tree.item(vid, text=vi.display_name, values=(value_preview, delta_str, davg_str, avg_str, updated_str, vi.last_status or "—"), tags=(tag,)) + self.tree.item( + vid, + text=vi.display_name, + values=( + value_preview, + delta_str, + davg_str, + avg_str, + updated_str, + vi.last_status or "—", + ), + tags=(tag,), + ) for key, vid in list(self._var_ids.items()): if vid not in valid_var_iids and self.tree.exists(vid): - self.tree.delete(vid); self._var_ids.pop(key, None) + self.tree.delete(vid) + self._var_ids.pop(key, None) for g, gid in list(self._group_ids.items()): if gid not in valid_group_iids and self.tree.exists(gid): - self.tree.delete(gid); self._group_ids.pop(g, None) + self.tree.delete(gid) + self._group_ids.pop(g, None) if sel_iid and self.tree.exists(sel_iid): self.tree.selection_set(sel_iid) - - def _attach_search_filter_to_combobox(self, combo: ttk.Combobox, source: List[str] | None): + def _attach_search_filter_to_combobox( + self, combo: ttk.Combobox, source: List[str] | None + ): if source is None: source = [] original = list(source) combo.configure(state="normal") # allow typing to filter + def on_key(_event=None): text = combo.get() vals = [v for v in original if text.lower() in v.lower()] combo["values"] = vals if vals else original + combo.bind("", on_key) def _watch_refresh_interval(self): try: val = float(self.refresh_var.get()) - except Exception as e: + except Exception: logger.exception("_watch_refresh_interval parse float") val = self._last_refresh_val if val != self._last_refresh_val: @@ -2543,117 +3423,182 @@ class App(tk.Tk): if self.poller and self.poller.is_alive(): try: self.poller.refresh_interval = max(0.1, val) - self.status_lbl.configure(text=f"Running… (refresh {self.poller.refresh_interval}s)") - except Exception as e: + self.status_lbl.configure( + text=f"Running… (refresh {self.poller.refresh_interval}s)" + ) + except Exception: logger.exception("_watch_refresh_interval set refresh_interval") pass self.after(2000, self._watch_refresh_interval) def menu_run_once_dialog(self): - top = tk.Toplevel(self); top.title("Run Function Once"); top.geometry("420x180"); top.transient(self); top.grab_set() - ttk.Label(top, text="Function:").pack(pady=(10,2)) - name = tk.StringVar(value=(self.functions_list[0] if self.functions_list else "")) + top = tk.Toplevel(self) + top.title("Run Function Once") + top.geometry("420x180") + top.transient(self) + top.grab_set() + ttk.Label(top, text="Function:").pack(pady=(10, 2)) + name = tk.StringVar( + value=(self.functions_list[0] if self.functions_list else "") + ) cmb = ttk.Combobox(top, values=self.functions_list, textvariable=name, width=44) self._attach_search_filter_to_combobox(cmb, self.functions_list) cmb.pack() - ttk.Label(top, text="Value:").pack(pady=(6,2)) + ttk.Label(top, text="Value:").pack(pady=(6, 2)) val = tk.StringVar(value="1") ttk.Entry(top, textvariable=val, width=16).pack() + def go(): fname = name.get().strip() - if not fname: return + if not fname: + return status, body, _ = self.scheduler.run_once(fname, val.get()) - messagebox.showinfo("Run Once", f"POST ?variable={fname}&value={val.get()}\nHTTP {status}\n{coerce_preview(body, 300)}") + messagebox.showinfo( + "Run Once", + f"POST ?variable={fname}&value={val.get()}\nHTTP {status}\n{coerce_preview(body, 300)}", + ) top.destroy() + ttk.Button(top, text="Run", command=go).pack(pady=10) def menu_schedule_dialog(self): - top = tk.Toplevel(self); top.title("Add Scheduled Function"); top.geometry("520x360"); top.transient(self); top.grab_set() - ttk.Label(top, text="Function:").pack(pady=(10,2)) - name = tk.StringVar(value=(self.functions_list[0] if self.functions_list else "")) + top = tk.Toplevel(self) + top.title("Add Scheduled Function") + top.geometry("520x360") + top.transient(self) + top.grab_set() + ttk.Label(top, text="Function:").pack(pady=(10, 2)) + name = tk.StringVar( + value=(self.functions_list[0] if self.functions_list else "") + ) cmb = ttk.Combobox(top, values=self.functions_list, textvariable=name, width=44) self._attach_search_filter_to_combobox(cmb, self.functions_list) cmb.pack() # Mode: Fixed vs Expression mode = tk.StringVar(value="expr") - frm = ttk.Frame(top); frm.pack(pady=(8,2)) - ttk.Radiobutton(frm, text="Fixed value", variable=mode, value="fixed").pack(side=tk.LEFT, padx=6) - ttk.Radiobutton(frm, text="Expression", variable=mode, value="expr").pack(side=tk.LEFT, padx=6) + frm = ttk.Frame(top) + frm.pack(pady=(8, 2)) + ttk.Radiobutton(frm, text="Fixed value", variable=mode, value="fixed").pack( + side=tk.LEFT, padx=6 + ) + ttk.Radiobutton(frm, text="Expression", variable=mode, value="expr").pack( + side=tk.LEFT, padx=6 + ) # Fixed value input val = tk.StringVar(value="1") - fixed_row = ttk.Frame(top); fixed_row.pack(fill=tk.X, padx=10, pady=(4,2)) + fixed_row = ttk.Frame(top) + fixed_row.pack(fill=tk.X, padx=10, pady=(4, 2)) ttk.Label(fixed_row, text="Value:").pack(side=tk.LEFT) val_entry = ttk.Entry(fixed_row, textvariable=val, width=16) val_entry.pack(side=tk.LEFT, padx=6) # Expression + sources expr = tk.StringVar(value="x") - expr_row = ttk.Frame(top); expr_row.pack(fill=tk.X, padx=10, pady=(4,2)) + expr_row = ttk.Frame(top) + expr_row.pack(fill=tk.X, padx=10, pady=(4, 2)) ttk.Label(expr_row, text="Expression (x,y,z):").pack(side=tk.LEFT) expr_entry = ttk.Entry(expr_row, textvariable=expr, width=46) expr_entry.pack(side=tk.LEFT, padx=6) # Sources row - src_row = ttk.Frame(top); src_row.pack(fill=tk.X, padx=10, pady=(4,2)) + src_row = ttk.Frame(top) + src_row.pack(fill=tk.X, padx=10, pady=(4, 2)) ttk.Label(src_row, text="x:").pack(side=tk.LEFT) x_src = tk.StringVar(value="") - x_combo = ttk.Combobox(src_row, values=self.known_variables, textvariable=x_src, width=18) + x_combo = ttk.Combobox( + src_row, values=self.known_variables, textvariable=x_src, width=18 + ) self._attach_search_filter_to_combobox(x_combo, self.known_variables) x_combo.pack(side=tk.LEFT, padx=4) ttk.Label(src_row, text="y:").pack(side=tk.LEFT) y_src = tk.StringVar(value="") - y_combo = ttk.Combobox(src_row, values=self.known_variables, textvariable=y_src, width=18) + y_combo = ttk.Combobox( + src_row, values=self.known_variables, textvariable=y_src, width=18 + ) self._attach_search_filter_to_combobox(y_combo, self.known_variables) y_combo.pack(side=tk.LEFT, padx=4) ttk.Label(src_row, text="z:").pack(side=tk.LEFT) z_src = tk.StringVar(value="") - z_combo = ttk.Combobox(src_row, values=self.known_variables, textvariable=z_src, width=18) + z_combo = ttk.Combobox( + src_row, values=self.known_variables, textvariable=z_src, width=18 + ) self._attach_search_filter_to_combobox(z_combo, self.known_variables) z_combo.pack(side=tk.LEFT, padx=4) - xmode_row = ttk.Frame(top); xmode_row.pack(fill=tk.X, padx=10, pady=(4,2)) + xmode_row = ttk.Frame(top) + xmode_row.pack(fill=tk.X, padx=10, pady=(4, 2)) ttk.Label(xmode_row, text="x from:").pack(side=tk.LEFT) x_mode = tk.StringVar(value="raw") - xmode_combo = ttk.Combobox(xmode_row, values=["raw", "x_avg", "dx", "dx_avg"], textvariable=x_mode, width=10) + xmode_combo = ttk.Combobox( + xmode_row, + values=["raw", "x_avg", "dx", "dx_avg"], + textvariable=x_mode, + width=10, + ) xmode_combo.pack(side=tk.LEFT, padx=6) # Interval input - ttk.Label(top, text="Interval (seconds, can be < 1.0):").pack(pady=(6,2)) + ttk.Label(top, text="Interval (seconds, can be < 1.0):").pack(pady=(6, 2)) interval = tk.DoubleVar(value=1.0) ttk.Entry(top, textvariable=interval, width=12).pack() + def update_mode(*_): if mode.get() == "fixed": val_entry.configure(state="normal") expr_entry.configure(state="disabled") - x_combo.configure(state="disabled"); y_combo.configure(state="disabled"); z_combo.configure(state="disabled") + x_combo.configure(state="disabled") + y_combo.configure(state="disabled") + z_combo.configure(state="disabled") else: val_entry.configure(state="disabled") expr_entry.configure(state="normal") - x_combo.configure(state="normal"); y_combo.configure(state="normal"); z_combo.configure(state="normal") - mode.trace_add("write", update_mode); update_mode() + x_combo.configure(state="normal") + y_combo.configure(state="normal") + z_combo.configure(state="normal") + + mode.trace_add("write", update_mode) + update_mode() + def go(): fname = name.get().strip() - if not fname: return + if not fname: + return if mode.get() == "fixed": - task = ActionTask(name=fname, value=val.get(), interval_s=max(0.0, float(interval.get()))) + task = ActionTask( + name=fname, + value=val.get(), + interval_s=max(0.0, float(interval.get())), + ) else: task = ActionTask( - name=fname, value="", interval_s=max(0.0, float(interval.get())), + name=fname, + value="", + interval_s=max(0.0, float(interval.get())), expr=expr.get().strip() or "x", - x_src=(x_src.get().strip() or None), y_src=(y_src.get().strip() or None), z_src=(z_src.get().strip() or None), - x_mode=x_mode.get() + x_src=(x_src.get().strip() or None), + y_src=(y_src.get().strip() or None), + z_src=(z_src.get().strip() or None), + x_mode=x_mode.get(), ) - tid = self.scheduler.add_task(task) + _tid = self.scheduler.add_task(task) # Return value not needed self.refresh_actions_tree() top.destroy() + ttk.Button(top, text="Add", command=go).pack(pady=10) def save_config(self): try: from tkinter import filedialog + cfg = { "host": self.host_var.get(), "port": int(self.port_var.get()), "refresh_interval": float(self.refresh_var.get()), "variables_keys": self.variables_keys, - "vars": {k: self._serialize_varinfo(v) for k,v in self.vars.items() if k in self.variables_keys}, - "scheduled_tasks": [self._serialize_task(t) for t in self.scheduler.list_tasks()], + "vars": { + k: self._serialize_varinfo(v) + for k, v in self.vars.items() + if k in self.variables_keys + }, + "scheduled_tasks": [ + self._serialize_task(t) for t in self.scheduler.list_tasks() + ], } cfg["display_defaults"] = { "backend": self.display_backend_var.get(), @@ -2662,9 +3607,15 @@ class App(tk.Tk): "show_thresholds": bool(self.display_show_thresholds_var.get()), } - path = filedialog.asksaveasfilename(defaultextension=".json", filetypes=[("JSON","*.json")], title="Save Configuration") - if not path: return + path = filedialog.asksaveasfilename( + defaultextension=".json", + filetypes=[("JSON", "*.json")], + title="Save Configuration", + ) + if not path: + return import json + with open(path, "w", encoding="utf-8") as f: json.dump(cfg, f, indent=2) messagebox.showinfo("Saved", f"Configuration saved to:\n{path}") @@ -2674,9 +3625,14 @@ class App(tk.Tk): def load_config(self): try: from tkinter import filedialog - path = filedialog.askopenfilename(filetypes=[("JSON","*.json")], title="Load Configuration") - if not path: return + + path = filedialog.askopenfilename( + filetypes=[("JSON", "*.json")], title="Load Configuration" + ) + if not path: + return import json + with open(path, "r", encoding="utf-8") as f: cfg = json.load(f) self.host_var.set(cfg.get("host", self.host_var.get())) @@ -2692,22 +3648,24 @@ class App(tk.Tk): dd = cfg.get("display_defaults", {}) try: self.display_backend_var.set(dd.get("backend", "matplotlib")) - except Exception as e: + except Exception: logger.exception("load_config display_backend_var") pass try: self.default_samples_var.set(int(dd.get("samples", 200))) - except Exception as e: + except Exception: logger.exception("load_config default_samples_var") pass try: self.default_maxpts_var.set(int(dd.get("max_draw_pts", 400))) - except Exception as e: + except Exception: logger.exception("load_config default_maxpts_var") pass try: - self.display_show_thresholds_var.set(bool(dd.get("show_thresholds", True))) - except Exception as e: + self.display_show_thresholds_var.set( + bool(dd.get("show_thresholds", True)) + ) + except Exception: logger.exception("load_config display_show_thresholds_var") pass @@ -2729,15 +3687,39 @@ class App(tk.Tk): return { "display_name": vi.display_name, "thresholds": { - "dead_low": t.dead_low, "low": t.low, "high": t.high, "extreme_high": t.extreme_high, - "alarm_dead_low": t.alarm_dead_low, "alarm_low": t.alarm_low, "alarm_high": t.alarm_high, "alarm_extreme_high": t.alarm_extreme_high, - "action_dead_low": t.action_dead_low, "value_dead_low": t.value_dead_low, "action_dead_low_interval": t.action_dead_low_interval, - "action_low": t.action_low, "value_low": t.value_low, "action_low_interval": t.action_low_interval, - "action_high": t.action_high, "value_high": t.value_high, "action_high_interval": t.action_high_interval, - "action_extreme_high": t.action_extreme_high, "value_extreme_high": t.value_extreme_high, "action_extreme_high_interval": t.action_extreme_high_interval, - "action_operating": t.action_operating, "value_operating": t.value_operating, "action_operating_interval": t.action_operating_interval, - "expr_dead_low": t.expr_dead_low, "expr_low": t.expr_low, "expr_operating": t.expr_operating, "expr_high": t.expr_high, "expr_extreme_high": t.expr_extreme_high, - "expr_target_dead_low": t.expr_target_dead_low, "expr_target_low": t.expr_target_low, "expr_target_operating": t.expr_target_operating, "expr_target_high": t.expr_target_high, "expr_target_extreme_high": t.expr_target_extreme_high, + "dead_low": t.dead_low, + "low": t.low, + "high": t.high, + "extreme_high": t.extreme_high, + "alarm_dead_low": t.alarm_dead_low, + "alarm_low": t.alarm_low, + "alarm_high": t.alarm_high, + "alarm_extreme_high": t.alarm_extreme_high, + "action_dead_low": t.action_dead_low, + "value_dead_low": t.value_dead_low, + "action_dead_low_interval": t.action_dead_low_interval, + "action_low": t.action_low, + "value_low": t.value_low, + "action_low_interval": t.action_low_interval, + "action_high": t.action_high, + "value_high": t.value_high, + "action_high_interval": t.action_high_interval, + "action_extreme_high": t.action_extreme_high, + "value_extreme_high": t.value_extreme_high, + "action_extreme_high_interval": t.action_extreme_high_interval, + "action_operating": t.action_operating, + "value_operating": t.value_operating, + "action_operating_interval": t.action_operating_interval, + "expr_dead_low": t.expr_dead_low, + "expr_low": t.expr_low, + "expr_operating": t.expr_operating, + "expr_high": t.expr_high, + "expr_extreme_high": t.expr_extreme_high, + "expr_target_dead_low": t.expr_target_dead_low, + "expr_target_low": t.expr_target_low, + "expr_target_operating": t.expr_target_operating, + "expr_target_high": t.expr_target_high, + "expr_target_extreme_high": t.expr_target_extreme_high, # nowości: "expr_operating_interval": getattr(t, "expr_operating_interval", 1.0), "y_source": getattr(t, "y_source", None), @@ -2751,33 +3733,57 @@ class App(tk.Tk): "expr_thr_low": t.expr_thr_low, "expr_thr_high": t.expr_thr_high, "expr_thr_extreme_high": t.expr_thr_extreme_high, - }, } + def _deserialize_varinfo(self, key: str, data: dict) -> VarInfo: name = data.get("display_name", key) td = (data or {}).get("thresholds", {}) or {} t = Thresholds( - dead_low=td.get("dead_low"), low=td.get("low"), high=td.get("high"), extreme_high=td.get("extreme_high"), - alarm_dead_low=bool(td.get("alarm_dead_low", True)), alarm_low=bool(td.get("alarm_low", False)), - alarm_high=bool(td.get("alarm_high", False)), alarm_extreme_high=bool(td.get("alarm_extreme_high", True)), - action_dead_low=td.get("action_dead_low"), value_dead_low=str(td.get("value_dead_low", "1")), action_dead_low_interval=float(td.get("action_dead_low_interval", 1.0)), - action_low=td.get("action_low"), value_low=str(td.get("value_low", "1")), action_low_interval=float(td.get("action_low_interval", 1.0)), - action_high=td.get("action_high"), value_high=str(td.get("value_high", "1")), action_high_interval=float(td.get("action_high_interval", 1.0)), - action_extreme_high=td.get("action_extreme_high"), value_extreme_high=str(td.get("value_extreme_high", "1")), action_extreme_high_interval=float(td.get("action_extreme_high_interval", 1.0)), - action_operating=td.get("action_operating"), value_operating=str(td.get("value_operating", "1")), action_operating_interval=float(td.get("action_operating_interval", 1.0)), - expr_dead_low=td.get("expr_dead_low"), expr_low=td.get("expr_low"), expr_operating=td.get("expr_operating"), - expr_high=td.get("expr_high"), expr_extreme_high=td.get("expr_extreme_high"), - expr_target_dead_low=td.get("expr_target_dead_low"), expr_target_low=td.get("expr_target_low"), - expr_target_operating=td.get("expr_target_operating"), expr_target_high=td.get("expr_target_high"), expr_target_extreme_high=td.get("expr_target_extreme_high"), + dead_low=td.get("dead_low"), + low=td.get("low"), + high=td.get("high"), + extreme_high=td.get("extreme_high"), + alarm_dead_low=bool(td.get("alarm_dead_low", True)), + alarm_low=bool(td.get("alarm_low", False)), + alarm_high=bool(td.get("alarm_high", False)), + alarm_extreme_high=bool(td.get("alarm_extreme_high", True)), + action_dead_low=td.get("action_dead_low"), + value_dead_low=str(td.get("value_dead_low", "1")), + action_dead_low_interval=float(td.get("action_dead_low_interval", 1.0)), + action_low=td.get("action_low"), + value_low=str(td.get("value_low", "1")), + action_low_interval=float(td.get("action_low_interval", 1.0)), + action_high=td.get("action_high"), + value_high=str(td.get("value_high", "1")), + action_high_interval=float(td.get("action_high_interval", 1.0)), + action_extreme_high=td.get("action_extreme_high"), + value_extreme_high=str(td.get("value_extreme_high", "1")), + action_extreme_high_interval=float( + td.get("action_extreme_high_interval", 1.0) + ), + action_operating=td.get("action_operating"), + value_operating=str(td.get("value_operating", "1")), + action_operating_interval=float(td.get("action_operating_interval", 1.0)), + expr_dead_low=td.get("expr_dead_low"), + expr_low=td.get("expr_low"), + expr_operating=td.get("expr_operating"), + expr_high=td.get("expr_high"), + expr_extreme_high=td.get("expr_extreme_high"), + expr_target_dead_low=td.get("expr_target_dead_low"), + expr_target_low=td.get("expr_target_low"), + expr_target_operating=td.get("expr_target_operating"), + expr_target_high=td.get("expr_target_high"), + expr_target_extreme_high=td.get("expr_target_extreme_high"), # nowości (wstecznie opcjonalne): expr_operating_interval=float(td.get("expr_operating_interval", 1.0)), - y_source=td.get("y_source"), z_source=td.get("z_source"), - expr_x_source_dead_low=td.get("expr_x_source_dead_low","raw"), - expr_x_source_low=td.get("expr_x_source_low","raw"), - expr_x_source_operating=td.get("expr_x_source_operating","raw"), - expr_x_source_high=td.get("expr_x_source_high","raw"), - expr_x_source_extreme_high=td.get("expr_x_source_extreme_high","raw"), + y_source=td.get("y_source"), + z_source=td.get("z_source"), + expr_x_source_dead_low=td.get("expr_x_source_dead_low", "raw"), + expr_x_source_low=td.get("expr_x_source_low", "raw"), + expr_x_source_operating=td.get("expr_x_source_operating", "raw"), + expr_x_source_high=td.get("expr_x_source_high", "raw"), + expr_x_source_extreme_high=td.get("expr_x_source_extreme_high", "raw"), expr_thr_dead_low=td.get("expr_thr_dead_low"), expr_thr_low=td.get("expr_thr_low"), expr_thr_high=td.get("expr_thr_high"), @@ -2786,48 +3792,64 @@ class App(tk.Tk): return VarInfo(key=key.lower(), display_name=name, thresholds=t) def _serialize_task(self, t: ActionTask): - return {"name": t.name, "value": t.value, "interval_s": t.interval_s, "enabled": t.enabled, - "expr": t.expr, "x_src": t.x_src, "y_src": t.y_src, "z_src": t.z_src, "x_mode": getattr(t, "x_mode", "raw")} + return { + "name": t.name, + "value": t.value, + "interval_s": t.interval_s, + "enabled": t.enabled, + "expr": t.expr, + "x_src": t.x_src, + "y_src": t.y_src, + "z_src": t.z_src, + "x_mode": getattr(t, "x_mode", "raw"), + } def _deserialize_task(self, d: dict) -> ActionTask: - return ActionTask(name=d.get("name",""), value=d.get("value","1"), - interval_s=float(d.get("interval_s", 1.0)), enabled=d.get("enabled", True), - expr=d.get("expr"), x_src=d.get("x_src"), y_src=d.get("y_src"), z_src=d.get("z_src"), - x_mode=d.get("x_mode","raw")) + return ActionTask( + name=d.get("name", ""), + value=d.get("value", "1"), + interval_s=float(d.get("interval_s", 1.0)), + enabled=d.get("enabled", True), + expr=d.get("expr"), + x_src=d.get("x_src"), + y_src=d.get("y_src"), + z_src=d.get("z_src"), + x_mode=d.get("x_mode", "raw"), + ) def on_close(self) -> None: """Enhanced resource cleanup to prevent memory leaks and ensure clean shutdown.""" # Set closing flag to prevent new operations self._closing = True - + logger.info("Application shutdown initiated") - + # 1. Stop all Qt windows and timers first (most complex cleanup) self._cleanup_qt_windows() - + # 2. Stop polling thread self._cleanup_poller() - + # 3. Stop scheduler thread self._cleanup_scheduler() - + # 4. Cancel plot timer self._cleanup_plot_timer() - + # 5. Close all plot windows self._cleanup_plot_windows() - + # 6. Clear all window references self._clear_window_references() - + logger.info("Application cleanup completed") self.destroy() def _cleanup_qt_windows(self) -> None: """Clean up Qt windows with proper error isolation.""" - if not hasattr(self, '_qt_windows') or not self._qt_windows: + if not hasattr(self, "_qt_windows") or not self._qt_windows: return - + logger.debug(f"Cleaning up {len(self._qt_windows)} Qt windows") for key, tup in list(self._qt_windows.items()): try: @@ -2843,12 +3865,12 @@ class App(tk.Tk): logger.warning(f"Invalid Qt window tuple for key {key}: {tup}") except Exception: logger.exception(f"Failed to cleanup Qt window: {key}") - + self._qt_windows.clear() def _cleanup_poller(self) -> None: """Stop poller thread with timeout.""" - poller = getattr(self, 'poller', None) + poller = getattr(self, "poller", None) if poller and poller.is_alive(): logger.debug("Stopping poller thread") self.stop_event.set() @@ -2859,7 +3881,7 @@ class App(tk.Tk): def _cleanup_scheduler(self) -> None: """Stop scheduler thread with timeout.""" - scheduler = getattr(self, 'scheduler', None) + scheduler = getattr(self, "scheduler", None) if scheduler and scheduler.is_alive(): logger.debug("Stopping scheduler thread") scheduler.stop() @@ -2869,7 +3891,7 @@ class App(tk.Tk): def _cleanup_plot_timer(self) -> None: """Cancel the plot update timer.""" - plot_timer = getattr(self, '_plot_timer', None) + plot_timer = getattr(self, "_plot_timer", None) if plot_timer: try: self.after_cancel(plot_timer) @@ -2879,13 +3901,13 @@ class App(tk.Tk): def _cleanup_plot_windows(self) -> None: """Close all plot windows with error isolation.""" - if not hasattr(self, '_plot_windows') or not self._plot_windows: + if not hasattr(self, "_plot_windows") or not self._plot_windows: return - + logger.debug(f"Cleaning up {len(self._plot_windows)} plot windows") for key, win in list(self._plot_windows.items()): try: - if win and hasattr(win, 'destroy'): + if win and hasattr(win, "destroy"): win.destroy() logger.debug(f"Destroyed plot window: {key}") except Exception: @@ -2893,9 +3915,9 @@ class App(tk.Tk): def _clear_window_references(self) -> None: """Clear all window reference dictionaries.""" - if hasattr(self, '_plot_windows'): + if hasattr(self, "_plot_windows"): self._plot_windows.clear() - if hasattr(self, '_qt_windows'): + if hasattr(self, "_qt_windows"): self._qt_windows.clear() logger.debug("Window references cleared") @@ -2908,19 +3930,36 @@ class App(tk.Tk): win._key = key win._backend = "canvas" - 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)) + 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)) + 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, - textvariable=win._sample_len_var, width=6).pack(side=tk.LEFT) + ttk.Spinbox( + tools, + from_=20, + to=5000, + increment=10, + textvariable=win._sample_len_var, + width=6, + ).pack(side=tk.LEFT) - ttk.Label(tools, text="Max draw pts:").pack(side=tk.LEFT, padx=(10,2)) + ttk.Label(tools, text="Max draw pts:").pack(side=tk.LEFT, padx=(10, 2)) win._max_draw_var = tk.IntVar(value=int(self.default_maxpts_var.get())) - ttk.Spinbox(tools, from_=100, to=5000, increment=50, - textvariable=win._max_draw_var, width=6).pack(side=tk.LEFT) + ttk.Spinbox( + tools, + from_=100, + to=5000, + increment=50, + textvariable=win._max_draw_var, + width=6, + ).pack(side=tk.LEFT) cv = tk.Canvas(win, background="white", highlightthickness=0) cv.pack(fill=tk.BOTH, expand=True) @@ -2937,13 +3976,15 @@ class App(tk.Tk): try: cv = win._tk_canvas for iid in getattr(win, "_cv_ids", []): - try: cv.delete(iid) - except Exception as e: logger.exception("_plot_update_canvas delete iid") + try: + cv.delete(iid) + except Exception: + logger.exception("_plot_update_canvas delete iid") win._cv_ids = [] W = max(10, cv.winfo_width()) H = max(10, cv.winfo_height()) - H1 = int(H*0.6) + H1 = int(H * 0.6) H2 = H - H1 # mapowanie Y (górny wykres) @@ -2951,55 +3992,92 @@ class App(tk.Tk): vmin, vmax = min(vals_draw), max(vals_draw) if vmax == vmin: vmax = vmin + 1.0 - def y1(v): return int((1.0 - (v - vmin)/(vmax - vmin)) * (H1-20)) + 10 - def x(i): return int((i / max(1, len(vals_draw)-1)) * (W-20)) + 10 + + def y1(v): + return int((1.0 - (v - vmin) / (vmax - vmin)) * (H1 - 20)) + 10 + + def x(i): + return int((i / max(1, len(vals_draw) - 1)) * (W - 20)) + 10 # polyline pts = [] for i, v in enumerate(vals_draw): - pts.append(x(i)); pts.append(y1(v)) + pts.append(x(i)) + pts.append(y1(v)) if len(pts) >= 4: win._cv_ids.append(cv.create_line(*pts, width=2)) # progi - if bool(getattr(win, "_show_thr_var", self.display_show_thresholds_var).get()): - def within(y): return y is not None and (vmin <= y <= vmax) + if bool( + getattr( + win, "_show_thr_var", self.display_show_thresholds_var + ).get() + ): + + def within(y): + return y is not None and (vmin <= y <= vmax) + colors = { - "dead_low": "#FBC02D", "low": "#FFF59D", "mid": "#2ECC71", - "high": "#FF8A80", "extreme": "#D50000", + "dead_low": "#FBC02D", + "low": "#FFF59D", + "mid": "#2ECC71", + "high": "#FF8A80", + "extreme": "#D50000", } - for name in ("dead_low","low","mid","high","extreme"): + for name in ("dead_low", "low", "mid", "high", "extreme"): y = thr.get(name) if within(y): Y = y1(y) - win._cv_ids.append(cv.create_line(10, Y, W-10, Y, dash=(4,3), fill=colors[name])) + win._cv_ids.append( + cv.create_line( + 10, Y, W - 10, Y, dash=(4, 3), fill=colors[name] + ) + ) # wskaźnik najbliższego progu cur = vals_draw[-1] - cands = [(k, thr.get(k)) for k in ("dead_low","low","mid","high","extreme") if thr.get(k) is not None] + cands = [ + (k, thr.get(k)) + for k in ("dead_low", "low", "mid", "high", "extreme") + if thr.get(k) is not None + ] if cands: - k_best, y_best = min(cands, key=lambda kv: abs(kv[1]-cur)) - up = (y_best > cur) + k_best, y_best = min(cands, key=lambda kv: abs(kv[1] - cur)) + up = y_best > cur char = "▲" if up else "▼" - col = {"dead_low": "#FBC02D","low": "#FFF59D","mid": "#2ECC71","high":"#FF8A80","extreme":"#D50000"}[k_best] - win._cv_ids.append(cv.create_text(18, 8, text=char, fill=col, anchor="nw")) + col = { + "dead_low": "#FBC02D", + "low": "#FFF59D", + "mid": "#2ECC71", + "high": "#FF8A80", + "extreme": "#D50000", + }[k_best] + win._cv_ids.append( + cv.create_text(18, 8, text=char, fill=col, anchor="nw") + ) # dolny wykres (Δ) jako słupki if dels_draw: dmin, dmax = min(dels_draw), max(dels_draw) if dmax == dmin: dmax = dmin + 1.0 - def y2(v): return H1 + 10 + int((1.0 - (v - dmin)/(dmax - dmin)) * (H2-20)) - def x2(i): return int((i / max(1, len(dels_draw)-1)) * (W-20)) + 10 - bw = max(1, int((W-20) / max(1, len(dels_draw)))) + def y2(v): + return H1 + 10 + int((1.0 - (v - dmin) / (dmax - dmin)) * (H2 - 20)) + + def x2(i): + return int((i / max(1, len(dels_draw) - 1)) * (W - 20)) + 10 + + bw = max(1, int((W - 20) / max(1, len(dels_draw)))) for i, v in enumerate(dels_draw): X = x2(i) Y = y2(v) - win._cv_ids.append(cv.create_rectangle(X, Y, X+bw, H-10, width=0)) + win._cv_ids.append( + cv.create_rectangle(X, Y, X + bw, H - 10, width=0) + ) # wskaźnik – tylko strzałka (kolor jak wyżej), rysowana nad osią # (prosto: nie liczymy „najbliższego” drugi raz – to kosmetyka) - except Exception as e: + except Exception: logger.exception("_plot_update_canvas") # --- [DROP-IN] otwieranie okna PyQtGraph (bez QTimer) ----------------------- @@ -3012,12 +4090,13 @@ class App(tk.Tk): tk.messagebox.showerror("Plot", f'Zmienna "{key}" nie jest monitorowana') return - self._qt_ensure_app() app = getattr(self, "_qt_app", None) or QtWidgets.QApplication.instance() if app is None: - tk.messagebox.showerror("PyQtGraph", "Brak QApplication – pyqtgraph nie może wystartować.") + tk.messagebox.showerror( + "PyQtGraph", "Brak QApplication – pyqtgraph nie może wystartować." + ) return # Qt window @@ -3031,7 +4110,7 @@ class App(tk.Tk): p1.showGrid(x=True, y=True, alpha=0.2) p2.showGrid(x=True, y=True, alpha=0.2) - curve = p1.plot([], [], pen=None) # ustawimy pen później przez setData + curve = p1.plot([], [], pen=None) # ustawimy pen później przez setData # BarGraphItem dla delt bars = None bars_x = [] @@ -3039,30 +4118,46 @@ class App(tk.Tk): # Linie progów (InfiniteLine) thr_lines = { - "dead_low": pg.InfiniteLine(angle=0, pen=pg.mkPen("#FBC02D", width=2, style=QtCore.Qt.DashLine)), - "low": pg.InfiniteLine(angle=0, pen=pg.mkPen("#FFF59D", width=2, style=QtCore.Qt.DashLine)), - "mid": pg.InfiniteLine(angle=0, pen=pg.mkPen("#2ECC71", width=1, style=QtCore.Qt.DotLine)), - "high": pg.InfiniteLine(angle=0, pen=pg.mkPen("#FF8A80", width=2, style=QtCore.Qt.DashLine)), - "extreme": pg.InfiniteLine(angle=0, pen=pg.mkPen("#D50000", width=2, style=QtCore.Qt.DashLine)), + "dead_low": pg.InfiniteLine( + angle=0, pen=pg.mkPen("#FBC02D", width=2, style=QtCore.Qt.DashLine) + ), + "low": pg.InfiniteLine( + angle=0, pen=pg.mkPen("#FFF59D", width=2, style=QtCore.Qt.DashLine) + ), + "mid": pg.InfiniteLine( + angle=0, pen=pg.mkPen("#2ECC71", width=1, style=QtCore.Qt.DotLine) + ), + "high": pg.InfiniteLine( + angle=0, pen=pg.mkPen("#FF8A80", width=2, style=QtCore.Qt.DashLine) + ), + "extreme": pg.InfiniteLine( + angle=0, pen=pg.mkPen("#D50000", width=2, style=QtCore.Qt.DashLine) + ), } for ln in thr_lines.values(): - ln.setVisible(False); p1.addItem(ln) + ln.setVisible(False) + p1.addItem(ln) # Wskaźnik najbliższego progu: TextItem po lewej - tri_val = pg.TextItem("", anchor=(0,0)) - tri_delta = pg.TextItem("", anchor=(0,1)) - tri_val.setColor("#2ECC71"); tri_delta.setColor("#2ECC71") - tri_val.setVisible(False); tri_delta.setVisible(False) - p1.addItem(tri_val); p2.addItem(tri_delta) + tri_val = pg.TextItem("", anchor=(0, 0)) + tri_delta = pg.TextItem("", anchor=(0, 1)) + tri_val.setColor("#2ECC71") + tri_delta.setColor("#2ECC71") + tri_val.setVisible(False) + tri_delta.setVisible(False) + p1.addItem(tri_val) + p2.addItem(tri_delta) # wrapper (bez Tk toplevel – tylko referencje i ustawienia) class _QtHandle: pass + win = _QtHandle() win._backend = "pyqtgraph" win._key = key win._qt_widget = glw - win._p1 = p1; win._p2 = p2 + win._p1 = p1 + win._p2 = p2 win._curve = curve win._bars = bars win._bars_x = bars_x @@ -3072,13 +4167,15 @@ class App(tk.Tk): win._tri_delta = tri_delta win._sample_len_var = tk.IntVar(value=int(self.default_samples_var.get())) win._max_draw_var = tk.IntVar(value=int(self.default_maxpts_var.get())) - win._show_thr_var = tk.BooleanVar(value=bool(self.display_show_thresholds_var.get())) + win._show_thr_var = tk.BooleanVar( + value=bool(self.display_show_thresholds_var.get()) + ) win._last_seen_ver = -1 win._last_title = None self._plot_windows[key] = win - self._ensure_qt_pump() # włącz pompowanie zdarzeń Qt w pętli Tk + self._ensure_qt_pump() # enable Qt event pumping in Tk loop self._ensure_plot_timer() # --- [DROP-IN] aktualizacja PyQtGraph --------------------------------------- @@ -3088,24 +4185,25 @@ class App(tk.Tk): if w is None or not hasattr(w, "isVisible") or not w.isVisible(): return - p1 = win._p1; p2 = win._p2 + p1 = win._p1 + p2 = win._p2 # krzywa wartości if vals_draw: if win._curve.opts["pen"] is None: win._curve.setPen(pg.mkPen(width=2)) win._curve.setData(xs_v, vals_draw) - + p1.setXRange(0, max(1, (xs_v[-1] if xs_v else 1)), padding=0.02) vmin, vmax = min(vals_draw), max(vals_draw) vmin, vmax = min(vals_draw), max(vals_draw) span = (vmax - vmin) or 1.0 - p1.setYRange(vmin - 0.03*span, vmax + 0.03*span, padding=0.1) + p1.setYRange(vmin - 0.03 * span, vmax + 0.03 * span, padding=0.1) # --- delty (słupki) --- if dels_draw: dmin, dmax = min(dels_draw), max(dels_draw) dspan = (dmax - dmin) or 1.0 - p2.setYRange(dmin - 0.03*dspan, dmax + 0.03*dspan, padding=0.0) + p2.setYRange(dmin - 0.03 * dspan, dmax + 0.03 * dspan, padding=0.0) # len(dels_draw) == len(xs_b) if win._bars is None: @@ -3118,23 +4216,35 @@ class App(tk.Tk): # aktualizuj szerokość słupków n = max(1, len(xs_b)) - win._bars_w = max(1.0, float(len(xs_b)) / n) # symboliczne – pyqtgraph skaluje z X + win._bars_w = max( + 1.0, float(len(xs_b)) / n + ) # symboliczne – pyqtgraph skaluje z X try: win._bars.setOpts(width=1.0) # stała, bo mamy indeksy jako X - except Exception as e: + except Exception: logger.exception("_plot_update_pyqtgraph set bar width") # --- progi + wskaźnik --- for ln in win._thr_lines.values(): ln.setVisible(False) - win._tri_val.setVisible(False); win._tri_delta.setVisible(False) + win._tri_val.setVisible(False) + win._tri_delta.setVisible(False) - if bool(getattr(win, "_show_thr_var", self.display_show_thresholds_var).get()) and vals_draw: + if ( + bool( + getattr( + win, "_show_thr_var", self.display_show_thresholds_var + ).get() + ) + and vals_draw + ): # linie pokaż tylko, jeśli są w zakresie Y aktualnego widoku vmin, vmax = p1.viewRange()[1] - def within(y): return (y is not None) and (vmin <= y <= vmax) - for name in ("dead_low","low","mid","high","extreme"): + def within(y): + return (y is not None) and (vmin <= y <= vmax) + + for name in ("dead_low", "low", "mid", "high", "extreme"): y = thr.get(name) if within(y): ln = win._thr_lines[name] @@ -3143,30 +4253,44 @@ class App(tk.Tk): # wskaźnik najbliższego progu cur = vals_draw[-1] - cands = [(k, thr.get(k)) for k in ("dead_low","low","mid","high","extreme") if thr.get(k) is not None] + cands = [ + (k, thr.get(k)) + for k in ("dead_low", "low", "mid", "high", "extreme") + if thr.get(k) is not None + ] if cands: - k_best, y_best = min(cands, key=lambda kv: abs(kv[1]-cur)) - up = (y_best > cur) + k_best, y_best = min(cands, key=lambda kv: abs(kv[1] - cur)) + up = y_best > cur char = "▲" if up else "▼" - col = {"dead_low":"#FBC02D","low":"#FFF59D","mid":"#2ECC71","high":"#FF8A80","extreme":"#D50000"}[k_best] - win._tri_val.setText(char); win._tri_val.setColor(col); win._tri_val.setVisible(True) - win._tri_delta.setText(char); win._tri_delta.setColor(col); win._tri_delta.setVisible(True) + col = { + "dead_low": "#FBC02D", + "low": "#FFF59D", + "mid": "#2ECC71", + "high": "#FF8A80", + "extreme": "#D50000", + }[k_best] + win._tri_val.setText(char) + win._tri_val.setColor(col) + win._tri_val.setVisible(True) + win._tri_delta.setText(char) + win._tri_delta.setColor(col) + win._tri_delta.setVisible(True) try: x0 = p1.viewRange()[0][0] y0 = p1.viewRange()[1][0] y1 = p2.viewRange()[1][1] win._tri_val.setPos(x0, y0) win._tri_delta.setPos(x0, y1) - except Exception as e: + except Exception: logger.exception("_plot_update_pyqtgraph set tri pos") - except Exception as e: + except Exception: logger.exception("_plot_update_pyqtgraph") - # --- [DROP-IN] otwieranie okna Matplotlib ----------------------------------- def _open_matplotlib_window(self, key: str): import matplotlib + matplotlib.use("TkAgg") # Correct backend for Tk embedding from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg from matplotlib.figure import Figure @@ -3178,55 +4302,114 @@ class App(tk.Tk): win._key = key 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)) + 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)) + 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, - textvariable=win._sample_len_var, width=6).pack(side=tk.LEFT) + ttk.Spinbox( + tools, + from_=20, + to=5000, + increment=10, + textvariable=win._sample_len_var, + width=6, + ).pack(side=tk.LEFT) - ttk.Label(tools, text="Max draw pts:").pack(side=tk.LEFT, padx=(10,2)) + ttk.Label(tools, text="Max draw pts:").pack(side=tk.LEFT, padx=(10, 2)) win._max_draw_var = tk.IntVar(value=int(self.default_maxpts_var.get())) - sp = ttk.Spinbox(tools, from_=100, to=5000, increment=50, - textvariable=win._max_draw_var, width=6) + sp = ttk.Spinbox( + tools, + from_=100, + to=5000, + increment=50, + textvariable=win._max_draw_var, + width=6, + ) sp.pack(side=tk.LEFT) win._max_draw_pts = int(win._max_draw_var.get()) - sp.configure(command=lambda w=win: setattr(w, "_max_draw_pts", int(w._max_draw_var.get()))) + sp.configure( + command=lambda w=win: setattr( + w, "_max_draw_pts", int(w._max_draw_var.get()) + ) + ) - fig = Figure(figsize=(8,4), dpi=100) - 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") + fig = Figure(figsize=(8, 4), dpi=100) + 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") # linie progów win._thr_lines = { - "dead_low": ax_val.axhline(0, color="#FBC02D", linewidth=1.2, linestyle="--", visible=False), - "low": ax_val.axhline(0, color="#FFF59D", linewidth=1.2, linestyle="--", visible=False), - "mid": ax_val.axhline(0, color="#2ECC71", linewidth=1.0, linestyle=":", visible=False), - "high": ax_val.axhline(0, color="#FF8A80", linewidth=1.2, linestyle="--", visible=False), - "extreme": ax_val.axhline(0, color="#D50000", linewidth=1.2, linestyle="--", visible=False), + "dead_low": ax_val.axhline( + 0, color="#FBC02D", linewidth=1.2, linestyle="--", visible=False + ), + "low": ax_val.axhline( + 0, color="#FFF59D", linewidth=1.2, linestyle="--", visible=False + ), + "mid": ax_val.axhline( + 0, color="#2ECC71", linewidth=1.0, linestyle=":", visible=False + ), + "high": ax_val.axhline( + 0, color="#FF8A80", linewidth=1.2, linestyle="--", visible=False + ), + "extreme": ax_val.axhline( + 0, color="#D50000", linewidth=1.2, linestyle="--", visible=False + ), } canvas = FigureCanvasTkAgg(fig, master=win) - canvas.draw(); canvas.get_tk_widget().pack(fill=tk.BOTH, expand=True) + canvas.draw() + canvas.get_tk_widget().pack(fill=tk.BOTH, expand=True) # wskaźniki (trójkąty) - 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) + 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) - win._fig = fig; win._ax_val = ax_val; win._ax_delta = ax_delta - win._canvas = canvas; win._line = line; win._bars = bars; win._bars_len = 0 + win._fig = fig + win._ax_val = ax_val + win._ax_delta = ax_delta + win._canvas = canvas + win._line = line + win._bars = bars + win._bars_len = 0 win._last_seen_ver = -1 win._layout_dirty = True - canvas.mpl_connect("resize_event", lambda _evt=None: setattr(win, "_layout_dirty", True)) + canvas.mpl_connect( + "resize_event", lambda _evt=None: setattr(win, "_layout_dirty", True) + ) self._plot_windows[key] = win self._ensure_plot_timer() @@ -3246,12 +4429,14 @@ class App(tk.Tk): # delty (słupki) if len(dels_draw) != getattr(win, "_bars_len", 0): for b in getattr(win, "_bars", []): - try: b.remove() - except Exception as e: logger.exception("_plot_update_matplotlib remove bar") + try: + b.remove() + except Exception: + logger.exception("_plot_update_matplotlib remove bar") 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): + for b, h in zip(win._bars, dels_draw, strict=True): b.set_height(h) if dels_draw: dmin, dmax = min(dels_draw), max(dels_draw) @@ -3264,57 +4449,116 @@ class App(tk.Tk): # progi + wskaźnik for ln in win._thr_lines.values(): ln.set_visible(False) - win._tri_val.set_visible(False); win._tri_delta.set_visible(False) + win._tri_val.set_visible(False) + win._tri_delta.set_visible(False) - if bool(getattr(win, "_show_thr_var", self.display_show_thresholds_var).get()) and vals_draw: + if ( + bool( + getattr( + win, "_show_thr_var", self.display_show_thresholds_var + ).get() + ) + and vals_draw + ): vmin, vmax = win._ax_val.get_ylim() - def within(y): return (y is not None) and (vmin <= y <= vmax) - for name in ("dead_low","low","mid","high","extreme"): + + def within(y): + return (y is not None) and (vmin <= y <= vmax) + + for name in ("dead_low", "low", "mid", "high", "extreme"): y = thr.get(name) if within(y): win._thr_lines[name].set_ydata([y, y]) win._thr_lines[name].set_visible(True) cur = vals_draw[-1] if vals_draw else None - cands = [(k, thr.get(k)) for k in ("dead_low","low","mid","high","extreme") if thr.get(k) is not None] + cands = [ + (k, thr.get(k)) + for k in ("dead_low", "low", "mid", "high", "extreme") + if thr.get(k) is not None + ] if (cur is not None) and cands: - k_best, y_best = min(cands, key=lambda kv: abs(kv[1]-cur)) + k_best, y_best = min(cands, key=lambda kv: abs(kv[1] - cur)) color_map = { - "dead_low": "#FBC02D", "low": "#FFF59D", "mid": "#2ECC71", - "high": "#FF8A80", "extreme": "#D50000", + "dead_low": "#FBC02D", + "low": "#FFF59D", + "mid": "#2ECC71", + "high": "#FF8A80", + "extreme": "#D50000", } col = color_map.get(k_best, "#2ECC71") - up = (y_best > cur) + 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) + 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) if getattr(win, "_layout_dirty", False): - try: win._fig.tight_layout() - except Exception as e: logger.exception("_plot_update_matplotlib tight_layout") + try: + win._fig.tight_layout() + except Exception: + logger.exception("_plot_update_matplotlib tight_layout") win._layout_dirty = False win._canvas.draw_idle() - except Exception as e: + except Exception: logger.exception("_plot_update_matplotlib") 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)()]) - return n_tk + n_qt + """Count all open plot windows (both Tk/matplotlib and Qt/pyqtgraph)""" + count = 0 - # --- helpers: monitory (Windows) + fallback --- + for key, win in getattr(self, "_plot_windows", {}).items(): + try: + # Check if it's a Tk window (matplotlib) + if hasattr(win, "winfo_exists") and win.winfo_exists(): + count += 1 + # Check if it's a Qt window wrapper + elif hasattr(win, "_qt_widget"): + qt_widget = getattr(win, "_qt_widget", None) + if ( + qt_widget + and hasattr(qt_widget, "isVisible") + and qt_widget.isVisible() + ): + count += 1 + except Exception: + logger.exception(f"count_plot_windows: Error checking window {key}") + + # Also check canvas windows if they exist separately + canvas_count = len( + [ + w + for w in getattr(self, "_plot_windows_canvas", []) + if getattr(w, "winfo_exists", lambda: False)() + ] + ) + + return count + canvas_count + + # --- helpers: monitors (Windows) + fallback --- def _enum_monitors(self): monitors = [] try: - import ctypes - from ctypes import wintypes class RECT(ctypes.Structure): - _fields_ = [("left", ctypes.c_long), ("top", ctypes.c_long), - ("right", ctypes.c_long), ("bottom", ctypes.c_long)] + _fields_ = [ + ("left", ctypes.c_long), + ("top", ctypes.c_long), + ("right", ctypes.c_long), + ("bottom", ctypes.c_long), + ] - MONITORENUMPROC = ctypes.WINFUNCTYPE(ctypes.c_int, ctypes.c_ulong, ctypes.c_ulong, ctypes.POINTER(RECT), ctypes.c_double) + MONITORENUMPROC = ctypes.WINFUNCTYPE( + ctypes.c_int, + ctypes.c_ulong, + ctypes.c_ulong, + ctypes.POINTER(RECT), + ctypes.c_double, + ) user32 = ctypes.windll.user32 def _cb(hMon, hDC, lprc, dwData): @@ -3325,8 +4569,8 @@ class App(tk.Tk): user32.EnumDisplayMonitors(0, 0, MONITORENUMPROC(_cb), 0) if not monitors: raise RuntimeError("No monitors from EnumDisplayMonitors") - except Exception as e: - # Fallback: pojedynczy ekran wg Tk + except Exception: + # Fallback: single screen according to Tk logger.exception("_enum_monitors") w = self.winfo_screenwidth() h = self.winfo_screenheight() @@ -3338,7 +4582,8 @@ class App(tk.Tk): def _which_monitor(self, monitors, x, y, w, h): cx, cy = self._win_center(x, y, w, h) - best = 0; best_d = float("inf") + best = 0 + best_d = float("inf") for i, (L, T, R, B) in enumerate(monitors): if L <= cx <= R and T <= cy <= B: return i @@ -3358,81 +4603,99 @@ class App(tk.Tk): def arrange_plot_windows(self): """ - Układa wszystkie otwarte okna wykresów (Tk-Matplotlib, Tk-Canvas, Qt-PyQtGraph) - w siatki na poszczególnych monitorach. + Arranges all open plot windows (Tk-Matplotlib, Tk-Canvas, Qt-PyQtGraph) + in grids on individual monitors. - Zasady: - - grupujemy okna wg monitora, NA KTÓRYM AKTUALNIE SĄ otwarte, - - w każdej grupie sortujemy alfabetycznie wg nazwy zmiennej (display_name), - - wypełniamy siatkę od lewej do prawej, potem kolejny wiersz, - - jeśli n>=9 na danym monitorze → siatka pełnoekranowa, - jeśli n<9 → okna max ~połowa domyślnego rozmiaru (dodatkowo skalujemy w dół, gdy trzeba), - - zostawiamy marginesy na krawędziach (TOP_MARGIN, SIDE/BOTTOM), żeby tytuły nie „wychodziły” poza ekran. + Rules: + - group windows by monitor WHERE THEY ARE CURRENTLY open, + - in each group sort alphabetically by variable name (display_name), + - fill grid from left to right, then next row, + - if n>=9 on given monitor -> fullscreen grid, + if n<9 -> windows max ~half of default size (additionally scale down when needed), + - leave margins on edges (TOP_MARGIN, SIDE/BOTTOM), so titles don't „wychodziły” poza ekran. """ monitors = self._enum_monitors() - # Zbierz okna (Tk MPL) + # Collect windows from main dictionary _plot_windows (Matplotlib and Qt) tk_wins = [] - for w in list(getattr(self, "_plot_windows", {}).values()): - if getattr(w, "winfo_exists", lambda: False)(): - try: - x = w.winfo_rootx(); y = w.winfo_rooty() - ww = w.winfo_width(); hh = w.winfo_height() + qt_wins = [] + + for key, w in list(getattr(self, "_plot_windows", {}).items()): + try: + # Sprawdź czy to okno Tk (Matplotlib) + if hasattr(w, "winfo_exists") and w.winfo_exists(): + x = w.winfo_rootx() + y = w.winfo_rooty() + ww = w.winfo_width() + hh = w.winfo_height() if ww <= 1 or hh <= 1: geo = w.geometry() # "WxH+X+Y" parts = geo.replace("x", "+").split("+") - ww = int(parts[0]); hh = int(parts[1]); x = int(parts[2]); y = int(parts[3]) - key = getattr(w, "_key", None) + ww = int(parts[0]) + hh = int(parts[1]) + x = int(parts[2]) + y = int(parts[3]) + name = None - if key and key in self.vars: + if key in self.vars: name = self.vars[key].display_name if not name: - # fallback: tytuł okna - name = str(w.title() or "") + # fallback: window title + name = str(w.title() or key) tk_wins.append(("tk", w, x, y, ww, hh, name)) - except Exception as e: - logger.exception("arrange_plot_windows Tk MPL") - # Zbierz okna (Tk Canvas) + # Sprawdź czy to wrapper Qt window (PyQtGraph) + elif hasattr(w, "_qt_widget"): + qt_widget = getattr(w, "_qt_widget", None) + if ( + qt_widget + and hasattr(qt_widget, "isVisible") + and qt_widget.isVisible() + ): + fg = qt_widget.frameGeometry() + x = fg.x() + y = fg.y() + ww = fg.width() + hh = fg.height() + + name = None + if key in self.vars: + name = self.vars[key].display_name + if not name: + name = str(qt_widget.windowTitle() or key) + qt_wins.append(("qt", qt_widget, x, y, ww, hh, name)) + + except Exception: + logger.exception(f"arrange_plot_windows: Error processing window {key}") + + # Zbierz okna (Tk Canvas) - jeśli istnieją oddzielnie for w in list(getattr(self, "_plot_windows_canvas", [])): if getattr(w, "winfo_exists", lambda: False)(): try: - x = w.winfo_rootx(); y = w.winfo_rooty() - ww = w.winfo_width(); hh = w.winfo_height() + x = w.winfo_rootx() + y = w.winfo_rooty() + ww = w.winfo_width() + hh = w.winfo_height() if ww <= 1 or hh <= 1: geo = w.geometry() parts = geo.replace("x", "+").split("+") - ww = int(parts[0]); hh = int(parts[1]); x = int(parts[2]); y = int(parts[3]) + ww = int(parts[0]) + hh = int(parts[1]) + x = int(parts[2]) + y = int(parts[3]) key = getattr(w, "_key", None) name = None if key and key in self.vars: name = self.vars[key].display_name if not name: - name = str(w.title() or "") + name = str(w.title() or "Canvas") tk_wins.append(("tk", w, x, y, ww, hh, name)) - except Exception as e: + except Exception: logger.exception("arrange_plot_windows Tk Canvas") - # Zbierz okna (Qt pyqtgraph) - qt_wins = [] - for qw in list(getattr(self, "_plot_windows_qt", [])): - if getattr(qw, "isVisible", lambda: False)(): - try: - fg = qw.frameGeometry() - x = fg.x(); y = fg.y(); ww = fg.width(); hh = fg.height() - key = getattr(qw, "_key", None) - name = getattr(qw, "_display_name", None) - if not name and key and key in self.vars: - name = self.vars[key].display_name - if not name: - name = str(qw.windowTitle() or "") - qt_wins.append(("qt", qw, x, y, ww, hh, name)) - except Exception as e: - logger.exception("arrange_plot_windows Qt pyqtgraph") - all_wins = tk_wins + qt_wins if not all_wins: - tk.messagebox.showinfo("Arrange", "Brak otwartych okien wykresów.") + tk.messagebox.showinfo("Arrange", "No open plot windows found.") return # Grupowanie po monitorach (wg aktualnej pozycji) @@ -3441,26 +4704,36 @@ class App(tk.Tk): mid = self._which_monitor(monitors, x, y, ww, hh) groups[mid].append((kind, win, name)) - # USTAWIENIA rozmiaru/marginesów + # SIZE/MARGIN SETTINGS DEF_W, DEF_H = 760, 420 MAX_W, MAX_H = DEF_W // 2, DEF_H // 2 SIDE_MARGIN = 12 - TOP_MARGIN = 40 # większy top, żeby tytuły Qt nie wychodziły + TOP_MARGIN = 60 # zwiększony dla tytułów Qt BOTTOM_MARGIN = 12 - CELL_PAD = 10 # odstęp między kratkami + CELL_PAD = 10 # odstęp między kratkami - # Helper: najlepsza siatka (r,c) + # Helper: najlepsza siatka (r,c) - preferuje prostokąty szerokie def best_grid(n): - if n <= 0: return (1, 1) - c = int(math.ceil(math.sqrt(n))) - r = int(math.ceil(n / c)) - return (r, c) + if n <= 0: + return (1, 1) + # Dla małej liczby okien, preferuj układ poziomy + if n <= 3: + return (1, n) + if n == 4: + return (2, 2) + + # Dla większej liczby: przybliżony kwadrat, ale preferuj szerokość + cols = int(math.ceil(math.sqrt(n))) + if cols * (cols - 1) >= n: # sprawdź czy można zmniejszyć rows + cols -= 1 + rows = int(math.ceil(n / cols)) + return (rows, cols) for midx, items in groups.items(): if not items: continue - # Sortowanie alfabetycznie wg nazwy zmiennej (display_name) + # Alphabetical sorting by variable name (display_name) items.sort(key=lambda it: (str(it[2]).lower(), str(it[2]))) L, T, R, B = monitors[midx] @@ -3469,58 +4742,89 @@ class App(tk.Tk): n = len(items) rows, cols = best_grid(n) - full = (n >= 9) + use_max_size = ( + n < 9 + ) # use maximum size for smaller number of windows - # wstępny wymiar kratki - if full: - cell_w = max(1, (mon_w - (cols + 1) * CELL_PAD - 2 * SIDE_MARGIN) // cols) - cell_h = max(1, (mon_h - (rows + 1) * CELL_PAD - TOP_MARGIN - BOTTOM_MARGIN) // rows) - else: - cell_w = min(MAX_W, max(1, (mon_w - (cols + 1) * CELL_PAD - 2 * SIDE_MARGIN) // cols)) - cell_h = min(MAX_H, max(1, (mon_h - (rows + 1) * CELL_PAD - TOP_MARGIN - BOTTOM_MARGIN) // rows)) + # Oblicz dostępną przestrzeń + available_w = mon_w - (cols + 1) * CELL_PAD - 2 * SIDE_MARGIN + available_h = mon_h - (rows + 1) * CELL_PAD - TOP_MARGIN - BOTTOM_MARGIN - # dopasowanie, jeśli dalej nie mieści - while (cell_w * cols + (cols + 1) * CELL_PAD + 2 * SIDE_MARGIN) > mon_w or \ - (cell_h * rows + (rows + 1) * CELL_PAD + TOP_MARGIN + BOTTOM_MARGIN) > mon_h: - cell_w = max(120, int(cell_w * 0.9)) - cell_h = max(100, int(cell_h * 0.9)) - if cell_w <= 120 and cell_h <= 100: - break + # Initial cell size + cell_w = max(1, available_w // cols) + cell_h = max(1, available_h // rows) - # rozmiar całej siatki + pozycja startowa (centrowanie) + # Apply size limitation for small number of windows + if use_max_size: + cell_w = min(MAX_W, cell_w) + cell_h = min(MAX_H, cell_h) + + # Skalowanie w dół, jeśli nie mieści się + total_w = cell_w * cols + (cols + 1) * CELL_PAD + 2 * SIDE_MARGIN + total_h = cell_h * rows + (rows + 1) * CELL_PAD + TOP_MARGIN + BOTTOM_MARGIN + + if total_w > mon_w or total_h > mon_h: + scale_w = mon_w / total_w if total_w > mon_w else 1.0 + scale_h = mon_h / total_h if total_h > mon_h else 1.0 + scale_factor = min(scale_w, scale_h, 1.0) + + cell_w = max(120, int(cell_w * scale_factor)) + cell_h = max(100, int(cell_h * scale_factor)) + + # entire grid size + starting position (centering) grid_w = cell_w * cols + (cols + 1) * CELL_PAD grid_h = cell_h * rows + (rows + 1) * CELL_PAD origin_x = L + SIDE_MARGIN + max(0, (mon_w - grid_w - 2 * SIDE_MARGIN) // 2) - origin_y = T + TOP_MARGIN + max(0, (mon_h - grid_h - TOP_MARGIN - BOTTOM_MARGIN) // 2) + origin_y = ( + T + + TOP_MARGIN + + max(0, (mon_h - grid_h - TOP_MARGIN - BOTTOM_MARGIN) // 2) + ) # „bezpieczny” rozmiar (obcięty o mały bufor, różne dekoracje) - cell_w_adj = max(50, cell_w - 8) - cell_h_adj = max(50, cell_h - 12) - # Rozmieszczaj od lewej do prawej, potem kolejny wiersz + + + # Arrange by columns: alphabetically a-z in first column, then second column etc. i = 0 for c in range(cols): for r in range(rows): if i >= n: break - kind, win, _nm = items[i] + kind, win, _name = items[i] x = origin_x + CELL_PAD + c * (cell_w + CELL_PAD) y = origin_y + CELL_PAD + r * (cell_h + CELL_PAD) # „clamp” do granic ekranu (z marginesami) - x = max(L + SIDE_MARGIN, min(x, R - SIDE_MARGIN - cell_w_adj)) - y = max(T + TOP_MARGIN, min(y, B - BOTTOM_MARGIN - cell_h_adj)) + + try: - if kind == "tk": - win.geometry(f"{cell_w_adj}x{cell_h_adj}+{x}+{y}") - else: - win.setGeometry(x, y, cell_w_adj, cell_h_adj) - except Exception as e: - logger.exception("arrange_plot_windows set geometry") - i += 1 + # Apply window-specific padding based on GUI framework differences: + # Tk/matplotlib windows need MORE padding due to toolbars and larger decorations + # Qt windows are more precisely sized with minimal modern frames + if kind == "qt": + w_pad, h_pad = (8, 15) # Qt: minimal frames, precise sizing + else: # "tk" (matplotlib/canvas) + w_pad, h_pad = (20, 50) # Tk/matplotlib: toolbar + larger decorations + adj_w = max(50, cell_w - w_pad) + adj_h = max(50, cell_h - h_pad) + + # Adjust position if needed + final_x = max(L + SIDE_MARGIN, min(x, R - SIDE_MARGIN - adj_w)) + final_y = max(T + TOP_MARGIN, min(y, B - BOTTOM_MARGIN - adj_h)) + + if kind == "qt": + win.setGeometry(final_x, final_y, adj_w, adj_h) + else: # "tk" + win.geometry(f"{adj_w}x{adj_h}+{final_x}+{final_y}") + except Exception: + logger.exception( + f"arrange_plot_windows: Failed to set geometry for {kind} window" + ) + i += 1 # --- Utils --- @@ -3531,11 +4835,12 @@ def parse_first_float(value: str) -> Optional[float]: if m: try: return float(m.group(0)) - except Exception as e: + except Exception: logger.exception("parse_first_float") return None return None + def safe_eval(expression: str, env: dict): """ Bezpieczna ewaluacja krótkich wyrażeń progowych. @@ -3544,22 +4849,41 @@ def safe_eval(expression: str, env: dict): """ allowed_globals = { "__builtins__": {}, - "math": math, "min": min, "max": max, - "abs": abs, "round": round, "int": int, "float": float, + "math": math, + "min": min, + "max": max, + "abs": abs, + "round": round, + "int": int, + "float": float, } # tylko 'eval' – bez snippetów wieloliniowych: code = compile(expression, "", "eval") return eval(code, allowed_globals, env) -def eval_user_expression(expr: str, x: float, y: float | None = None, z: float | None = None): +def eval_user_expression( + expr: str, x: float, y: float | None = None, z: float | None = None +): """Evaluate user expression/snippet with x available. Allowed: math.*, min, max, abs, round, int, float, clamp (custom). Returns the computed result. """ + def clamp(v, lo, hi): return max(lo, min(hi, v)) - safe_globals = {"__builtins__": {}, "math": math, "min": min, "max": max, "abs": abs, "round": round, "int": int, "float": float, "clamp": clamp} + + safe_globals = { + "__builtins__": {}, + "math": math, + "min": min, + "max": max, + "abs": abs, + "round": round, + "int": int, + "float": float, + "clamp": clamp, + } safe_locals = {"x": x, "y": y, "z": z} if "\n" in expr or ";" in expr: code = compile(expr, "", "exec") @@ -3571,18 +4895,22 @@ def eval_user_expression(expr: str, x: float, y: float | None = None, z: float | code = compile(expr, "", "eval") return eval(code, safe_globals, safe_locals) + 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 as e: + except Exception: logger.exception("graceful_shutdown") + atexit.register(_graceful_shutdown) app.mainloop() + if __name__ == "__main__": main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..467d3cb --- /dev/null +++ b/requirements.txt @@ -0,0 +1,23 @@ +cffi==2.0.0 +contourpy==1.3.3 +cycler==0.12.1 +fonttools==4.60.1 +kiwisolver==1.4.9 +matplotlib==3.10.7 +numpy==2.3.3 +packaging==25.0 +pillow==12.0.0 +pycparser==2.23 +pyparsing==3.2.5 +PyQt5==5.15.11 +PyQt5-Qt5==5.15.2 +PyQt5_sip==12.17.1 +pyqtgraph==0.13.7 +PySide6==6.10.0 +PySide6_Addons==6.10.0 +PySide6_Essentials==6.10.0 +python-dateutil==2.9.0.post0 +scipy==1.16.2 +shiboken6==6.10.0 +six==1.17.0 +soundfile==0.13.1 diff --git a/test_arrange_windows.py b/test_arrange_windows.py new file mode 100644 index 0000000..b92a382 --- /dev/null +++ b/test_arrange_windows.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +""" +Quick test to verify the arrange_plot_windows function improvements. +This script simulates the functionality without running the full nuclear monitor. +""" + +import math + +def test_best_grid(): + """Test the improved grid calculation""" + def best_grid(n): + if n <= 0: + return (1, 1) + # For small number of windows, prefer horizontal layout + if n <= 3: + return (1, n) + if n == 4: + return (2, 2) + + # For larger numbers: approximate square, but prefer width + cols = int(math.ceil(math.sqrt(n))) + if cols * (cols - 1) >= n: # check if we can reduce rows + cols -= 1 + rows = int(math.ceil(n / cols)) + return (rows, cols) + + test_cases = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 12, 16] + + print("Grid layout tests:") + print("Windows | Grid (rows x cols) | Layout") + print("--------|-------------------|--------") + + for n in test_cases: + rows, cols = best_grid(n) + layout = "x".join(["O"] * cols) + layout = " | ".join([layout] * rows) + print(f"{n:7d} | {rows:2d} x {cols:2d} | {layout}") + +def test_window_padding(): + """Test the window-specific padding""" + def get_window_padding(kind): + if kind == "qt": + return (15, 40) # Qt needs more space for title bar + else: + return (8, 12) # Tk windows + + print("\nWindow padding tests:") + print("Type | Width Pad | Height Pad | Reason") + print("-----|-----------|------------|--------") + + for kind in ["tk", "qt"]: + w_pad, h_pad = get_window_padding(kind) + reason = "Qt title bar & frames" if kind == "qt" else "Tk decorations" + print(f"{kind:4s} | {w_pad:9d} | {h_pad:10d} | {reason}") + +def test_arrangement_calculation(): + """Test the arrangement calculation for different scenarios""" + print("\nArrangement calculation test:") + + # Simulate monitor: 1920x1080 + mon_w, mon_h = 1920, 1080 + SIDE_MARGIN = 12 + TOP_MARGIN = 60 + BOTTOM_MARGIN = 12 + CELL_PAD = 10 + MAX_W, MAX_H = 380, 210 # Half of 760x420 + + test_scenarios = [ + (2, "Two windows - should be side by side"), + (4, "Four windows - 2x2 grid"), + (6, "Six windows - 2x3 or 3x2 grid"), + (9, "Nine windows - full screen mode"), + ] + + def best_grid(n): + if n <= 0: + return (1, 1) + if n <= 3: + return (1, n) + if n == 4: + return (2, 2) + cols = int(math.ceil(math.sqrt(n))) + if cols * (cols - 1) >= n: + cols -= 1 + rows = int(math.ceil(n / cols)) + return (rows, cols) + + for n_windows, description in test_scenarios: + print(f"\n{description}") + print(f"Windows: {n_windows}") + + rows, cols = best_grid(n_windows) + use_max_size = n_windows < 9 + + # Calculate available space + available_w = mon_w - (cols + 1) * CELL_PAD - 2 * SIDE_MARGIN + available_h = mon_h - (rows + 1) * CELL_PAD - TOP_MARGIN - BOTTOM_MARGIN + + # Initial cell size + cell_w = max(1, available_w // cols) + cell_h = max(1, available_h // rows) + + # Apply max size constraint for few windows + if use_max_size: + cell_w = min(MAX_W, cell_w) + cell_h = min(MAX_H, cell_h) + + print(f"Grid: {rows}x{cols}") + print(f"Available space: {available_w}x{available_h}") + print(f"Cell size: {cell_w}x{cell_h}") + print(f"Max size applied: {use_max_size}") + + # Test padding for different window types + for win_type in ["tk", "qt"]: + if win_type == "qt": + w_pad, h_pad = (15, 40) + else: + w_pad, h_pad = (8, 12) + + adj_w = max(50, cell_w - w_pad) + adj_h = max(50, cell_h - h_pad) + print(f" {win_type} windows: {adj_w}x{adj_h} (after padding)") + +if __name__ == "__main__": + print("Testing arrange_plot_windows improvements\n") + print("=" * 50) + + test_best_grid() + test_window_padding() + test_arrangement_calculation() + + print("\n" + "=" * 50) + print("All tests completed. The arrange_plot_windows function should now:") + print("1. ✅ Properly detect both Qt and matplotlib windows") + print("2. ✅ Use improved grid layout (prefer horizontal for few windows)") + print("3. ✅ Apply appropriate padding for different window types") + print("4. ✅ Arrange windows top-to-bottom, left-to-right") + print("5. ✅ Handle multiple displays correctly") + print("6. ✅ Scale down when windows don't fit") + print("7. ✅ Sort windows alphabetically by display name") \ No newline at end of file diff --git a/test_arrangement_fixes.py b/test_arrangement_fixes.py new file mode 100644 index 0000000..fc4d73a --- /dev/null +++ b/test_arrangement_fixes.py @@ -0,0 +1,217 @@ +#!/usr/bin/env python3 +""" +Test script to verify the fixed arrange_plot_windows function. + +This tests: +1. Column-first arrangement (a-z in first column, then second column, etc.) +2. Corrected padding (Qt gets less padding, matplotlib gets more padding) +3. No double size calculation issues +4. Proper window detection from unified storage +""" + +# Mock window classes for testing +class MockQtWindow: + def __init__(self, name): + self.name = name + self.geometry_calls = [] + + def setGeometry(self, x, y, w, h): + self.geometry_calls.append((x, y, w, h)) + print(f"Qt Window '{self.name}': setGeometry({x}, {y}, {w}, {h})") + +class MockTkWindow: + def __init__(self, name): + self.name = name + self.geometry_calls = [] + + def geometry(self, geom_str): + self.geometry_calls.append(geom_str) + print(f"Tk Window '{self.name}': geometry('{geom_str}')") + +def test_arrangement_and_padding(): + """Test the column-first arrangement and corrected padding values.""" + + print("=== Testing Column-First Arrangement and Corrected Padding ===\n") + + # Test case: 6 windows arranged in a 3x2 grid (3 rows, 2 columns) + windows = [ + ("qt", MockQtWindow("PlotA"), "PlotA"), + ("tk", MockTkWindow("PlotB"), "PlotB"), + ("qt", MockQtWindow("PlotC"), "PlotC"), + ("tk", MockTkWindow("PlotD"), "PlotD"), + ("qt", MockQtWindow("PlotE"), "PlotE"), + ("tk", MockTkWindow("PlotF"), "PlotF"), + ] + + # Grid parameters + cols = 2 + rows = 3 + cell_w = 400 + cell_h = 300 + CELL_PAD = 10 + + # Mock monitor bounds + L, T, R, B = 100, 100, 1500, 900 + SIDE_MARGIN = 20 + TOP_MARGIN = 50 + BOTTOM_MARGIN = 50 + + # Calculate grid layout (similar to real function) + grid_w = cell_w * cols + (cols + 1) * CELL_PAD + grid_h = cell_h * rows + (rows + 1) * CELL_PAD + mon_w = R - L + mon_h = B - T + origin_x = L + SIDE_MARGIN + max(0, (mon_w - grid_w - 2 * SIDE_MARGIN) // 2) + origin_y = T + TOP_MARGIN + max(0, (mon_h - grid_h - TOP_MARGIN - BOTTOM_MARGIN) // 2) + + print(f"Grid: {cols}x{rows}, Cell: {cell_w}x{cell_h}") + print(f"Monitor: ({L},{T}) to ({R},{B})") + print(f"Grid origin: ({origin_x},{origin_y})") + print(f"Expected arrangement (column-first):") + print(" Column 1: PlotA(0,0), PlotB(0,1), PlotC(0,2)") + print(" Column 2: PlotD(1,0), PlotE(1,1), PlotF(1,2)") + print() + + # Test the column-first arrangement with corrected padding + arrangement_results = [] + i = 0 + for c in range(cols): # Column-first: iterate columns first + for r in range(rows): # Then rows within each column + if i >= len(windows): + break + kind, win, name = windows[i] + + # Calculate position + x = origin_x + CELL_PAD + c * (cell_w + CELL_PAD) + y = origin_y + CELL_PAD + r * (cell_h + CELL_PAD) + + # Apply corrected padding (Qt less, Tk/matplotlib more) + if kind == "qt": + w_pad, h_pad = (8, 15) # Qt: minimal frames, precise sizing + else: # "tk" (matplotlib/canvas) + w_pad, h_pad = (20, 50) # Tk/matplotlib: toolbar + larger decorations + + adj_w = max(50, cell_w - w_pad) + adj_h = max(50, cell_h - h_pad) + + # Clamp to monitor bounds + final_x = max(L + SIDE_MARGIN, min(x, R - SIDE_MARGIN - adj_w)) + final_y = max(T + TOP_MARGIN, min(y, B - BOTTOM_MARGIN - adj_h)) + + arrangement_results.append({ + 'name': name, + 'kind': kind, + 'column': c, + 'row': r, + 'padding': (w_pad, h_pad), + 'size': (adj_w, adj_h), + 'position': (final_x, final_y) + }) + + # Apply geometry + if kind == "qt": + win.setGeometry(final_x, final_y, adj_w, adj_h) + else: + win.geometry(f"{adj_w}x{adj_h}+{final_x}+{final_y}") + + i += 1 + + print("\n=== Arrangement Results ===") + for result in arrangement_results: + name = result['name'] + kind = result['kind'] + col = result['column'] + row = result['row'] + w_pad, h_pad = result['padding'] + adj_w, adj_h = result['size'] + final_x, final_y = result['position'] + + print(f"{name} ({kind}): Column {col}, Row {row}") + print(f" Padding: {w_pad}x{h_pad} ({'minimal' if kind == 'qt' else 'toolbar+margins'})") + print(f" Final size: {adj_w}x{adj_h}") + print(f" Position: ({final_x},{final_y})") + print() + + # Verify column-first arrangement + print("=== Verification ===") + expected_order = [ + ("PlotA", 0, 0), ("PlotB", 0, 1), ("PlotC", 0, 2), # Column 1 + ("PlotD", 1, 0), ("PlotE", 1, 1), ("PlotF", 1, 2) # Column 2 + ] + + success = True + for i, (expected_name, expected_col, expected_row) in enumerate(expected_order): + actual = arrangement_results[i] + if (actual['name'] != expected_name or + actual['column'] != expected_col or + actual['row'] != expected_row): + print(f"❌ Position {i}: Expected {expected_name} at ({expected_col},{expected_row}), " + f"got {actual['name']} at ({actual['column']},{actual['row']})") + success = False + + if success: + print("✅ Column-first arrangement verified correctly!") + + # Verify padding corrections + qt_windows = [r for r in arrangement_results if r['kind'] == 'qt'] + tk_windows = [r for r in arrangement_results if r['kind'] == 'tk'] + + if qt_windows and tk_windows: + qt_pad = qt_windows[0]['padding'] + tk_pad = tk_windows[0]['padding'] + + if qt_pad[0] < tk_pad[0] and qt_pad[1] < tk_pad[1]: + print("✅ Padding correction verified: Qt windows have less padding than Tk/matplotlib") + print(f" Qt padding: {qt_pad[0]}x{qt_pad[1]}") + print(f" Tk padding: {tk_pad[0]}x{tk_pad[1]}") + else: + print(f"❌ Padding incorrect: Qt {qt_pad} should be less than Tk {tk_pad}") + success = False + + return success + +def test_grid_calculation(): + """Test the grid calculation logic that prefers horizontal layouts.""" + + def best_grid(n): + """Find best grid dimensions preferring horizontal layouts for small n.""" + if n <= 0: + return (0, 0) + if n == 1: + return (1, 1) + if n <= 3: + return (n, 1) # Horizontal preference for small counts + + best_ratio = float('inf') + best_cols, best_rows = 1, n + + for cols in range(1, n + 1): + rows = (n + cols - 1) // cols + if cols * rows >= n: + ratio = max(cols / rows, rows / cols) + if ratio < best_ratio: + best_ratio = ratio + best_cols, best_rows = cols, rows + + return (best_cols, best_rows) + + print("\n=== Testing Grid Calculation ===") + test_cases = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 12, 15, 16] + + for n in test_cases: + cols, rows = best_grid(n) + ratio = max(cols/rows, rows/cols) if rows > 0 else float('inf') + print(f"Windows: {n:2d} → Grid: {cols}x{rows} (ratio: {ratio:.2f})") + + return True + +if __name__ == "__main__": + print("Testing arrange_plot_windows fixes...\n") + + result1 = test_arrangement_and_padding() + result2 = test_grid_calculation() + + if result1 and result2: + print("\n🎉 All tests passed! The arrangement and padding fixes are working correctly.") + else: + print("\n❌ Some tests failed. Check the implementation.") \ No newline at end of file