Files
vtt_work/nucleares_monitor/control_board_monitor.py
T
2025-10-29 15:51:25 +01:00

5288 lines
201 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Control Board Monitor — v1.8
Whats new:
- POSTs are now **exactly** like the sample app:
POST http://<host>:<port>/?variable=<NAME>&value=<ARG> (method=POST, no body)
(All previous POST modes removed.)
- Select Variables dialog: typing in the filter **does not clear** previously ticked checkboxes.
- Mouse wheel scrolling enabled (selector canvas, main variables tree, actions tree).
Debugging:
- Set logging level to DEBUG to enable verbose HTTP and threshold evaluation logging
- Most verbose debug messages have been optimized to reduce spam in production
- Critical error and warning messages remain at appropriate levels
"""
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
import time
import urllib.parse
import urllib.request
from collections import deque
from dataclasses import dataclass, field
from datetime import datetime
from typing import Deque, Dict, List, Optional, Tuple
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")
try:
import faulthandler as _fh
except ImportError:
_fh = None # module not available - continue without faulthandler
def _fh_install_signal_dump(sig_name: str):
"""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
sig = getattr(signal, sig_name, None)
if sig is None:
return
# 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):
logger.debug(
f"Failed to register faulthandler for signal {sig_name}", exc_info=True
)
# 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):
logger.debug(
f"Failed to dump traceback for signal {sig_name}", exc_info=True
)
# Log and continue - diagnostics should not crash the app
try:
signal.signal(sig, _dump)
except Exception as e:
# 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
logging.basicConfig(
level=logging.INFO,
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(),
],
)
logger = logging.getLogger("ControlBoardMonitor")
# Enable faulthandler globally (if available)
if _fh is not None:
try:
_fh.enable(all_threads=True)
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)
# --- 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),
)
threading.excepthook = _thread_excepthook
# 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:
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:
logger.info("pyqtgraph not available", exc_info=True)
_pyqtgraph_unavailable_reason = "Import failed"
try:
import tkinter as tk
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
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.start()
return t
# =====================
# Global configuration
# =====================
AVERAGE_WINDOW_N: int = 60 # default for moving average window (cycles)
SERVER_HOST: str = "localhost"
SERVER_PORT: int = 8785
REFRESH_INTERVAL_S: int = 1
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",
]
# 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]]:
# HTTP request logging removed to reduce verbosity - enable for debugging
with _HTTP_LOCK:
try:
logger.debug(f"[HTTP] {req}")
with urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT_S) as resp:
status = resp.getcode()
body_bytes = resp.read()
try:
body = body_bytes.decode("utf-8", errors="replace")
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:
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"}
)
# HTTP GET logging removed to reduce verbosity - enable for debugging
return _request(req)
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"
)
# HTTP POST logging removed to reduce verbosity - enable for debugging
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"}
)
# HTTP GET ROOT logging removed to reduce verbosity - enable for debugging
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] + "…")
def parse_function_names_from_html_index(html_text: str) -> List[str]:
start = html_text.find("==== POST ====")
if start == -1:
return []
post_html = html_text[start:]
names = re.findall(r"<b>([A-Z0-9_]+)</b>", post_html)
seen = set()
uniq: List[str] = []
for n in names:
if n not in seen:
seen.add(n)
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):
href = m.group(1)
parsed = urllib.parse.urlparse(href)
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
):
tokens.append(m.group(1))
seen = set()
uniq: List[str] = []
for v in tokens:
if v and v.upper() not in {"VARNAME", "VARIABLE", "NAME"} and v not in seen:
seen.add(v)
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):
logger.warning("Failed to parse JSON response", exc_info=True)
return [], []
get_names: List[str] = []
post_names: List[str] = []
if isinstance(data, dict):
for k, v in data.items():
key = str(k).strip().rstrip(":").upper()
if key in ("GET", "POST"):
names = extract_names_preserve_case(v)
if key == "GET":
get_names.extend(names)
else:
post_names.extend(names)
elif isinstance(data, list):
get_names.extend(extract_names_preserve_case(data))
def dedup(seq):
seen = set()
out = []
for s in seq:
if s not in seen:
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"):
if k in it and isinstance(it[k], str):
add(it[k])
for k in list(it.keys()):
if isinstance(k, str) and k.upper() == k:
add(k)
elif isinstance(value_obj, dict):
for k in value_obj.keys():
if isinstance(k, str):
add(k)
elif isinstance(value_obj, str):
for m in re.findall(r"[A-Za-z0-9_]+", value_obj):
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):
logger.warning("Failed to parse JSON batch values", exc_info=True)
return {}
# 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] = {}
if isinstance(data, dict):
for k, v in data.items():
if not isinstance(k, str):
k = str(k)
try:
values[k] = v if isinstance(v, str) else json.dumps(v)
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)
if isinstance(data, dict) and isinstance(data.get("values"), dict):
return "enveloped(values+errors)"
if isinstance(data, dict):
return "flat"
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]:
"""
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.
"""
if not expr:
return None
env: dict = {}
def push(prefix: str, d: Optional[dict]):
if not d:
return
env[prefix] = d.get("x")
env[f"{prefix}_avg"] = d.get("x_avg")
env[f"d{prefix}"] = d.get("dx")
env[f"d{prefix}_avg"] = d.get("dx_avg")
push("x", stats_x)
push("y", stats_y)
push("z", stats_z)
try:
# 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):
logger.warning(
f"Failed to evaluate threshold expression '{expr}'", exc_info=True
)
return None
# =====================
# Data model
# =====================
@dataclass
class Thresholds:
dead_low: Optional[float] = None
low: Optional[float] = None
high: Optional[float] = None
extreme_high: Optional[float] = None
# alarms (defaults: on for dead_low & extreme_high as requested)
alarm_dead_low: bool = True
alarm_low: bool = False
alarm_high: bool = False
alarm_extreme_high: bool = True
# function actions
action_dead_low: Optional[str] = None
value_dead_low: str = "1"
action_low: Optional[str] = None
value_low: str = "1"
action_high: Optional[str] = None
value_high: str = "1"
action_extreme_high: Optional[str] = None
value_extreme_high: str = "1"
# interval while-in-state (0 = once on enter)
action_dead_low_interval: float = 1.0
action_low_interval: float = 1.0
action_high_interval: float = 1.0
action_extreme_high_interval: float = 1.0
action_operating: Optional[str] = None
value_operating: str = "1"
action_operating_interval: float = 1.0
# expression actions
expr_dead_low: Optional[str] = None
expr_low: Optional[str] = None
expr_high: Optional[str] = None
expr_extreme_high: Optional[str] = None
# expression target function (per state)
expr_target_dead_low: Optional[str] = None
expr_target_low: Optional[str] = None
expr_target_high: Optional[str] = None
expr_target_extreme_high: Optional[str] = None
expr_operating: Optional[str] = None
expr_target_operating: Optional[str] = None
expr_operating_interval: float = 1.0
# 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_low: str = "raw"
expr_x_source_operating: str = "raw"
expr_x_source_high: str = "raw"
expr_x_source_extreme_high: str = "raw"
# Optional expressions for dynamic thresholds (override numeric constants if provided)
expr_thr_dead_low: Optional[str] = None
expr_thr_low: Optional[str] = None
expr_thr_high: Optional[str] = None
expr_thr_extreme_high: Optional[str] = None
@dataclass
class VarInfo:
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
error: Optional[str] = None
thresholds: Thresholds = field(default_factory=Thresholds)
last_state: str = "UNKNOWN"
# analytics
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 # increases with each append to history (optimization for plots)
@dataclass
class ActionTask:
name: str
value: str
interval_s: float
# dataclass ActionTask (dodaj pole)
tag: Optional[str] = None
enabled: bool = True
last_run: float = 0.0
next_run: float = 0.0
task_id: int = 0
expr: Optional[str] = None
x_src: Optional[str] = None
y_src: Optional[str] = None
z_src: Optional[str] = None
x_mode: str = "raw" # "raw" | "x_avg" | "dx" | "dx_avg"
# =====================
# Scheduler thread
# =====================
class ActionScheduler(threading.Thread):
def __init__(self, get_base_url_cb, get_value_cb):
super().__init__(daemon=True)
self.get_base_url_cb = get_base_url_cb
self.get_value_cb = get_value_cb
self.tasks: Dict[int, ActionTask] = {}
self._lock = threading.Lock()
self._stop = threading.Event()
self._counter = 1
def add_task(self, task: ActionTask) -> int:
with self._lock:
tid = self._counter
self._counter += 1
task.task_id = tid
now = time.time()
task.next_run = now if task.interval_s == 0 else now + task.interval_s
self.tasks[tid] = task
return tid
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
]
for tid in to_del:
self.tasks.pop(tid, None)
def list_tasks(self) -> List[ActionTask]:
with self._lock:
return list(self.tasks.values())
def set_enabled(self, task_id: int, enabled: bool) -> None:
with self._lock:
t = self.tasks.get(task_id)
if t:
t.enabled = enabled
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):
# Optimized value fetching with local caching to avoid redundant get_stats_for calls
value_cache = {} # Local cache for this task execution
def get_cached_stats(src: Optional[str]) -> Optional[dict]:
"""Get stats with local caching to avoid redundant calls within single task."""
if not src:
return None
if src not in value_cache:
try:
value_cache[src] = self.get_value_cb(src)
except Exception:
logger.exception("Scheduler.run_task_once->get_value_cb")
value_cache[src] = None
return value_cache[src]
def pick(stats: Optional[dict], mode: str) -> Optional[float]:
if not stats:
return None
if mode == "x_avg":
return stats.get("x_avg")
if mode == "dx":
return stats.get("dx")
if mode == "dx_avg":
return stats.get("dx_avg")
return stats.get("x")
# Pre-fetch only unique sources (avoids redundant calls for same source)
unique_sources = set(filter(None, [task.x_src, task.y_src, task.z_src]))
for src in unique_sources:
get_cached_stats(src)
# Now get the specific stats (using cache)
xs = get_cached_stats(task.x_src)
ys = get_cached_stats(task.y_src)
zs = get_cached_stats(task.z_src)
x = pick(xs, getattr(task, "x_mode", "raw"))
y = pick(ys, getattr(task, "y_mode", "raw"))
z = pick(zs, getattr(task, "z_mode", "raw"))
if x is None:
x = 0.0
try:
computed = eval_user_expression(task.expr, x, y, z)
value_to_send = str(computed)
except Exception:
logger.exception("Scheduler.run_task_once->eval_user_expression")
# Return synthetic error without posting
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}
return http_post_query(base, params)
def stop(self) -> None:
self._stop.set()
def run(self) -> None:
# Pętla bez time.sleep; tylko _stop.wait(...)
while not self._stop.is_set():
now = time.time()
to_run: List[ActionTask] = []
with self._lock:
# wybierz zadania do uruchomienia
for t in list(self.tasks.values()):
if not t.enabled:
continue
if t.interval_s == 0:
# jednorazowe uruchom dokładnie raz (gdy nie było jeszcze run)
if t.last_run <= 0:
to_run.append(t)
else:
if now >= t.next_run:
to_run.append(t)
# wykonaj poza lockiem
for t in to_run:
try:
code, msg, _hdrs = self.run_task_once(t)
t.last_run = time.time()
if t.interval_s == 0:
# one-time - disable after execution
t.enabled = False
else:
t.next_run = t.last_run + max(0.0, float(t.interval_s))
except Exception:
# nie blokujemy pętli schedulera na wyjątkach z pojedynczego taska
logger.exception("Scheduler.run->run_task_once")
# short, interruptible sleep
# (nie używamy time.sleep; dzięki temu zamknięcie jest natychmiastowe)
self._stop.wait(0.01)
# =====================
# Discovery worker thread (non-blocking discovery)
# =====================
class DiscoveryWorker(threading.Thread):
def __init__(self, host: str, port: int, result_callback, error_callback):
super().__init__(daemon=True, name="DiscoveryWorker")
self.host = host
self.port = port
self.result_callback = result_callback
self.error_callback = error_callback
self._stop_event = threading.Event()
def stop(self):
"""Stop the discovery worker."""
self._stop_event.set()
def run(self):
"""Perform discovery in background thread."""
try:
logger.info(f"Starting discovery for {self.host}:{self.port}")
base = build_base_url(self.host, self.port)
discovered_vars: List[str] = []
discovered_funcs: List[str] = []
msgs = []
# Check if we should stop before each network operation
if self._stop_event.is_set():
return
# 1) Functions via WEBSERVER_LIST_VARIABLES_JSON (POST list)
s1, b1, _1 = http_get(base, {"variable": "WEBSERVER_LIST_VARIABLES_JSON"})
if s1 == 200 and b1:
_get_list, post_list = parse_weblist_names(b1)
if post_list:
discovered_funcs.extend(post_list)
else:
msgs.append(f"LIST_VARIABLES_JSON HTTP {s1}: {coerce_preview(b1, 120)}")
if self._stop_event.is_set():
return
# 2) Variables via WEBSERVER_BATCH_GET keys
s2, b2, _2 = http_get(base, {"variable": "WEBSERVER_BATCH_GET"})
if s2 == 200 and b2:
values_map = parse_batch_values(b2)
if values_map:
seen = set()
for k in values_map.keys():
kl = k.lower()
if kl not in seen:
seen.add(kl)
discovered_vars.append(k)
else:
msgs.append(f"BATCH_GET HTTP {s2}: {coerce_preview(b2, 120)}")
if self._stop_event.is_set():
return
# 3) Fallbacks
if not discovered_vars or not discovered_funcs:
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
)
if not 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)}")
# 4) embedded defaults
used_defaults = False
if not discovered_vars:
discovered_vars = list(DEFAULT_VARS)
used_defaults = True
if not discovered_funcs:
discovered_funcs = list(DEFAULT_FUNCTIONS)
if self._stop_event.is_set():
return
# Return results via callback
result = {
"vars": discovered_vars,
"funcs": discovered_funcs,
"msgs": msgs,
"used_defaults": used_defaults,
}
# Schedule callback on main thread
self.result_callback(result)
logger.info("Discovery completed successfully")
except Exception as e:
logger.exception("Discovery worker failed")
# Schedule error callback on main thread
self.error_callback(str(e))
# =====================
# Poller thread (uses BATCH_GET)
# =====================
class Poller(threading.Thread):
def __init__(
self,
host: str,
port: int,
variables_keys: List[str], # canonical lower-case keys
ui_queue: queue.Queue,
refresh_interval: float,
stop_event: threading.Event,
paused_event: threading.Event,
) -> None:
super().__init__(daemon=True)
self.host = host
self.port = port
self.variables_keys = variables_keys
self.ui_queue = ui_queue
self.refresh_interval = float(max(0.1, refresh_interval))
self.stop_event = stop_event
self.paused_event = paused_event
def run(self) -> None:
base_url = build_base_url(self.host, self.port)
logger.info("[Poller] start")
while not self.stop_event.is_set():
cycle_start = time.time()
try:
try:
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:
logger.exception("Poller.run->parse_batch_values")
values_map = None
if values_map:
lower_map = {k.lower(): v for k, v in values_map.items()}
self.ui_queue.put(("batch", lower_map))
for key in list(self.variables_keys):
if key in lower_map:
self.ui_queue.put(
("update", key, str(lower_map[key]), 200)
)
else:
self.ui_queue.put(
("error", key, 206, "Not in BATCH_GET payload")
)
else:
for key in list(self.variables_keys):
try:
st, b, _h = http_get(base_url, {"variable": key})
if st == 200:
self.ui_queue.put(("update", key, b, st))
else:
self.ui_queue.put(
("error", key, st, coerce_preview(b, 200))
)
except Exception:
logger.exception(f"Poller.run->http_get({key})")
else:
for key in list(self.variables_keys):
st, b, _h = http_get(base_url, {"variable": key})
if st == 200:
self.ui_queue.put(("update", key, b, st))
else:
self.ui_queue.put(
("error", key, st, coerce_preview(b, 200))
)
except Exception:
# nigdy nie wywalamy wątku na zewnątrz
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():
# czekaj 50ms lub do przerwania
self.stop_event.wait(0.05)
# Odmierz pozostały czas do końca interwału też przerywalnie
cycle_dt = time.time() - cycle_start
remaining = float(self.refresh_interval) - cycle_dt
if remaining > 0:
waited = 0.0
# czekamy w małych porcjach, by reagować na pause/stop
while waited < remaining and not self.stop_event.is_set():
if self.paused_event.is_set():
break
chunk = min(0.1, remaining - waited)
self.stop_event.wait(chunk)
waited += chunk
logger.info("[Poller] stop")
# sygnalizacja zakończenia
try:
self.ui_queue.put(("stopped", ""))
except Exception:
logger.exception("Poller.run(stop)")
# =====================
# GUI
# =====================
class App(tk.Tk):
def __init__(self) -> None:
super().__init__()
self.title("Control Board Monitor — v1.16")
self.geometry("1280x860")
# Canonical registry: lower-case key -> VarInfo
self.vars: Dict[str, VarInfo] = {}
# Thread-safe data protection and snapshots
self._data_lock = threading.Lock()
self._cached_avg_window = 60 # Cache avg window value for thread safety
self._cached_host = SERVER_HOST # Cache host value for thread safety
self._cached_port = SERVER_PORT # Cache port value for thread safety
# Cache for get_stats_for results to improve scheduler performance
self._stats_cache: Dict[str, dict] = {}
self._stats_cache_timestamp = 0.0
self.stop_event = threading.Event()
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.functions_list: List[str] = [] # preserve original casing
self._closing = False
# Removed unused _stop_event - was never read anywhere
# Window tracking initialization
self._plot_windows: Dict[str, object] = {} # Track plotting windows
self._qt_windows: Dict[str, tuple] = {} # Track Qt windows for cleanup
self.scheduler = ActionScheduler(
self._get_base_url_threadsafe, self.get_stats_for
)
self.scheduler.start()
# Discovery worker tracking
self._discovery_worker: Optional[DiscoveryWorker] = None
self._discovery_in_progress = False
# UI refresh optimization - debouncing
self._tree_refresh_timer: Optional[str] = None
self._tree_refresh_pending = False
self._tree_refresh_delay_ms = 100 # Debounce delay in milliseconds
# Centralized input validation methods
self._validation_cache = {} # Cache valid values to reduce UI disruption
controls = ttk.Frame(self, padding=10)
controls.pack(side=tk.TOP, fill=tk.X)
self.host_var = tk.StringVar(value=SERVER_HOST)
self.port_var = tk.IntVar(value=SERVER_PORT)
self.refresh_var = tk.DoubleVar(value=REFRESH_INTERVAL_S)
self.avg_window_var = tk.IntVar(value=AVERAGE_WINDOW_N)
# Initialize cached values
self._cached_avg_window = AVERAGE_WINDOW_N
# Add thread-safe callbacks for host/port changes
def _update_host(*_):
try:
with self._data_lock:
self._cached_host = self._get_validated_host(SERVER_HOST)
except Exception:
with self._data_lock:
self._cached_host = SERVER_HOST
def _update_port(*_):
try:
with self._data_lock:
self._cached_port = self._get_validated_port(SERVER_PORT)
except Exception:
with self._data_lock:
self._cached_port = SERVER_PORT
self.host_var.trace_add("write", _update_host)
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.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.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.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)
)
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
)
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.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))
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
)
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.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:
with self._data_lock:
self._cached_avg_window = max(1, int(self.avg_window_var.get()))
except Exception:
with self._data_lock:
self._cached_avg_window = 60
self.tree.heading("avg", text=f"Avg ({self.avg_window_var.get()})")
self.tree.heading("davg", text=f"Δ avg ({self.avg_window_var.get()})")
self.avg_window_var.trace_add("write", _update_avg_window)
self.tree.heading("#0", text="Variable")
self.tree.heading("value", text="Value")
self.tree.heading("delta", text="Δ last")
self.tree.heading("davg", text=f"Δ avg ({self.avg_window_var.get()})")
self.tree.heading("avg", text=f"Avg ({self.avg_window_var.get()})")
self.tree.heading("updated", text="Updated")
self.tree.heading("status", text="HTTP")
self.tree.column("#0", width=500, anchor="w")
self.tree.column("value", width=340, anchor="w")
self.tree.column("delta", width=110, anchor="e")
self.tree.column("davg", width=120, anchor="e")
self.tree.column("avg", width=120, anchor="e")
self.tree.column("updated", width=110, anchor="center")
self.tree.column("status", width=60, anchor="center")
style = ttk.Style(self)
try:
style.theme_use("clam")
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.bind("<Button-3>", self.on_tree_right_click)
self.tree.bind("<Double-1>", lambda _e: self.open_plot_window())
bottom = ttk.Frame(main)
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.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)
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.heading(col, text=hdr)
self.actions_tree.column(col, width=w, anchor="center")
self.actions_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
vsb2 = ttk.Scrollbar(bottom, orient="vertical", command=self.actions_tree.yview)
vsb2.pack(side=tk.RIGHT, fill=tk.Y)
self.actions_tree.configure(yscrollcommand=vsb2.set)
self._bind_mousewheel(self.actions_tree)
main.add(top_frame, weight=3)
main.add(bottom, weight=2)
footer = ttk.Frame(self, padding=10)
footer.pack(side=tk.BOTTOM, fill=tk.X)
self.cycle_lbl = ttk.Label(footer, text="Last cycle: —")
self.cycle_lbl.pack(side=tk.LEFT)
self.protocol("WM_DELETE_WINDOW", self.on_close)
# 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
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_separator()
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()}"
),
)
# 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)
filemenu.add_separator()
filemenu.add_command(label="Exit", command=self.on_close)
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_separator()
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)
# Add "Options" to menubar at the end
menubar.add_cascade(label="Options", menu=opmenu)
# Connect menubar to window
self.config(menu=menubar)
self.functions_list = list(DEFAULT_FUNCTIONS)
self.func_combo["values"] = self.functions_list
if self.functions_list and not self.func_name_var.get():
self.func_name_var.set(self.functions_list[0])
# Stable IDs
self._group_ids: Dict[str, str] = {}
self._var_ids: Dict[str, str] = {}
# Color tag cache: color_hex -> tag_name
self._color_tags: Dict[str, str] = {}
# Track repeating actions per (var_key,state)
self.state_tasks: Dict[tuple, int] = {}
self.expr_state_tasks: Dict[tuple, int] = {}
# Latest values from batch (lowercased keys)
self.latest_values: Dict[str, str] = {}
self.known_variables: List[str] = []
# Trigger discovery shortly after startup so function list populates
self.after(200, self.reload_discovery)
# Live watch for refresh interval changes
self._last_refresh_val = float(self.refresh_var.get())
self.after(2000, self._watch_refresh_interval)
# 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="Remove", command=self.remove_selected_task)
self.actions_tree.bind("<Button-3>", self.on_actions_right_click)
if _pyqtgraph_available:
self._qt_ensure_app()
def _get_validated_host(self, fallback: str = "localhost") -> str:
"""Get validated host with fallback on invalid input."""
try:
host = self.host_var.get().strip()
if not host:
logger.warning("Empty host field, using fallback")
return fallback
# Cache valid host to reduce UI disruption
self._validation_cache["host"] = host
return host
except Exception as e:
logger.warning(f"Invalid host input: {e}, using fallback")
cached = self._validation_cache.get("host", fallback)
return cached
def _get_validated_port(self, fallback: int = 8080) -> int:
"""Get validated port with fallback on invalid input."""
try:
port_raw = self.port_var.get()
# Handle both string and int inputs (for testing)
if isinstance(port_raw, int):
port = port_raw
else:
port_str = str(port_raw).strip()
if not port_str:
logger.warning("Empty port field, using fallback")
return fallback
port = int(port_str)
if not (1 <= port <= 65535):
logger.warning(f"Port {port} out of valid range, using fallback")
return self._validation_cache.get("port", fallback)
# Cache valid port to reduce UI disruption
self._validation_cache["port"] = port
return port
except ValueError as e:
logger.warning(
f"Invalid port input '{self.port_var.get()}': {e}, using fallback"
)
cached = self._validation_cache.get("port", fallback)
return cached
except Exception as e:
logger.warning(f"Unexpected error validating port: {e}, using fallback")
cached = self._validation_cache.get("port", fallback)
return cached
def _get_base_url_validated(self) -> str:
"""Get base URL using validated host and port."""
host = self._get_validated_host()
port = self._get_validated_port()
return build_base_url(host, port)
def _qt_ensure_app(self):
"""Utwórz (raz) QApplication wspólną dla wszystkich okien Qt."""
try:
from pyqtgraph.Qt import QtWidgets
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(
[]
)
def _ensure_qt_pump(self):
if getattr(self, "_qt_pump_on", False):
return
self._qt_pump_on = True
self.after(16, self._pump_qt_events)
def _maybe_stop_qt_pump(self):
"""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
)
# Najprostsze i najpewniejsze: sprawdzamy widoczność/istnienie
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:
logger.exception("[qt] stop-pump check error")
# Just in case don't disable pump on error
def _pump_qt_events(self):
"""Qt event pumping from Tk loop - without blocking and without reentrancy."""
try:
# If no QT windows - don't pump, but reset the flag so it can restart
if not getattr(self, "_plot_windows", None):
self._qt_pump_on = False
return
if not any(
getattr(w, "_backend", "") == "pyqtgraph"
for w in self._plot_windows.values()
):
self._qt_pump_on = False
return
from pyqtgraph.Qt import QtCore, QtWidgets
app = getattr(self, "_qt_app", None) or QtWidgets.QApplication.instance()
if app is None:
return
# Reentrancy guard - if we're already inside, give up
if getattr(self, "_qt_pumping", False):
return
self._qt_pumping = True
try:
# Don't block; process only what's already in queue
app.sendPostedEvents()
# Use correct PySide6 enum values
app.processEvents(
QtCore.QEventLoop.AllEvents, 0 # DontWait = 0 in PySide6
)
finally:
self._qt_pumping = False
except Exception:
logger.exception("_pump_qt_events")
finally:
# 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()
):
# ~60 FPS max
self.after(16, self._pump_qt_events)
def _get_base_url_threadsafe(self) -> str:
"""Thread-safe version of getting base URL using cached values."""
with self._data_lock:
return build_base_url(self._cached_host, self._cached_port)
def _invalidate_stats_cache(self):
"""Invalidate the stats cache when data changes."""
with self._data_lock:
self._stats_cache.clear()
self._stats_cache_timestamp = time.time()
def get_stats_for(self, name: Optional[str]) -> Optional[dict]:
if not name:
return None
key = str(name).lower()
# Check cache first (with 100ms cache window for scheduler performance)
current_time = time.time()
cache_window = 0.1 # 100ms cache to avoid redundant calculations
with self._data_lock:
if (
key in self._stats_cache
and current_time - self._stats_cache_timestamp < cache_window
):
return self._stats_cache[key].copy()
vi = self.vars.get(key)
def _avg(dq, n):
if not dq or n <= 0 or len(dq) < n:
return None
lst = list(dq)[-n:]
try:
return sum(lst) / float(n)
except Exception:
logger.exception("get_stats_for avg")
return None
# Use cached value instead of Tkinter variable to ensure thread safety
n = max(1, self._cached_avg_window)
result = None
if vi:
result = {
"x": parse_first_float(vi.last_value),
"x_avg": _avg(vi.history, n),
"dx": vi.delta_last,
"dx_avg": _avg(vi.history_delta, n),
}
else:
# fallback for non-monitored variables
val = self.get_current_value(name)
result = {"x": val, "x_avg": None, "dx": None, "dx_avg": None}
# Cache the result
if result is not None:
with self._data_lock:
self._stats_cache[key] = result.copy()
return result
# --- Discovery ---
def reload_discovery(self) -> None:
"""Start discovery in background thread to avoid blocking UI."""
# Prevent multiple concurrent discovery operations
if self._discovery_in_progress:
logger.debug("Discovery already in progress, ignoring request")
return
# Stop any existing discovery worker
if self._discovery_worker and self._discovery_worker.is_alive():
self._discovery_worker.stop()
self._discovery_worker.join(timeout=1.0)
# Update UI to show discovery is starting
self._discovery_in_progress = True
self.status_lbl.configure(text="Discovering variables and functions...")
# Get validated connection parameters
host = self._get_validated_host()
port = self._get_validated_port()
# Create and start discovery worker
self._discovery_worker = DiscoveryWorker(
host=host,
port=port,
result_callback=self._on_discovery_success,
error_callback=self._on_discovery_error,
)
self._discovery_worker.start()
logger.debug(f"Started background discovery for {host}:{port}")
def _on_discovery_success(self, result: dict) -> None:
"""Handle successful discovery completion (called from worker thread)."""
# Schedule UI update on main thread
self.after_idle(lambda: self._apply_discovery_results(result))
def _on_discovery_error(self, error_msg: str) -> None:
"""Handle discovery error (called from worker thread)."""
# Schedule UI update on main thread
self.after_idle(lambda: self._handle_discovery_error(error_msg))
def _apply_discovery_results(self, result: dict) -> None:
"""Apply discovery results to UI (runs on main thread)."""
try:
discovered_vars = result["vars"]
discovered_funcs = result["funcs"]
msgs = result["msgs"]
used_defaults = result["used_defaults"]
# Apply results
self.functions_list = discovered_funcs
self.func_combo["values"] = self.functions_list
if self.functions_list and not self.func_name_var.get():
self.func_name_var.set(self.functions_list[0])
# Remember known variables for y/z source selectors
self.known_variables = list(discovered_vars)
self.show_selector_dialog(discovered_vars)
# Update status
if used_defaults:
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)"
)
else:
self.status_lbl.configure(
text=f"Discovered: vars {len(discovered_vars)} | funcs {len(discovered_funcs)}"
)
logger.info(
f"Discovery completed: {len(discovered_vars)} vars, {len(discovered_funcs)} funcs"
)
except Exception as e:
logger.exception("Failed to apply discovery results")
self._handle_discovery_error(f"Failed to apply results: {e}")
finally:
self._discovery_in_progress = False
def _handle_discovery_error(self, error_msg: str) -> None:
"""Handle discovery error on main thread."""
logger.error(f"Discovery failed: {error_msg}")
self.status_lbl.configure(text=f"Discovery failed: {error_msg}")
# Load defaults as fallback
self.functions_list = list(DEFAULT_FUNCTIONS)
self.func_combo["values"] = self.functions_list
if self.functions_list and not self.func_name_var.get():
self.func_name_var.set(self.functions_list[0])
self.known_variables = list(DEFAULT_VARS)
self.show_selector_dialog(list(DEFAULT_VARS))
messagebox.showerror(
"Discovery Error",
f"Failed to discover server variables and functions:\n{error_msg}\n\n"
"Loaded default values so you can proceed.",
)
self._discovery_in_progress = False
# --- 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
tylko te, które powinny działać w bieżącym stanie według aktualnych progów.
Interwał 0 => run once; >0 => cykliczne.
"""
if not hasattr(self, "scheduler") or self.scheduler is None:
return
t = vi.thresholds
# 1) Usuń istniejące zadania powiązane z tą zmienną (po tagu):
for state in ("DEAD_LOW", "LOW", "OPERATING", "HIGH", "EXTREME_HIGH"):
tag = f"state::{vi.key}::{state}"
self.scheduler.remove_tasks_by_tag(tag)
# 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),
),
"LOW": (
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_interval", 1.0),
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),
),
"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),
),
}
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]
# 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.
# Don't add new task if there's nothing to execute.
if not any([name, expr, expr_target]):
self.refresh_actions_tree()
return
tag = f"state::{vi.key}::{curr_state}"
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
interval_s=float(interval_s),
expr=expr,
x_src=vi.display_name,
y_src=getattr(t, "y_source", None),
z_src=getattr(t, "z_source", None),
tag=tag,
)
else:
# zwykły POST do 'name' z 'value'
task = ActionTask(
name=name or "",
value=str(value),
interval_s=float(interval_s),
tag=tag,
)
self.scheduler.add_task(task)
self.refresh_actions_tree()
def show_selector_dialog(self, discovered: List[str]) -> None:
seen = set()
disp = []
for n in discovered:
kl = n.lower()
if kl not in seen:
seen.add(kl)
disp.append(n)
dlg = tk.Toplevel(self)
dlg.title("Select Variables to Monitor")
dlg.geometry("600x540")
dlg.transient(self)
dlg.grab_set()
temp_selected = {k: True for k in self.variables_keys}
top = ttk.Frame(dlg, padding=8)
top.pack(side=tk.TOP, fill=tk.X)
ttk.Label(top, text="Filter:").pack(side=tk.LEFT)
filt_var = tk.StringVar(value="")
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)
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(
"<Configure>", 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")
def _mw(event):
if event.delta:
canvas.yview_scroll(int(-1 * (event.delta / 120)), "units")
return "break"
def _mw_up(_e):
canvas.yview_scroll(-1, "units")
return "break"
def _mw_down(_e):
canvas.yview_scroll(1, "units")
return "break"
canvas.bind("<MouseWheel>", _mw)
inner.bind("<MouseWheel>", _mw)
canvas.bind("<Button-4>", _mw_up)
canvas.bind("<Button-5>", _mw_down)
inner.bind("<Button-4>", _mw_up)
inner.bind("<Button-5>", _mw_down)
rows: List[Tuple[str, str, tk.BooleanVar]] = []
def rebuild():
for child in inner.winfo_children():
child.destroy()
rows.clear()
f = filt_var.get().strip().lower()
for name in disp:
if f and f not in name.lower():
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
def clear_all():
for _, key, var in rows:
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]
for name in disp:
key = name.lower()
if temp_selected.get(key, False) and key not in new_order:
new_order.append(key)
self.variables_keys = new_order
new_vars: Dict[str, VarInfo] = {}
for key in self.variables_keys:
if key in self.vars:
vi = self.vars[key]
else:
disp_name = next((n for n, k, _ in rows if k == key), key)
vi = VarInfo(key=key, display_name=disp_name)
new_vars[key] = vi
self.vars = new_vars
# If poller is running, update its live list
if self.poller and self.poller.is_alive():
self.poller.variables_keys = self.variables_keys[:]
self._refresh_tree_immediate() # Immediate refresh for user action completion
dlg.destroy()
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 ---
def on_actions_right_click(self, event):
try:
iid = self.actions_tree.identify_row(event.y)
if iid:
self.actions_tree.selection_set(iid)
finally:
self.actions_menu.tk_popup(event.x_root, event.y_root)
def run_func_once(self):
fname = self.func_name_var.get().strip()
if not fname:
messagebox.showinfo("No function", "Choose a function first.")
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)}",
)
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))
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.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)
)
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
tid = int(sel[0])
tasks = {t.task_id: t for t in self.scheduler.list_tasks()}
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
tid = int(sel[0])
tasks = {t.task_id: t for t in self.scheduler.list_tasks()}
t = tasks.get(tid)
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)}",
)
def remove_selected_task(self):
sel = self.actions_tree.selection()
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
key = str(name).lower()
if hasattr(self, "latest_values") and key in self.latest_values:
return parse_first_float(self.latest_values.get(key))
base = self._get_base_url_validated()
st, bd, _h = http_get(base, {"variable": key})
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()
focus_before = self.actions_tree.focus()
# 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")
)
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",
),
)
# 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)
# --- [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
):
try:
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)
def _decimate(self, seq, max_pts: int):
"""Prosta decymacja do maks. liczby punktów: zwraca (xs, ys)."""
if not seq:
return [], []
if len(seq) <= max_pts:
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)]
return xs, ys
def _plot_tick(self):
"""
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 []
)
if not wins:
self._plot_timer = None
return
# Zbierz żywe okna per backend
alive = []
for key, win in wins:
backend = getattr(win, "_backend", "")
try:
if backend in ("matplotlib", "canvas"):
# Tk toplevel - check if window exists and is properly initialized
if (
hasattr(win, "winfo_exists")
and hasattr(win, "_key")
and win.winfo_exists()
):
try:
# Additional check: ensure window is fully realized
win.winfo_width() # This will fail if window isn't ready
alive.append((key, win))
except tk.TclError as e:
# Check if window was just created (give it a chance)
creation_time = getattr(win, "_creation_time", 0)
current_time = time.time()
if (
current_time - creation_time < 2.0
): # Less than 2 seconds old
logger.debug(
f"Window {key} still initializing ({current_time - creation_time:.1f}s old), keeping in registry"
)
alive.append((key, win)) # Keep it for now
else:
logger.info(
f"Window {key} not ready after timeout, removing: {e}"
)
self._plot_windows.pop(key, None)
elif backend == "pyqtgraph":
# Qt sprawdź, czy widget widoczny
qt = getattr(win, "_qt_widget", None)
if qt is not None:
try:
vis = bool(qt.isVisible())
except Exception:
logger.exception("pyqtgraph isVisible")
vis = True
if vis:
alive.append((key, win))
else:
# nieznane (pomijamy)
pass
except Exception:
logger.exception(
f"_plot_tick(check window alive) - key: {key}, backend: {getattr(win, '_backend', 'unknown')}"
)
# window died - remove from registry
try:
removed = self._plot_windows.pop(key, None)
if removed:
logger.info(f"Removed dead window from registry: {key}")
except Exception:
logger.exception("_plot_tick(remove dead window)")
pass
if not alive:
self._plot_timer = None
return
for key, win in alive:
try:
vi = self.vars.get(key)
if not vi:
continue
# 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}"
)
if getattr(win, "_last_title", None) != new_title:
try:
# Tk
if hasattr(win, "title"):
win.title(new_title)
except Exception:
logger.exception("_plot_tick(update title Tk)")
pass
try:
# Qt
if getattr(win, "_backend", "") == "pyqtgraph":
qtw = getattr(win, "_qt_widget", None)
if qtw is not None and hasattr(qtw, "setWindowTitle"):
qtw.setWindowTitle(new_title)
except Exception:
logger.exception("_plot_tick(update title Qt)")
pass
win._last_title = new_title
# Pomiń rysowanie, gdy brak nowych danych
last_ver = getattr(win, "_last_seen_ver", -1)
if getattr(vi, "hist_ver", 0) == last_ver:
continue
# 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:
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:
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 []
)
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 {}
)
# backend-specyficzny update
b = getattr(win, "_backend", "")
if b == "matplotlib":
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
)
win._last_seen_ver = getattr(vi, "hist_ver", win._last_seen_ver)
except Exception:
logger.exception("_plot_tick(one window)")
continue
# kolejny tick
try:
ms = int(max(100, float(self.refresh_var.get()) * 1000))
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."
)
return
self.stop_event.clear()
self.paused_event.clear()
self.poller = Poller(
host=self._get_validated_host(),
port=self._get_validated_port(),
variables_keys=self.variables_keys,
ui_queue=self.ui_queue,
refresh_interval=float(self.refresh_var.get()),
stop_event=self.stop_event,
paused_event=self.paused_event,
)
self.poller.start()
self.start_btn.configure(state="disabled")
self.pause_btn.configure(state="normal", text="Pause")
self.status_lbl.configure(text="Running…")
self.after(100, self.drain_queue)
def toggle_pause(self) -> None:
if not (self.poller and self.poller.is_alive()):
return
if self.paused_event.is_set():
self.paused_event.clear()
self.pause_btn.configure(text="Pause")
self.status_lbl.configure(text="Running…")
else:
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):
if event.delta:
widget.yview_scroll(int(-1 * (event.delta / 120)), "units")
return "break"
# Linux (X11)
def _mw_up(_e):
widget.yview_scroll(-1, "units")
return "break"
def _mw_down(_e):
widget.yview_scroll(1, "units")
return "break"
widget.bind("<MouseWheel>", _mw)
widget.bind("<Button-4>", _mw_up)
widget.bind("<Button-5>", _mw_down)
def drain_queue(self) -> None:
try:
while True:
item = self.ui_queue.get_nowait()
kind = item[0]
if kind == "update":
_, key, value, status = item
with self._data_lock:
vi = self.vars.get(key)
if not vi:
vi = VarInfo(key=key, display_name=key)
prev_num = parse_first_float(vi.last_value)
curr_num = parse_first_float(value)
vi.last_value = value
vi.last_updated = time.time()
vi.last_status = status
vi.error = None
# analytics update
if prev_num is not None and curr_num is not None:
vi.delta_last = curr_num - prev_num
try:
vi.history_delta.append(vi.delta_last)
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:
logger.exception(
"Scheduler.run_task_once->get_value_cb"
)
pass
self.vars[key] = vi
# Invalidate stats cache when data changes
self._invalidate_stats_cache()
self.evaluate_thresholds(vi, value)
elif kind == "error":
_, key, status, msg = item
with self._data_lock:
vi = self.vars.get(key) or VarInfo(key=key, display_name=key)
vi.last_status = status
vi.error = msg
vi.last_updated = time.time()
self.vars[key] = vi
# Invalidate stats cache when error data changes
self._invalidate_stats_cache()
elif kind == "cycle_start":
_, hhmmss = item
self.cycle_lbl.configure(text=f"Last cycle start: {hhmmss}")
elif kind == "batch":
_, mapping = item
if isinstance(mapping, dict):
self.latest_values.update(mapping)
# refresh known_variables list from batch keys (preserve case with existing where possible)
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
)
elif kind == "stopped":
self.status_lbl.configure(text="Stopped")
elif kind == "batch_fmt":
_, fmt = item
# nie nadpisuj info o błędach/paused tylko dołóż sufiks
cur = self.status_lbl.cget("text") or ""
base = cur.split(" | ")[0] # zachowaj, co było przedtem
self.status_lbl.configure(text=f"{base} | batch: {fmt}")
self.ui_queue.task_done()
except queue.Empty:
pass
self.refresh_tree()
self.refresh_actions_tree()
if self.poller and self.poller.is_alive():
self.after(250, self.drain_queue)
# --- Threshold logic & rendering ---
def on_tree_right_click(self, event):
try:
iid = self.tree.identify_row(event.y)
if iid:
self.tree.selection_set(iid)
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()
if not sel:
tk.messagebox.showwarning("Plot", "Najpierw zaznacz zmienną w tabeli.")
return
iid = sel[0]
if not iid.startswith("var:"):
tk.messagebox.showwarning(
"Plot", "Wybierz wiersz ze zmienną (nie grupę)."
)
return
key = iid.split("var:", 1)[1]
else:
key = str(var_key).lower()
if key not in self.vars:
for k, vi in self.vars.items():
if vi.display_name.lower() == str(var_key).lower():
key = k
break
if key not in self.vars:
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
if backend == "pyqtgraph":
if _pyqtgraph_available:
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")
dlg.geometry("360x220")
dlg.transient(self)
dlg.grab_set()
# Backend
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)
)
state = "normal" if _pyqtgraph_available else "disabled"
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))
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.Label(box, text="Default max draw pts:").grid(
row=1, column=0, sticky="w", pady=(6, 0)
)
md = tk.IntVar(value=int(self.default_maxpts_var.get()))
ttk.Spinbox(
box, from_=100, to=5000, increment=50, textvariable=md, width=6
).grid(row=1, column=1, padx=6, pady=(6, 0))
# Show thresholds on plots
chk_box = ttk.Frame(dlg)
chk_box.pack(fill=tk.X, padx=10, pady=(10, 0))
show_thr_local = tk.BooleanVar(
value=bool(self.display_show_thresholds_var.get())
)
ttk.Checkbutton(
chk_box, text="Show thresholds (plots)", variable=show_thr_local
).pack(anchor="w")
# Buttons
bar = ttk.Frame(dlg)
bar.pack(fill=tk.X, pady=12, padx=10)
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.",
)
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)
def open_thresholds_dialog(self):
sel = self.tree.selection()
expr_xsrc_vars = {}
if not sel:
messagebox.showinfo("No selection", "Select a variable first.")
return
iid = sel[0]
if not iid.startswith("var:"):
messagebox.showinfo(
"Select variable", "Select a specific variable row (not a group)."
)
return
key = iid.split("var:", 1)[1]
vi = self.vars.get(key)
if not vi:
messagebox.showinfo("Missing", "Selected variable is not available.")
return
dlg = tk.Toplevel(self)
dlg.title(f"Thresholds, actions & alarms — {vi.display_name}")
dlg.geometry("980x700")
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),
}
# Scrollable body
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")
def _on_cfg(event=None):
canvas.configure(scrollregion=canvas.bbox("all"))
canvas.itemconfigure(win, width=canvas.winfo_width())
grid.bind("<Configure>", _on_cfg)
canvas.bind("<Configure>", _on_cfg)
# thresholds
ttk.Label(grid, text="DEAD LOW").grid(row=0, column=0, sticky="w", pady=2)
ttk.Label(grid, text="LOW").grid(row=1, column=0, sticky="w", pady=2)
ttk.Label(grid, text="HIGH").grid(row=2, column=0, sticky="w", pady=2)
ttk.Label(grid, text="EXTREME HIGH").grid(row=3, column=0, sticky="w", pady=2)
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
)
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)
extreme_high_var = mk_val(t.extreme_high, "extreme_high", 3)
ttk.Label(grid, text="Alarm on enter:").grid(row=0, column=3, sticky="w")
alarm_dead_low = tk.BooleanVar(value=t.alarm_dead_low)
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"
)
# 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"
)
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
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.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
)
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
)
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)
)
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.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.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 = {}
for i, (label, keyname, tkey) in enumerate(expr_rows):
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.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.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
)
# mapowanie nazwy wiersza na pole w Thresholds:
statekey = {
"Dead Low expr": "dead_low",
"Low expr": "low",
"Operating expr": "operating",
"High expr": "high",
"Extreme High expr": "extreme_high",
}[label]
field_name = f"expr_x_source_{statekey}"
src_combo.set(getattr(t, field_name, "raw"))
src_combo.grid(row=r, column=5, sticky="w")
expr_xsrc_vars[field_name] = src_combo
# Bottom bar
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]]:
# Przyjmujemy tk.StringVar / tk.Entry / str
if hasattr(var_or_entry, "get"):
s = var_or_entry.get().strip()
else:
s = str(var_or_entry).strip()
if s == "":
return None, None
try:
return float(s), None
except Exception:
logger.exception("Failed to parse float value")
# potraktuj jako wyrażenie (np. "x_avg - 5")
return None, s
# USE CORRECT VARIABLES:
dead_val, dead_expr = _parse_threshold_field(dead_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)
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,
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_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_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_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_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
),
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
),
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(),
)
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:
logger.exception("App.open_thresholds_dialog->do_save on_state_change")
pass
dlg.destroy()
self._reschedule_state_tasks_for(vi)
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 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:
logger.exception("App.on_state_change remove_task")
pass
# stop any repeating expr task for previous state
etid = self.expr_state_tasks.pop((vi.key, prev_state), None)
if etid is not None:
try:
self.scheduler.remove_task(etid)
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
alarm_map = {
"DEAD_LOW": t.alarm_dead_low,
"LOW": t.alarm_low,
"OPERATING": False,
"HIGH": t.alarm_high,
"EXTREME_HIGH": t.alarm_extreme_high,
}
if alarm_map.get(state):
try:
import winsound
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:
logger.exception("App.on_enter_state winsound.Beep")
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",
}.get(state)
target_key = {
"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
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,
}
y_source = action_map.get(state)
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:
val = parse_first_float(self.latest_values.get(key))
else:
base = self._get_base_url_validated()
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
x_variants = self.get_stats_for(vi.key) or {}
mode_map = {
"DEAD_LOW": t.expr_x_source_dead_low,
"LOW": t.expr_x_source_low,
"OPERATING": t.expr_x_source_operating,
"HIGH": t.expr_x_source_high,
"EXTREME_HIGH": t.expr_x_source_extreme_high,
}
x_for_expr = {
"raw": x_variants.get("x"),
"x_avg": x_variants.get("x_avg"),
"dx": x_variants.get("dx"),
"dx_avg": x_variants.get("dx_avg"),
}.get(mode_map.get(state, "raw"), x_variants.get("x"))
if expr and x_for_expr is not None:
try:
computed = eval_user_expression(expr, x_for_expr, y_val, z_val)
if target_func:
base = self._get_base_url_validated()
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"
)
except Exception as 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),
"LOW": (t.action_low, t.value_low),
"OPERATING": (t.action_operating, t.value_operating),
"HIGH": (t.action_high, t.value_high),
"EXTREME_HIGH": (t.action_extreme_high, t.value_extreme_high),
}
act, val = act_map.get(state, (None, "1"))
if act:
# Once on enter if interval == 0
interval_map = {
"DEAD_LOW": t.action_dead_low_interval,
"LOW": t.action_low_interval,
"OPERATING": t.action_operating_interval,
"HIGH": t.action_high_interval,
"EXTREME_HIGH": t.action_extreme_high_interval,
}
interval = float(interval_map.get(state, 0) or 0.0)
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))
)
self.state_tasks[(vi.key, state)] = tid
# Expression scheduling while in state
expr_key_map = {
"DEAD_LOW": ("expr_dead_low", "expr_target_dead_low"),
"LOW": ("expr_low", "expr_target_low"),
"OPERATING": ("expr_operating", "expr_target_operating"),
"HIGH": ("expr_high", "expr_target_high"),
"EXTREME_HIGH": ("expr_extreme_high", "expr_target_extreme_high"),
}
ekey, tkey = expr_key_map.get(state, (None, None))
expr_str = getattr(t, ekey) if ekey else None
expr_target = getattr(t, tkey) if tkey else None
if expr_str and expr_target:
# Use action intervals as default pacing for expressions
expr_interval = {
"DEAD_LOW": t.action_dead_low_interval,
"LOW": t.action_low_interval,
"OPERATING": t.action_operating_interval,
"HIGH": t.action_high_interval,
"EXTREME_HIGH": t.action_extreme_high_interval,
}.get(state, 1.0) or 1.0
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,
)
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"),
)
tid = self.scheduler.add_task(task)
self.expr_state_tasks[(vi.key, state)] = tid
# --- Tree helpers & rendering ---
def _ensure_group(self, group: str) -> str:
iid = self._group_ids.get(group)
if iid and self.tree.exists(iid):
return iid
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
)
return iid
def _ensure_var(self, group_iid: str, key: str, display_name: str) -> str:
iid = self._var_ids.get(key)
if iid and self.tree.exists(iid):
return iid
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=("", "", "")
)
return iid
def _get_color_tag(self, hex_color: str) -> str:
tag = self._color_tags.get(hex_color)
if tag:
return tag
tag = f"col_{hex_color[1:]}"
self._color_tags[hex_color] = tag
try:
self.tree.tag_configure(tag, background=hex_color)
except Exception:
logger.exception("App._get_color_tag")
pass
return tag
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
zdefiniowano formuły expr_thr_*. Zwracamy też 'mid'=(low+high)/2 jeżeli możliwe.
"""
logger.debug(f"[_thr] key={vi.key} start")
t = vi.thresholds
stats_x = self.get_stats_for(vi.key)
stats_y = self.get_stats_for(getattr(t, "y_source", None))
stats_z = self.get_stats_for(getattr(t, "z_source", None))
def eff(num, expr):
if expr:
try:
# Evaluate threshold expression
v = eval_threshold_expr(expr, stats_x, stats_y, stats_z)
logger.debug(f"[_thr] {expr} -> {v} for {vi.key}")
if v is not None:
return float(v)
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}"
)
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"],
)
prev = vi.last_state
state = "OPERATING"
if x is None:
state = "OPERATING"
else:
dl = dead if dead is not None else -float("inf")
lo = low if low is not None else -float("inf")
hi = high if high is not None else float("inf")
ex = ext if ext is not None else float("inf")
if x < dl:
state = "DEAD_LOW"
elif x < lo:
state = "LOW"
elif x < hi:
state = "OPERATING"
elif x < ex:
state = "HIGH"
else:
state = "EXTREME_HIGH"
if state != prev:
self.on_state_change(vi, prev, state, x)
vi.last_state = state
except Exception:
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_high = thr["high"]
thr_ext = thr["extreme"]
try:
x = (
float(x_value)
if x_value is not None
else parse_first_float(vi.last_value)
)
except Exception:
logger.exception("_state_color parse_first_float")
x = None
if x is None:
return "#E9ECEF" # brak danych
if thr_dead is None:
thr_dead = -float("inf")
if thr_low is None and thr_high is None:
thr_low, thr_high = x - 1e-6, x + 1e-6
if thr_low is None:
thr_low = thr_dead if thr_dead != -float("inf") else (x - 1e-6)
if thr_high is None:
thr_high = max(thr_low, x + 1e-6)
if thr_ext is None:
thr_ext = float("inf")
def _hex(c):
return "#{:02X}{:02X}{:02X}".format(*c)
def _lerp(c1, c2, t):
t = 0.0 if t < 0 else 1.0 if t > 1 else t
return (
int(c1[0] + (c2[0] - c1[0]) * t),
int(c1[1] + (c2[1] - c1[1]) * t),
int(c1[2] + (c2[2] - c1[2]) * t),
)
Y_LO, Y_HI = (255, 244, 178), (255, 149, 0)
G_OK = (46, 204, 113)
R_LO, R_HI = (255, 138, 128), (213, 0, 0)
if x <= thr_low:
denom = (thr_low - thr_dead) if (thr_low > thr_dead) else 1.0
t = (thr_low - x) / max(denom, 1e-9)
return _hex(_lerp(Y_LO, Y_HI, t))
if x <= thr_high:
return _hex(G_OK)
denom = (thr_ext - thr_high) if (thr_ext > thr_high) else 1.0
t = (x - thr_high) / max(denom, 1e-9)
return _hex(_lerp(R_LO, R_HI, t))
except Exception:
logger.exception("_state_color")
return "#E9ECEF"
def request_tree_refresh(self) -> None:
"""Request a tree refresh with debouncing to improve performance."""
# Cancel any pending refresh
if self._tree_refresh_timer:
self.after_cancel(self._tree_refresh_timer)
self._tree_refresh_timer = None
# Schedule a new refresh after delay
self._tree_refresh_pending = True
self._tree_refresh_timer = self.after(
self._tree_refresh_delay_ms, self._perform_tree_refresh
)
def _perform_tree_refresh(self) -> None:
"""Perform the actual tree refresh (called after debounce delay)."""
try:
self._tree_refresh_timer = None
self._tree_refresh_pending = False
self._refresh_tree_immediate()
except Exception:
logger.exception("Error in debounced tree refresh")
def refresh_tree(self) -> None:
"""Public interface for tree refresh - uses debouncing by default."""
self.request_tree_refresh()
def _refresh_tree_immediate(self) -> None:
"""Immediate tree refresh without debouncing (internal use)."""
filt = self.filter_var.get().strip().lower()
groups: Dict[str, List[VarInfo]] = {}
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())
):
continue
prefix = text.split("_", 1)[0] if "_" in text else "MISC"
groups.setdefault(prefix, []).append(vi)
sel = self.tree.selection()
sel_iid = sel[0] if sel else None
valid_var_iids = set()
valid_group_iids = set()
for g in sorted(groups.keys(), key=lambda s: s.lower()):
gid = self._ensure_group(g)
valid_group_iids.add(gid)
for vi in sorted(groups[g], key=lambda x: x.display_name.lower()):
vid = self._ensure_var(gid, vi.key, vi.display_name)
valid_var_iids.add(vid)
updated_str = "—"
if vi.last_updated:
updated_str = datetime.fromtimestamp(vi.last_updated).strftime(
"%H:%M:%S"
)
if vi.error:
value_preview = f"[ERR] {vi.error}"
else:
value_preview = coerce_preview(vi.last_value, 120)
# Δ last
delta_str = "N/A"
if vi.delta_last is not None:
try:
delta_str = f"{vi.delta_last:+.6g}"
except Exception:
logger.exception("_state_color delta_last")
delta_str = str(vi.delta_last)
# Δ avg(N)
davg_str = "N/A"
try:
n = max(1, int(self.avg_window_var.get()))
except Exception:
n = AVERAGE_WINDOW_N
if hasattr(vi, "history_delta") and vi.history_delta:
dvals = list(vi.history_delta)[-n:]
if len(dvals) >= n:
try:
davg_val = sum(dvals) / float(n)
davg_str = f"{davg_val:.6g}"
except Exception:
logger.exception("_state_color davg_last")
davg_str = "N/A"
# value avg(N)
avg_str = "N/A"
if hasattr(vi, "history") and vi.history:
vals = list(vi.history)[-n:]
if len(vals) >= n:
try:
avg_val = sum(vals) / float(n)
avg_str = f"{avg_val:.6g}"
except Exception:
logger.exception("_state_color avg_last")
avg_str = "N/A"
x = parse_first_float(vi.last_value)
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,),
)
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)
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)
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
):
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("<KeyRelease>", on_key)
def _watch_refresh_interval(self):
try:
val = float(self.refresh_var.get())
except Exception:
logger.exception("_watch_refresh_interval parse float")
val = self._last_refresh_val
if val != self._last_refresh_val:
self._last_refresh_val = val
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:
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 "")
)
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))
val = tk.StringVar(value="1")
ttk.Entry(top, textvariable=val, width=16).pack()
def go():
fname = name.get().strip()
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)}",
)
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 "")
)
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
)
# Fixed value input
val = tk.StringVar(value="1")
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))
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))
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
)
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
)
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
)
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))
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.pack(side=tk.LEFT, padx=6)
# Interval input
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")
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()
def go():
fname = name.get().strip()
if not fname:
return
if mode.get() == "fixed":
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())),
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(),
)
_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._get_validated_host(),
"port": self._get_validated_port(),
"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()
],
}
cfg["display_defaults"] = {
"backend": self.display_backend_var.get(),
"samples": int(self.default_samples_var.get()),
"max_draw_pts": int(self.default_maxpts_var.get()),
"show_thresholds": bool(self.display_show_thresholds_var.get()),
}
path = filedialog.asksaveasfilename(
defaultextension=".json",
filetypes=[("JSON", "*.json")],
title="Save Configuration",
)
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}")
except Exception as e:
messagebox.showerror("Save failed", str(e))
def load_config(self):
try:
from tkinter import filedialog
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()))
self.port_var.set(cfg.get("port", self.port_var.get()))
self.refresh_var.set(cfg.get("refresh_interval", self.refresh_var.get()))
keys = cfg.get("variables_keys", [])
self.variables_keys = [k.lower() for k in keys]
new_vars = {}
for k, data in (cfg.get("vars") or {}).items():
vi = self._deserialize_varinfo(k, data)
new_vars[k.lower()] = vi
self.vars = new_vars
dd = cfg.get("display_defaults", {})
try:
self.display_backend_var.set(dd.get("backend", "matplotlib"))
except Exception:
logger.exception("load_config display_backend_var")
pass
try:
self.default_samples_var.set(int(dd.get("samples", 200)))
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:
logger.exception("load_config default_maxpts_var")
pass
try:
self.display_show_thresholds_var.set(
bool(dd.get("show_thresholds", True))
)
except Exception:
logger.exception("load_config display_show_thresholds_var")
pass
# replace scheduled tasks
for t in [t.task_id for t in self.scheduler.list_tasks()]:
self.scheduler.remove_task(t)
for td in cfg.get("scheduled_tasks", []):
self.scheduler.add_task(self._deserialize_task(td))
if self.poller and self.poller.is_alive():
self.poller.variables_keys = self.variables_keys[:]
self._refresh_tree_immediate() # Immediate refresh for configuration loading
self.refresh_actions_tree()
messagebox.showinfo("Loaded", f"Configuration loaded from:\n{path}")
except Exception as e:
messagebox.showerror("Load failed", str(e))
def _serialize_varinfo(self, vi: VarInfo):
t = vi.thresholds
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,
# nowości:
"expr_operating_interval": getattr(t, "expr_operating_interval", 1.0),
"y_source": getattr(t, "y_source", None),
"z_source": getattr(t, "z_source", None),
"expr_x_source_dead_low": t.expr_x_source_dead_low,
"expr_x_source_low": t.expr_x_source_low,
"expr_x_source_operating": t.expr_x_source_operating,
"expr_x_source_high": t.expr_x_source_high,
"expr_x_source_extreme_high": t.expr_x_source_extreme_high,
"expr_thr_dead_low": t.expr_thr_dead_low,
"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"),
# 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"),
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"),
expr_thr_extreme_high=td.get("expr_thr_extreme_high"),
)
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"),
}
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"),
)
def on_close(self) -> None:
"""Enhanced resource cleanup to prevent memory leaks and ensure clean shutdown."""
# Prevent double cleanup from both WM_DELETE_WINDOW and atexit
if getattr(self, "_already_closing", False):
return
self._already_closing = True
# 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. Stop discovery worker
self._cleanup_discovery_worker()
# 5. Cancel plot timer
self._cleanup_plot_timer()
# 6. Close all plot windows
self._cleanup_plot_windows()
# 7. Clear all window references
self._clear_window_references()
# 8. Clear stats cache
self._invalidate_stats_cache()
logger.info("Application cleanup completed")
self.destroy()
def _cleanup_qt_windows(self) -> None:
"""Clean up Qt windows with proper error isolation and resource cleanup."""
if not hasattr(self, "_qt_windows") or not self._qt_windows:
return
logger.info(f"Cleaning up {len(self._qt_windows)} Qt windows")
for key, tup in list(self._qt_windows.items()):
try:
if isinstance(tup, tuple) and len(tup) >= 2:
win, timer = tup[:2]
# Clean up timer if present
if timer:
timer.stop()
timer.deleteLater()
# Clean up Qt window if present
if win:
# Clean up the actual Qt widget
qt_widget = getattr(win, "_qt_widget", None)
if qt_widget:
try:
# Hide first to prevent flicker
if hasattr(qt_widget, "hide"):
qt_widget.hide()
# Close the widget
if hasattr(qt_widget, "close"):
qt_widget.close()
# Delete later for proper cleanup
if hasattr(qt_widget, "deleteLater"):
qt_widget.deleteLater()
except Exception:
logger.exception(
f"Failed to cleanup Qt widget for {key}"
)
# Clean up our wrapper object
if hasattr(win, "close"):
win.close()
# Qt window cleaned up successfully
else:
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)
if poller and poller.is_alive():
logger.info("Stopping poller thread")
self.stop_event.set()
self.paused_event.clear()
poller.join(timeout=2.0)
if poller.is_alive():
logger.warning("Poller thread did not stop within timeout")
def _cleanup_scheduler(self) -> None:
"""Stop scheduler thread with timeout."""
scheduler = getattr(self, "scheduler", None)
if scheduler and scheduler.is_alive():
logger.info("Stopping scheduler thread")
scheduler.stop()
scheduler.join(timeout=2.0)
if scheduler.is_alive():
logger.warning("Scheduler thread did not stop within timeout")
def _cleanup_discovery_worker(self) -> None:
"""Stop discovery worker thread with timeout."""
worker = getattr(self, "_discovery_worker", None)
if worker and worker.is_alive():
logger.info("Stopping discovery worker thread")
worker.stop()
worker.join(timeout=2.0)
if worker.is_alive():
logger.warning("Discovery worker thread did not stop within timeout")
self._discovery_in_progress = False
def _cleanup_plot_timer(self) -> None:
"""Cancel the plot update timer and tree refresh timer."""
plot_timer = getattr(self, "_plot_timer", None)
if plot_timer:
try:
self.after_cancel(plot_timer)
logger.debug("Plot timer cancelled")
except Exception:
logger.exception("Failed to cancel plot timer")
# Cancel pending tree refresh timer
tree_refresh_timer = getattr(self, "_tree_refresh_timer", None)
if tree_refresh_timer:
try:
self.after_cancel(tree_refresh_timer)
self._tree_refresh_timer = None
logger.debug("Tree refresh timer cancelled")
except Exception:
logger.exception("Failed to cancel tree refresh timer")
def _cleanup_plot_windows(self) -> None:
"""Close all plot windows with error isolation."""
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"):
win.destroy()
logger.debug(f"Destroyed plot window: {key}")
except Exception:
logger.exception(f"Failed to destroy plot window: {key}")
def _clear_window_references(self) -> None:
"""Clear all window reference dictionaries."""
if hasattr(self, "_plot_windows"):
self._plot_windows.clear()
if hasattr(self, "_qt_windows"):
self._qt_windows.clear()
logger.debug("Window references cleared")
# --- [DROP-IN] otwieranie okna Canvas (lite) --------------------------------
def _open_canvas_window(self, key: str):
vi = self.vars[key]
win = tk.Toplevel(self)
win.title(f"{vi.display_name} — plot")
win.geometry("740x360")
win._key = key
win._backend = "canvas"
win._creation_time = time.time() # Track creation time
tools = ttk.Frame(win)
tools.pack(side=tk.TOP, fill=tk.X)
win._show_thr_var = tk.BooleanVar(
value=bool(self.display_show_thresholds_var.get())
)
ttk.Checkbutton(tools, text="Show thresholds", variable=win._show_thr_var).pack(
side=tk.LEFT, padx=(8, 6)
)
ttk.Label(tools, text="Samples:").pack(side=tk.LEFT, padx=(8, 2))
win._sample_len_var = tk.IntVar(value=int(self.default_samples_var.get()))
ttk.Spinbox(
tools,
from_=20,
to=5000,
increment=10,
textvariable=win._sample_len_var,
width=6,
).pack(side=tk.LEFT)
logger.debug(f"Registered canvas window: {key} -> {id(win)}")
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)
cv = tk.Canvas(win, background="white", highlightthickness=0)
cv.pack(fill=tk.BOTH, expand=True)
win._tk_canvas = cv
# id narysowanych elementów (żeby kasować przy nast. klatce)
win._cv_ids = []
win._last_seen_ver = -1
self._plot_windows[key] = win
# Window registered for plotting
self._ensure_plot_timer()
# --- [DROP-IN] aktualizacja Canvas (lite) -----------------------------------
def _plot_update_canvas(self, win, xs_v, vals_draw, xs_b, dels_draw, thr: dict):
try:
cv = win._tk_canvas
for iid in getattr(win, "_cv_ids", []):
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)
H2 = H - H1
# mapowanie Y (górny wykres)
if vals_draw:
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
# polyline
pts = []
for i, v in enumerate(vals_draw):
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)
colors = {
"dead_low": "#FBC02D",
"low": "#FFF59D",
"mid": "#2ECC71",
"high": "#FF8A80",
"extreme": "#D50000",
}
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]
)
)
# 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
]
if cands:
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")
)
# 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))))
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)
)
# wskaźnik tylko strzałka (kolor jak wyżej), rysowana nad osią
# (prosto: nie liczymy „najbliższego” drugi raz to kosmetyka)
except Exception:
logger.exception("_plot_update_canvas")
# --- [DROP-IN] otwieranie okna PyQtGraph (bez QTimer) -----------------------
def _open_pyqtgraph_window(self, key: str):
if not _pyqtgraph_available:
tk.messagebox.showwarning("PyQtGraph", "PyQtGraph nie jest dostępny.")
return
vi = self.vars[key]
if not vi:
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ć."
)
return
# Qt window
glw = pg.GraphicsLayoutWidget(show=True, title=f"{vi.display_name} — plot")
glw.resize(780, 480)
glw.setWindowTitle(f"{vi.display_name} — plot")
# Add close event handler to remove from tracking when user closes window
def qt_close_handler():
try:
logger.debug(f"Qt window closed by user: {key}")
# Remove from both tracking dictionaries
self._plot_windows.pop(key, None)
self._qt_windows.pop(key, None)
except Exception:
logger.exception(f"Error handling Qt window close for {key}")
# Connect close event - override the closeEvent method
original_close_event = glw.closeEvent
def enhanced_close_event(event):
qt_close_handler()
if original_close_event:
original_close_event(event)
else:
event.accept()
glw.closeEvent = enhanced_close_event
# Add window state tracking
def qt_visibility_handler():
"""Track Qt window visibility changes."""
try:
if hasattr(glw, "isVisible") and not glw.isVisible():
logger.debug(f"Qt window became invisible: {key}")
except Exception:
logger.exception(f"Error tracking Qt window visibility for {key}")
# Connect visibility change handler if available
if hasattr(glw, "visibilityChanged"):
glw.visibilityChanged.connect(qt_visibility_handler)
p1 = glw.addPlot(row=0, col=0)
p2 = glw.addPlot(row=1, col=0)
p2.setXLink(p1)
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
# BarGraphItem dla delt
bars = None
bars_x = []
bars_w = 1.0
# 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)
),
}
for ln in thr_lines.values():
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)
# wrapper (bez Tk toplevel tylko referencje i ustawienia)
class _QtHandle:
pass
win = _QtHandle()
win._backend = "pyqtgraph"
win._key = key
win._creation_time = time.time() # Track creation time
win._qt_widget = glw
win._p1 = p1
win._p2 = p2
win._curve = curve
win._bars = bars
win._bars_x = bars_x
win._bars_w = bars_w
win._thr_lines = thr_lines
win._tri_val = tri_val
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._last_seen_ver = -1
win._last_title = None
self._plot_windows[key] = win
# Also register in _qt_windows for proper cleanup (no timer for PyQtGraph windows)
self._qt_windows[key] = (win, None)
# PyQtGraph window registered for plotting
logger.debug(f"Registered PyQtGraph window: {key} -> {id(win)}")
self._ensure_qt_pump() # enable Qt event pumping in Tk loop
self._ensure_plot_timer()
# --- [DROP-IN] aktualizacja PyQtGraph ---------------------------------------
def _plot_update_pyqtgraph(self, win, xs_v, vals_draw, xs_b, dels_draw, thr: dict):
try:
w = getattr(win, "_qt_widget", None)
if w is None or not hasattr(w, "isVisible") or not w.isVisible():
return
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)
# --- 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)
# len(dels_draw) == len(xs_b)
if win._bars is None:
# pierwszy raz twórz BarGraphItem
# (barWidth będzie aktualizowany przy zmianie rozdzielczości)
win._bars = pg.BarGraphItem(x=xs_b, height=dels_draw, width=1.0)
p2.addItem(win._bars)
else:
win._bars.setOpts(x=xs_b, height=dels_draw)
# 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
try:
win._bars.setOpts(width=1.0) # stała, bo mamy indeksy jako X
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)
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"):
y = thr.get(name)
if within(y):
ln = win._thr_lines[name]
ln.setValue(float(y))
ln.setVisible(True)
# 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
]
if cands:
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)
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:
logger.exception("_plot_update_pyqtgraph set tri pos")
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
vi = self.vars[key]
win = tk.Toplevel(self)
win.title(f"{vi.display_name} — plot")
win.geometry("760x420")
win._key = key
win._backend = "matplotlib"
win._creation_time = time.time() # Track creation time
tools = ttk.Frame(win)
tools.pack(side=tk.TOP, fill=tk.X)
win._show_thr_var = tk.BooleanVar(
value=bool(self.display_show_thresholds_var.get())
)
ttk.Checkbutton(tools, text="Show thresholds", variable=win._show_thr_var).pack(
side=tk.LEFT, padx=(8, 6)
)
ttk.Label(tools, text="Samples:").pack(side=tk.LEFT, padx=(8, 2))
win._sample_len_var = tk.IntVar(value=int(self.default_samples_var.get()))
ttk.Spinbox(
tools,
from_=20,
to=5000,
increment=10,
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))
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.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())
)
)
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
),
}
canvas = FigureCanvasTkAgg(fig, master=win)
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,
)
(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._last_seen_ver = -1
win._layout_dirty = True
canvas.mpl_connect(
"resize_event", lambda _evt=None: setattr(win, "_layout_dirty", True)
)
self._plot_windows[key] = win
logger.debug(f"Registered matplotlib window: {key} -> {id(win)}")
self._ensure_plot_timer()
# --- [DROP-IN] aktualizacja Matplotlib --------------------------------------
def _plot_update_matplotlib(self, win, xs_v, vals_draw, xs_b, dels_draw, thr: dict):
try:
# wartości
win._line.set_data(xs_v, vals_draw)
if vals_draw:
vmin, vmax = min(vals_draw), max(vals_draw)
span = (vmax - vmin) or 1.0
m = 0.08 * span
win._ax_val.set_xlim(0, max(1, len(xs_v) - 1))
win._ax_val.set_ylim(vmin - m, vmax + m)
# delty (słupki)
if len(dels_draw) != getattr(win, "_bars_len", 0):
for b in getattr(win, "_bars", []):
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, strict=True):
b.set_height(h)
if dels_draw:
dmin, dmax = min(dels_draw), max(dels_draw)
span = (dmax - dmin) or 1.0
m = 0.08 * span
win._ax_delta.set_xlim(0, max(1, len(dels_draw) - 1))
win._ax_delta.set_ylim(dmin - m, dmax + m)
win._ax_delta.set_xlabel(f"last {len(vals_draw)} samples")
# 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)
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"):
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
]
if (cur is not None) and cands:
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",
}
col = color_map.get(k_best, "#2ECC71")
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)
if getattr(win, "_layout_dirty", False):
try:
win._fig.tight_layout()
except Exception:
logger.exception("_plot_update_matplotlib tight_layout")
win._layout_dirty = False
win._canvas.draw_idle()
except Exception:
logger.exception("_plot_update_matplotlib")
def count_plot_windows(self) -> int:
"""Count all open plot windows (both Tk/matplotlib and Qt/pyqtgraph)"""
count = 0
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:
class RECT(ctypes.Structure):
_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,
)
user32 = ctypes.windll.user32
def _cb(hMon, hDC, lprc, dwData):
r = lprc.contents
monitors.append((int(r.left), int(r.top), int(r.right), int(r.bottom)))
return 1
user32.EnumDisplayMonitors(0, 0, MONITORENUMPROC(_cb), 0)
if not monitors:
raise RuntimeError("No monitors from EnumDisplayMonitors")
except Exception:
# Fallback: single screen according to Tk
logger.exception("_enum_monitors")
w = self.winfo_screenwidth()
h = self.winfo_screenheight()
monitors = [(0, 0, w, h)]
return monitors
def _win_center(self, x, y, w, h):
return (x + w // 2, y + h // 2)
def _which_monitor(self, monitors, x, y, w, h):
cx, cy = self._win_center(x, y, w, h)
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
# odległość do środka monitora
mcx, mcy = (L + R) // 2, (T + B) // 2
d = (mcx - cx) ** 2 + (mcy - cy) ** 2
if d < best_d:
best, best_d = i, d
return best
def _best_grid(self, n):
if n <= 0:
return (1, 1)
c = int(math.ceil(math.sqrt(n)))
r = int(math.ceil(n / c))
return (r, c)
def arrange_plot_windows(self):
"""
Arranges all open plot windows (Tk-Matplotlib, Tk-Canvas, Qt-PyQtGraph)
in grids on individual monitors.
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()
# Collect windows from main dictionary _plot_windows (Matplotlib and Qt)
tk_wins = []
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])
name = None
if key in self.vars:
name = self.vars[key].display_name
if not name:
# fallback: window title
name = str(w.title() or key)
tk_wins.append(("tk", w, x, y, ww, hh, name))
# 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()
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])
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 "Canvas")
tk_wins.append(("tk", w, x, y, ww, hh, name))
except Exception:
logger.exception("arrange_plot_windows Tk Canvas")
all_wins = tk_wins + qt_wins
if not all_wins:
tk.messagebox.showinfo("Arrange", "No open plot windows found.")
return
# Grupowanie po monitorach (wg aktualnej pozycji)
groups = {i: [] for i in range(len(monitors))}
for kind, win, x, y, ww, hh, name in all_wins:
mid = self._which_monitor(monitors, x, y, ww, hh)
groups[mid].append((kind, win, name))
# SIZE/MARGIN SETTINGS
DEF_W, DEF_H = 760, 420
MAX_W, MAX_H = DEF_W // 2, DEF_H // 2
SIDE_MARGIN = 12
TOP_MARGIN = 60 # zwiększony dla tytułów Qt
BOTTOM_MARGIN = 12
CELL_PAD = 10 # odstęp między kratkami
# Helper: najlepsza siatka (r,c) - preferuje prostokąty szerokie
def best_grid(n):
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
# 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]
mon_w = R - L
mon_h = B - T
n = len(items)
rows, cols = best_grid(n)
use_max_size = n < 9 # use maximum size for smaller number of windows
# 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
# Initial cell size
cell_w = max(1, available_w // cols)
cell_h = max(1, available_h // rows)
# 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)
)
# „bezpieczny” rozmiar (obcięty o mały bufor, różne dekoracje)
# 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, _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)
try:
# 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 ---
def parse_first_float(value: str) -> Optional[float]:
if value is None:
return None
m = re.search(r"[-+]?\d+(?:\.\d+)?", str(value).replace(",", "."))
if m:
try:
return float(m.group(0))
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.
Dostępne: math, min, max, abs, round, int, float.
Zmiennych szukamy w `env` (np. x, x_avg, dx, dx_avg, y, y_avg, ...).
"""
allowed_globals = {
"__builtins__": {},
"math": math,
"min": min,
"max": max,
"abs": abs,
"round": round,
"int": int,
"float": float,
}
# tylko 'eval' bez snippetów wieloliniowych:
code = compile(expression, "<thr>", "eval")
return eval(code, allowed_globals, env)
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_locals = {"x": x, "y": y, "z": z}
if "\n" in expr or ";" in expr:
code = compile(expr, "<expr>", "exec")
exec(code, safe_globals, safe_locals)
if "result" not in safe_locals:
raise ValueError("Snippet must assign to 'result', e.g., result = x * 1.1")
return safe_locals["result"]
else:
code = compile(expr, "<expr>", "eval")
return eval(code, safe_globals, safe_locals)
if __name__ == "__main__":
app = App()
app.mainloop()