mirror of
https://github.com/migatu/vtt_work.git
synced 2026-07-14 21:38:39 +00:00
5288 lines
201 KiB
Python
5288 lines
201 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
Control Board Monitor — v1.8
|
||
|
||
What’s 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 scheduler’a 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()
|