Files
vtt_work/nucleares_monitor/control_board_monitor.py
T
2025-10-21 09:18:28 +02:00

3274 lines
155 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Control Board Monitor — v1.8
Whats new:
- POSTs are now **exactly** like the sample app:
POST http://<host>:<port>/?variable=<NAME>&value=<ARG> (method=POST, no body)
(All previous POST modes removed.)
- Select Variables dialog: typing in the filter **does not clear** previously ticked checkboxes.
- Mouse wheel scrolling enabled (selector canvas, main variables tree, actions tree).
"""
import json
import math
import queue
import re
import threading
from collections import deque
import time
import urllib.parse
import urllib.request
from dataclasses import dataclass, field
from datetime import datetime
from typing import Dict, List, Optional, Tuple, Deque
import math # NOWE: do decymacji i obliczeń
import traceback
# --- Qt log & DPI: uspokojenie Qt na multi-monitor (opcjonalne, ale pomaga) ---
import os
os.environ.setdefault("QT_LOGGING_RULES", "qt.core.qobject.connect=false")
os.environ.setdefault("QT_ENABLE_HIGHDPI_SCALING", "0")
os.environ.setdefault("QT_AUTO_SCREEN_SCALE_FACTOR", "0")
DEBUG_LOG = True # możesz wyłączyć na False gdy już będzie stabilnie
def _dbg(msg: str):
if not DEBUG_LOG:
return
try:
from datetime import datetime
with open("cbm_debug.log", "a", encoding="utf-8") as f:
f.write(f"{datetime.now().strftime('%H:%M:%S.%f')} {msg}\n")
except Exception:
pass
def _dbg_exc(where: str):
_dbg(f"[EXC] {where}\n{traceback.format_exc()}")
SAFE_GLOBALS = {
"__builtins__": {},
"abs": abs, "min": min, "max": max, "round": round,
"int": int, "float": float, "pow": pow, "math": math,
}
def safe_eval(expr: str, symbols: dict):
"""Bardzo ograniczony eval tylko dopuszczone funkcje i przekazane symbole."""
clean = {k: v for k, v in (symbols or {}).items() if v is not None}
return eval(expr, SAFE_GLOBALS, clean)
def eval_user_expression(expr: str, x, y, z) -> float:
"""Eval wyrażenia użytkownika (np. w schedulerze) na (x,y,z)."""
val = safe_eval(expr, {"x": x, "y": y, "z": z})
return float(val)
# Spójna warstwa Qt z pyqtgraph (nie mieszamy bezpośrednio PyQt5/PySide6)
_pyqtgraph_available = False
_pyqtgraph_unavailable_reason = ""
try:
import pyqtgraph as pg
from pyqtgraph.Qt import QtCore, QtWidgets
_pyqtgraph_available = True
except Exception as e:
_pyqtgraph_unavailable_reason = str(e)
try:
import tkinter as tk
from tkinter import ttk, messagebox
except Exception:
raise SystemExit("Tkinter is required to run this app.")
# Optional plotting support
try:
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
from matplotlib.figure import Figure
HAS_MPL = True
import matplotlib as mpl
mpl.rcParams["path.simplify"] = True
mpl.rcParams["agg.path.chunksize"] = 10000
PLOT_SCALE = -200
except Exception:
HAS_MPL = False
# --- Daemon thread spawner ----------------------------------------------------
def spawn_daemon(name: str, target, *args, **kwargs):
"""
Start background thread as daemon=True. Returns the Thread object.
"""
t = threading.Thread(target=target, args=args, kwargs=kwargs, daemon=True, name=name)
t.start()
return t
# =====================
# Global configuration
# =====================
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]]:
with _HTTP_LOCK:
try:
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 Exception:
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:
body = str(e)
return e.code, body, dict(e.headers or {})
except Exception as e:
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"})
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")
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"})
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 Exception:
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 Exception:
return {}
# Jeśli serwer owinął odpowiedź w {"values": {...}}, wyciągamy środek:
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 Exception:
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 Exception:
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:
# jeżeli masz już safe_eval w kodzie użyj go; inaczej zwykły eval bez builtins
val = safe_eval(expr, env) if 'safe_eval' in globals() else eval(expr, {"__builtins__": {}}, env)
return float(val)
except Exception:
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 # rośnie przy każdym append do history (optymalizacja pod ploty)
@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):
# fetch x/y/z using callback; None -> 0.0 for x
def gv(n):
try:
return self.get_value_cb(n) if n else None
except Exception:
return None
# stara wersja:
# x = gv(task.x_src); y = gv(task.y_src); z = gv(task.z_src)
def pick(stats: Optional[dict], mode: str) -> Optional[float]:
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")
xs = self.get_value_cb(task.x_src)
ys = self.get_value_cb(task.y_src)
zs = self.get_value_cb(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 as e:
# Return synthetic error without posting
return 0, f"Expression error: {e}", {}
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:
# jednorazowe wyłącz po wykonaniu
t.enabled = False
else:
t.next_run = t.last_run + max(0.0, float(t.interval_s))
except Exception:
# nie blokujemy pętli schedulera na wyjątkach z pojedynczego taska
_dbg_exc("Scheduler.run->run_task_once")
# krótka, przerywalna drzemka
# (nie używamy time.sleep; dzięki temu zamknięcie jest natychmiastowe)
self._stop.wait(0.01)
# =====================
# 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)
while not self.stop_event.is_set():
cycle_start = time.time()
try:
self.ui_queue.put(("cycle_start", datetime.now().strftime("%H:%M:%S")))
# główny odczyt (BATCH lub fallback na pojedyncze)
status, body, _ = http_get(base_url, {"variable": "WEBSERVER_BATCH_GET"})
if status == 200:
values_map = parse_batch_values(body)
if values_map:
lower_map = {k.lower(): v for k, v in values_map.items()}
self.ui_queue.put(("batch", lower_map))
for key in list(self.variables_keys):
if key in lower_map:
self.ui_queue.put(("update", key, str(lower_map[key]), 200))
else:
self.ui_queue.put(("error", key, 206, "Not in BATCH_GET payload"))
else:
for key in list(self.variables_keys):
st, b, _h = http_get(base_url, {"variable": key})
if st == 200:
self.ui_queue.put(("update", key, b, st))
else:
self.ui_queue.put(("error", key, st, coerce_preview(b, 200)))
else:
for key in list(self.variables_keys):
st, b, _h = http_get(base_url, {"variable": key})
if st == 200:
self.ui_queue.put(("update", key, b, st))
else:
self.ui_queue.put(("error", key, st, coerce_preview(b, 200)))
except Exception:
# nigdy nie wywalamy wątku na zewnątrz
_dbg_exc("Poller.run(main loop)")
# OBSŁUGA PAUZY aktywnie czekamy, ale przerywalnie
while self.paused_event.is_set() and not self.stop_event.is_set():
# czekaj 50ms lub do przerwania
self.stop_event.wait(0.05)
# Odmierz pozostały czas do końca interwału też przerywalnie
cycle_dt = time.time() - cycle_start
remaining = 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
# sygnalizacja zakończenia
try:
self.ui_queue.put(("stopped", ""))
except Exception:
_dbg_exc("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] = {}
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
self._stop_event = threading.Event()
self.scheduler = ActionScheduler(
lambda: build_base_url(self.host_var.get().strip(), int(self.port_var.get())),
self.get_stats_for
)
self.scheduler.start()
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)
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)
self.avg_window_var.trace_add("write", lambda *_: (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.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: pass
# 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]):
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)
# DOMYŚLNE USTAWIENIA WYŚWIETLANIA (trzymane w tk.Variable, będą też zapisane w konfigu)
self.display_backend_var = tk.StringVar(value="matplotlib") # "matplotlib" | "canvas" | "pyqtgraph"
self.default_samples_var = tk.IntVar(value=200) # domyślna liczba próbek na wykres
self.default_maxpts_var = tk.IntVar(value=400) # ile maks. punktów realnie rysować
# 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()}"))
# Reszta 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)
# Dodaj „Opcje” do menubar na końcu
menubar.add_cascade(label="Opcje", menu=opmenu)
# Podłącz menubar do okna
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()
self.after(16, self._pump_qt_events)
def _qt_ensure_app(self):
"""Utwórz (raz) QApplication wspólną dla wszystkich okien Qt."""
try:
from pyqtgraph.Qt import QtWidgets
except Exception:
self._qt_app = None
return
if getattr(self, "_qt_app", None) is None:
self._qt_app = QtWidgets.QApplication.instance() or QtWidgets.QApplication([])
def _pump_qt_events(self):
try:
if not getattr(self, "_plot_windows", None):
return # nic do pompowania
if not any(getattr(w, "_backend", "") == "pyqtgraph" for w in self._plot_windows.values()):
return
from pyqtgraph.Qt import QtWidgets
app = getattr(self, "_qt_app", None) or QtWidgets.QApplication.instance()
if app is not None:
app.processEvents()
except Exception as e:
_dbg_exc("_pump_qt_events")
if not getattr(self, "_closing", False):
self.after(16, self._pump_qt_events)
def _safe_bool(self, tkvar, default=False):
try:
return bool(tkvar.get())
except Exception:
return default
def get_stats_for(self, name: Optional[str]) -> Optional[dict]:
if not name:
return None
key = str(name).lower()
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:
return None
try:
n = max(1, int(self.avg_window_var.get()))
except Exception:
n = 60
if vi:
return {
"x": parse_first_float(vi.last_value),
"x_avg": _avg(vi.history, n),
"dx": vi.delta_last,
"dx_avg": _avg(vi.history_delta, n),
}
# fallback dla zmiennych nie-monitorowanych
val = self.get_current_value(name)
return {"x": val, "x_avg": None, "dx": None, "dx_avg": None}
# --- Discovery ---
def reload_discovery(self) -> None:
host = self.host_var.get().strip()
port = int(self.port_var.get())
base = build_base_url(host, port)
discovered_vars: List[str] = []
discovered_funcs: List[str] = []
msgs = []
# 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)}")
# 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)}")
# 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)
# Apply
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)
# 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)}")
# --- 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]
# Jeśli użytkownik ustawił 0 => 'run once' przy wejściu w stan:
# Samo przełączenie na 0 ma natychmiast skasować ew. cykliczne zadania zrobiliśmy to wyżej.
# Nie dodajemy nowego zadania, jeśli nie ma co wykonywać.
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()
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))
tid = 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 = build_base_url(self.host_var.get().strip(), int(self.port_var.get()))
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")
)
# odtwórz zaznaczenie/fokus (o ile elementy wciąż istnieją)
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)
def _ensure_plot_timer(self):
if getattr(self, "_plot_timer", None) is None:
try:
ms = int(max(150, float(self.refresh_var.get()) * 1000)) # >= ~6 FPS
except Exception:
ms = 300
if self._closing:
return
self._plot_timer = self.after(ms, self._plot_tick) if not self._closing else None
def _plot_tick(self):
windows = [w for w in getattr(self, "_plot_windows", {}).values()
if w.winfo_exists() and getattr(w, "_canvas", None)]
if not windows:
self._plot_timer = None
return
for win in windows:
try:
key = getattr(win, "_key", None)
vi = self.vars.get(key)
if not vi:
continue
# ustaw tytuł okna: nazwa + aktualna wartość (bez nadmiernego odświeżania)
val_num = parse_first_float(vi.last_value)
new_title = f"{vi.display_name}{val_num:.6g}" if val_num is not None else f"{vi.display_name}"
if getattr(win, "_last_title", None) != new_title:
win.title(new_title)
win._last_title = new_title
# pomiń jeśli brak nowych danych od ostatniego rysowania
last_ver = getattr(win, "_last_seen_ver", -1)
if getattr(vi, "hist_ver", 0) == last_ver:
continue
# pobierz dane (per-okno długość próbek)
try:
n = int(win._sample_len_var.get())
except Exception:
n = 200
vals = list(vi.history)[-n:] if hasattr(vi, "history") else []
dels = list(vi.history_delta)[-n:] if hasattr(vi, "history_delta") else []
# decymacja (ogranicz rysowane punkty do _max_draw_pts)
try:
max_pts = int(getattr(win, "_max_draw_pts", int(win._max_draw_var.get())))
except Exception:
max_pts = 400
if len(vals) > max_pts:
step = max(1, math.ceil(len(vals) / max_pts))
vals_draw = vals[::step]
xs_v = list(range(0, len(vals), step))[:len(vals_draw)]
else:
vals_draw = vals
xs_v = list(range(len(vals)))
win._line.set_data(xs_v, vals_draw)
if vals_draw:
vmin, vmax = min(vals_draw), max(vals_draw)
span = (vmax - vmin) or 1.0
m = 0.08 * span
win._ax_val.set_xlim(0, max(1, len(xs_v) - 1))
win._ax_val.set_ylim(vmin - m, vmax + m)
# --- progi i wskaźnik najbliższego progu (Matplotlib) ---
if getattr(win, "_backend", "") == "matplotlib":
show_thr = bool(getattr(win, "_show_thr_var", tk.BooleanVar(value=True)).get())
for ln in win._thr_lines.values():
ln.set_visible(False)
win._tri_val.set_visible(False)
win._tri_delta.set_visible(False)
if show_thr and vals_draw:
thr = self._effective_thresholds(vi)
vmin, vmax = win._ax_val.get_ylim()
def within(y): return (y is not None) and (vmin <= y <= vmax)
for thr_name in ("dead_low","low","mid","high","extreme"):
y = thr.get(thr_name)
if within(y):
win._thr_lines[thr_name].set_ydata([y, y])
win._thr_lines[thr_name].set_visible(True)
# wskaźnik: najbliższy próg do aktualnej wartości
cur = parse_first_float(vi.last_value)
candidates = [(k, thr[k]) for k in ("dead_low","low","mid","high","extreme") if thr.get(k) is not None]
if cur is not None and candidates:
k_best, y_best = min(candidates, key=lambda kv: abs(kv[1]-cur))
# kolor zgodny z linią
color_map = {
"dead_low": "#FBC02D", "low": "#FFF59D", "mid": "#2ECC71",
"high": "#FF8A80", "extreme": "#D50000",
}
col = color_map.get(k_best, "#2ECC71")
# trójkąt w dół, jeśli próg < aktualna; w górę, jeśli próg > aktualna
up = (y_best > cur)
char = "▲" if up else "▼"
win._tri_val.set_text(char); win._tri_val.set_color(col); win._tri_val.set_visible(True)
win._tri_delta.set_text(char); win._tri_delta.set_color(col); win._tri_delta.set_visible(True)
# słupki delty z taką samą decymacją
if len(dels) > max_pts:
step_b = max(1, math.ceil(len(dels) / max_pts))
dels_draw = dels[::step_b]
xs_b = list(range(0, len(dels), step_b))[:len(dels_draw)]
else:
dels_draw = dels
xs_b = list(range(len(dels)))
if len(dels_draw) != win._bars_len:
for b in win._bars:
b.remove()
win._bars = win._ax_delta.bar(xs_b, dels_draw)
win._bars_len = len(dels_draw)
else:
for b, h in zip(win._bars, dels_draw):
b.set_height(h)
if dels_draw:
dmin, dmax = min(dels_draw), max(dels_draw)
span = (dmax - dmin) or 1.0
m = 0.08 * span
win._ax_delta.set_xlim(0, max(1, len(dels_draw) - 1))
win._ax_delta.set_ylim(dmin - m, dmax + m)
win._ax_delta.set_xlabel(f"last {len(vals)} samples")
# tight_layout tylko po resize
if getattr(win, "_layout_dirty", False):
try:
win._fig.tight_layout()
except Exception:
pass
win._layout_dirty = False
win._canvas.draw_idle()
win._last_seen_ver = getattr(vi, "hist_ver", win._last_seen_ver)
except Exception:
_dbg_exc("_plot_tick(one window)")
continue
# kolejny tick
try:
ms = int(max(150, float(self.refresh_var.get()) * 1000))
except Exception:
ms = 300
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.host_var.get().strip(),
port=int(self.port_var.get()),
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
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:
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:
pass
self.vars[key] = vi
self.evaluate_thresholds(vi, value)
elif kind == "error":
_, key, status, msg = item
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
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
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):
# Ustal monitored key po IID 'var:<key>'
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 nie jest dostępny użyj Matplotlib lub Canvas.")
return
# === Matplotlib (domyślnie) ===
import matplotlib
matplotlib.use("Agg")
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
from matplotlib.figure import Figure
vi = self.vars[key]
win = tk.Toplevel(self)
# pierwszy tytuł bez wartości; aktualna wartość będzie dopisywana w _plot_tick
win.title(f"{vi.display_name} — plot")
win.geometry("760x420")
win._key = key
win._backend = "matplotlib"
tools = ttk.Frame(win); tools.pack(side=tk.TOP, fill=tk.X)
win._show_thr_var = tk.BooleanVar(value=bool(self.display_show_thresholds_var.get()))
ttk.Checkbutton(tools, text="Show thresholds", variable=win._show_thr_var).pack(side=tk.LEFT, padx=(8,6))
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")
# threshold lines (na osi wartości)
win._thr_lines = {
"dead_low": ax_val.axhline(0, color="#FBC02D", linewidth=1.2, linestyle="--", visible=False), # ciemny żółty
"low": ax_val.axhline(0, color="#FFF59D", linewidth=1.2, linestyle="--", visible=False), # jasny żółty
"mid": ax_val.axhline(0, color="#2ECC71", linewidth=1.0, linestyle=":", visible=False), # zielony (środek)
"high": ax_val.axhline(0, color="#FF8A80", linewidth=1.2, linestyle="--", visible=False), # jasny czerwony
"extreme": ax_val.axhline(0, color="#D50000", linewidth=1.2, linestyle="--", visible=False), # ciemny czerwony
}
canvas = FigureCanvasTkAgg(fig, master=win)
canvas.draw(); canvas.get_tk_widget().pack(fill=tk.BOTH, expand=True)
# wskazniki 'najbliższego progu' (po lewej; poza obszarem osi)
win._tri_val = ax_val.text(-0.03, -0.10, "▲", transform=ax_val.transAxes,
ha="left", va="top", color="#2ECC71", fontsize=12,
clip_on=False, visible=False)
win._tri_delta = ax_delta.text(-0.03, 1.10, "▲", transform=ax_delta.transAxes,
ha="left", va="bottom", color="#2ECC71", fontsize=12,
clip_on=False, visible=False)
(line,) = ax_val.plot([], [], linewidth=1.2, antialiased=False)
bars = ax_delta.bar([], [], linewidth=0, antialiased=False)
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))
if not hasattr(self, "_plot_windows"):
self._plot_windows = {}
self._plot_windows[key] = win
self._ensure_plot_timer()
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:
# potraktuj jako wyrażenie (np. "x_avg - 5")
return None, s
# UŻYJ POPRAWNYCH ZMIENNYCH:
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:
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 evaluate_thresholds(self, vi: VarInfo, value: str) -> None:
x = parse_first_float(value)
t = vi.thresholds
# Dynamic thresholds
stats_x = self.get_stats_for(vi.key)
stats_y = self.get_stats_for(getattr(t, "y_source", None))
stats_z = self.get_stats_for(getattr(t, "z_source", None))
thr_dead = t.dead_low
thr_low = t.low
thr_high = t.high
thr_ext = t.extreme_high
if t.expr_thr_dead_low:
v = eval_threshold_expr(t.expr_thr_dead_low, stats_x, stats_y, stats_z)
if v is not None: thr_dead = v
if t.expr_thr_low:
v = eval_threshold_expr(t.expr_thr_low, stats_x, stats_y, stats_z)
if v is not None: thr_low = v
if t.expr_thr_high:
v = eval_threshold_expr(t.expr_thr_high, stats_x, stats_y, stats_z)
if v is not None: thr_high = v
if t.expr_thr_extreme_high:
v = eval_threshold_expr(t.expr_thr_extreme_high, stats_x, stats_y, stats_z)
if v is not None: thr_ext = v
# Auto-defaults based on current value if thresholds are empty
cur = parse_first_float(vi.last_value)
auto_defaults = {}
if cur is not None:
auto_defaults = {
"dead_low": round(cur * 0.8, 6),
"low": round(cur * 0.9, 6),
"high": round(cur * 1.1, 6),
"extreme_high": round(cur * 1.2, 6),
}
prev = vi.last_state
state = "UNKNOWN"
no_thr = all(v is None for v in (thr_dead, thr_low, thr_high, thr_ext))
if x is None or no_thr:
state = "OPERATING"
else:
dl = thr_dead if thr_dead is not None else -float("inf")
lo = thr_low if thr_low is not None else -float("inf")
hi = thr_high if thr_high is not None else float("inf")
ex = thr_ext if thr_ext is not None else float("inf")
if x < dl:
state = "DEAD_LOW"
elif x < lo:
state = "LOW"
elif x < hi:
state = "OPERATING"
elif x < ex:
state = "HIGH"
else:
state = "EXTREME_HIGH"
if state != prev:
self.on_state_change(vi, prev, state, x)
vi.last_state = state
def on_state_change(self, vi: VarInfo, prev_state: str, new_state: str, x: Optional[float]) -> None:
# 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:
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:
pass
# then handle enter actions for new_state
self.on_enter_state(vi, new_state, x)
def on_enter_state(self, vi: VarInfo, state: str, x: Optional[float]) -> None:
t = vi.thresholds
# Auto-defaults based on current value if thresholds are empty
cur = parse_first_float(vi.last_value)
auto_defaults = {}
if cur is not None:
auto_defaults = {
"dead_low": round(cur * 0.8, 6),
"low": round(cur * 0.9, 6),
"high": round(cur * 1.1, 6),
"extreme_high": round(cur * 1.2, 6),
}
alarm_map = {
"DEAD_LOW": t.alarm_dead_low,
"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:
try: self.bell()
except Exception: pass
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 = build_base_url(self.host_var.get().strip(), int(self.port_var.get()))
st, bd, _h = http_get(base, {"variable": key})
if st == 200:
val = parse_first_float(bd)
if label == 'y': y_val = val
else: z_val = val
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 = build_base_url(self.host_var.get().strip(), int(self.port_var.get()))
params = {"variable": target_func, "value": str(computed)}
http_post_query(base, params)
else:
self.status_lbl.configure(text=f"Expr computed for {vi.display_name} [{state}], no target function set")
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:
pass
return tag
def _effective_thresholds(self, vi:"WarInfo") -> dict:
return self._compute_effective_thresholds(vi)
def _compute_effective_thresholds(self, vi: "VarInfo") -> dict[str, float | None]:
"""
Liczy progi efektywne: bierzemy liczby z konfiguracji + nadpisujemy je, jeśli
zdefiniowano formuły expr_thr_*. Zwracamy też 'mid'=(low+high)/2 jeżeli możliwe.
"""
t = vi.thresholds
stats_x = self.get_stats_for(vi.key)
stats_y = self.get_stats_for(getattr(t, "y_source", None))
stats_z = self.get_stats_for(getattr(t, "z_source", None))
def eff(num, expr):
if expr:
try:
v = eval_threshold_expr(expr, stats_x, stats_y, stats_z)
if v is not None:
return float(v)
except Exception:
_dbg_exc("eval_threshold_expr")
return float(num) if num is not None else None
dead = eff(getattr(t, "dead_low", None), getattr(t, "expr_thr_dead_low", None))
low = eff(getattr(t, "low", None), getattr(t, "expr_thr_low", None))
high = eff(getattr(t, "high", None), getattr(t, "expr_thr_high", None))
ext = eff(getattr(t, "extreme_high", None), getattr(t, "expr_thr_extreme_high", None))
mid = (low + high) / 2.0 if (low is not None and high is not None) else None
return {"dead_low": dead, "low": low, "high": high, "extreme": ext, "mid": mid}
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:
_dbg_exc("evaluate_thresholds")
def _state_color(self, vi, x_value=None) -> str:
"""Kolor tła w hex na bazie progów efektywnych; odporne na None."""
try:
thr = self._compute_effective_thresholds(vi)
thr_dead = thr["dead_low"]
thr_low = thr["low"]
thr_high = thr["high"]
thr_ext = thr["extreme"]
try:
x = float(x_value) if x_value is not None else parse_first_float(vi.last_value)
except Exception:
x = None
if x is None:
return "#E9ECEF" # brak danych
if thr_dead is None:
thr_dead = -float("inf")
if thr_low is None and thr_high is None:
thr_low, thr_high = x - 1e-6, x + 1e-6
if thr_low is None:
thr_low = thr_dead if thr_dead != -float("inf") else (x - 1e-6)
if thr_high is None:
thr_high = max(thr_low, x + 1e-6)
if thr_ext is None:
thr_ext = float("inf")
def _hex(c): return "#{:02X}{:02X}{:02X}".format(*c)
def _lerp(c1, c2, t):
t = 0.0 if t < 0 else 1.0 if t > 1 else t
return (
int(c1[0] + (c2[0]-c1[0])*t),
int(c1[1] + (c2[1]-c1[1])*t),
int(c1[2] + (c2[2]-c1[2])*t),
)
Y_LO, Y_HI = (255, 244, 178), (255, 149, 0)
G_OK = (46, 204, 113)
R_LO, R_HI = (255, 138, 128), (213, 0, 0)
if x <= thr_low:
denom = (thr_low - thr_dead) if (thr_low > thr_dead) else 1.0
t = (thr_low - x) / max(denom, 1e-9)
return _hex(_lerp(Y_LO, Y_HI, t))
if x <= thr_high:
return _hex(G_OK)
denom = (thr_ext - thr_high) if (thr_ext > thr_high) else 1.0
t = (x - thr_high) / max(denom, 1e-9)
return _hex(_lerp(R_LO, R_HI, t))
except Exception:
_dbg_exc("_state_color")
return "#E9ECEF"
def refresh_tree(self) -> None:
filt = self.filter_var.get().strip().lower()
groups: Dict[str, List[VarInfo]] = {}
for key, 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:
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:
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:
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:
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:
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)
self.refresh_actions_tree()
top.destroy()
ttk.Button(top, text="Add", command=go).pack(pady=10)
def save_config(self):
try:
from tkinter import filedialog
cfg = {
"host": self.host_var.get(),
"port": int(self.port_var.get()),
"refresh_interval": float(self.refresh_var.get()),
"variables_keys": self.variables_keys,
"vars": {k: self._serialize_varinfo(v) for k,v in self.vars.items() if k in self.variables_keys},
"scheduled_tasks": [self._serialize_task(t) for t in self.scheduler.list_tasks()],
}
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:
pass
try:
self.default_samples_var.set(int(dd.get("samples", 200)))
except Exception:
pass
try:
self.default_maxpts_var.set(int(dd.get("max_draw_pts", 400)))
except Exception:
pass
try:
self.display_show_thresholds_var.set(bool(dd.get("show_thresholds", True)))
except Exception:
pass
# replace scheduled tasks
for t in [t.task_id for t in self.scheduler.list_tasks()]:
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()
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:
# zatrzymaj Pollera
self._closing = True
# zatrzymaj okna/timery Qt (jeśli są)
try:
for _k, tup in list(getattr(self, "_qt_windows", {}).items()):
try:
win, timer = tup
except Exception:
continue
try:
timer.stop(); timer.deleteLater()
except Exception:
pass
try:
win.close()
except Exception:
pass
self._qt_windows.clear()
except Exception:
pass
try:
if getattr(self, "poller", None) and self.poller.is_alive():
self.stop_event.set() # natychmiast przerwie wait() w pętli
self.paused_event.clear()
self.poller.join(timeout=1.5)
except Exception:
pass
# zatrzymaj scheduler
try:
if getattr(self, "scheduler", None) and self.scheduler.is_alive():
self.scheduler.stop() # ustawia wewnętrzny _stop Event
self.scheduler.join(timeout=1.5)
except Exception:
pass
# zatrzymaj timer do plotów, jeśli działa
try:
if getattr(self, "_plot_timer", None):
self.after_cancel(self._plot_timer)
except Exception:
pass
# zamknij okna wykresów
try:
for win in list(self._plot_windows.values()):
try:
win.destroy()
except Exception:
pass
except Exception:
pass
self.destroy()
def _open_canvas_window(self, key: str):
vi = self.vars[key]
win = tk.Toplevel(self)
win.title(f"{vi.display_name} — canvas")
win.geometry("760x420")
win._backend = "canvas"
win._key = key
tools = ttk.Frame(win); tools.pack(side=tk.TOP, fill=tk.X)
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()))
ttk.Spinbox(tools, from_=100, to=5000, increment=50,
textvariable=win._max_draw_var, width=6).pack(side=tk.LEFT)
# Dwie części: górna (linia wartości), dolna (słupki Δ)
win._canvas = tk.Canvas(win, bg="#ffffff", highlightthickness=0)
win._canvas.pack(fill=tk.BOTH, expand=True)
# dane do rysowania
win._line_items = [] # lista segmentów linii
win._bar_items = [] # lista prostokątów
def _tick():
if not win.winfo_exists():
return
key_local = getattr(win, "_key", None)
vi_local = self.vars.get(key_local)
if not vi_local:
win.after(400, _tick); return
# Title z wartością
try:
val_num = parse_first_float(vi_local.last_value)
win.title(f"{vi_local.display_name}{val_num:.6g}" if val_num is not None else vi_local.display_name)
except Exception:
pass
# …po utworzeniu Toplevel win…
if not hasattr(self, "_plot_windows_canvas"):
self._plot_windows_canvas = []
self._plot_windows_canvas.append(win)
def _on_close_canvas():
try:
self._plot_windows_canvas.remove(win)
except Exception:
pass
win.destroy()
win.protocol("WM_DELETE_WINDOW", _on_close_canvas)
# dane
try: n = int(win._sample_len_var.get())
except Exception: n = 200
try: max_pts = int(win._max_draw_var.get())
except Exception: max_pts = 400
vals = list(vi_local.history)[-n:]
dels = list(vi_local.history_delta)[-n:]
# decymacja
def decimate(arr, m):
if len(arr) <= m:
xs = list(range(len(arr))); return xs, arr
step = max(1, int(math.ceil(len(arr) / m)))
arr2 = arr[::step]; xs = list(range(0, len(arr), step))[:len(arr2)]
return xs, arr2
xs_v, vals_d = decimate(vals, max_pts)
xs_b, dels_d = decimate(dels, max_pts)
# geometra płótna
cw = max(10, win._canvas.winfo_width())
ch = max(10, win._canvas.winfo_height())
mid = ch // 2
top_h = int(ch * 0.62)
bot_y0 = top_h + 1
win._canvas.delete("all")
# oś X mapping
def map_x(i, npts):
return int((i / max(1, npts-1)) * (cw-20)) + 10
# linia wartości
if vals_d:
vmin, vmax = min(vals_d), max(vals_d)
span = (vmax - vmin) or 1.0
def map_y(v):
return int((1 - (v - vmin) / span) * (top_h-20)) + 10
last = None
for i, v in enumerate(vals_d):
x = map_x(i, len(vals_d)); y = map_y(v)
if last is not None:
win._canvas.create_line(last[0], last[1], x, y)
last = (x, y)
# słupki Δ
if dels_d:
dmin, dmax = min(dels_d), max(dels_d)
span = (dmax - dmin) or 1.0
def map_yb(v):
# dolny panel
y = int((1 - (v - dmin)/span) * (ch - bot_y0 - 20)) + bot_y0 + 10
return y
bw = max(1, int((cw-20) / max(1, len(dels_d))))
for i, v in enumerate(dels_d):
x = map_x(i, len(dels_d)); y = map_yb(v)
win._canvas.create_rectangle(x, y, x+bw, ch-8, outline="", fill="#8888ff")
# kolejny tick
if self._closing or not win.winfo_exists():
return
try:
ms = int(max(120, float(self.refresh_var.get())*1000))
except Exception:
ms = 250
win.after(ms, _tick)
_tick()
def _open_pyqtgraph_window(self, key: str):
if not _pyqtgraph_available:
tk.messagebox.showwarning("PyQtGraph", "PyQtGraph nie jest dostępny użyj Matplotlib lub Canvas.")
return
self._qt_ensure_app()
if key not in self.vars:
tk.messagebox.showwarning("Plot", f"Zmienna '{key}' nie jest monitorowana.")
return
vi = self.vars[key]
# ... wcześniej: vi = self.vars[key]
glw = pg.GraphicsLayoutWidget(title=f"{vi.display_name}")
p1 = glw.addPlot(row=0, col=0); p1.showGrid(x=True, y=True)
p2 = glw.addPlot(row=1, col=0); p2.showGrid(x=True, y=True)
curve = p1.plot([], [], pen=pg.mkPen(width=1))
bars = p2.plot([], [], pen=None, symbol=None, fillLevel=0,
brush=(100, 100, 255, 160), stepMode=False)
# linie progów tworzone raz
glw._thr_lines = {
"dead_low": pg.InfiniteLine(pos=0, angle=0, pen=pg.mkPen("#FBC02D", width=1), movable=False),
"low": pg.InfiniteLine(pos=0, angle=0, pen=pg.mkPen("#FFF59D", width=1), movable=False),
"mid": pg.InfiniteLine(pos=0, angle=0, pen=pg.mkPen("#2ECC71", width=1, style=QtCore.Qt.DotLine), movable=False),
"high": pg.InfiniteLine(pos=0, angle=0, pen=pg.mkPen("#FF8A80", width=1), movable=False),
"extreme": pg.InfiniteLine(pos=0, angle=0, pen=pg.mkPen("#D50000", width=1), movable=False),
}
for _ln in glw._thr_lines.values():
_ln.setVisible(False); p1.addItem(_ln)
glw._tri = pg.TextItem("", color=(46,204,113)); glw._tri.setVisible(False); p1.addItem(glw._tri)
# okno
glw.resize(820, 520)
glw.setWindowTitle(f"{vi.display_name} — pyqtgraph")
glw.show()
glw._key = key
glw._display_name = vi.display_name
# rejestr okien
if not hasattr(self, "_plot_windows_qt"):
self._plot_windows_qt = []
self._plot_windows_qt.append(glw)
# sprzątanie
def _on_close(evt):
try:
t = getattr(glw, "_timer", None)
if t: t.stop(); t.deleteLater()
except Exception:
pass
try:
self._plot_windows_qt.remove(glw)
except Exception:
pass
evt.accept()
glw.closeEvent = _on_close
# parametry rysowania
try:
samples = int(self.default_samples_var.get())
except Exception:
samples = 100
try:
maxpts = int(self.default_maxpts_var.get())
except Exception:
maxpts = 500
def decimate(arr, m):
if len(arr) <= m:
return list(range(len(arr))), arr
step = max(1, int(math.ceil(len(arr) / m)))
arr2 = arr[::step]
xs = list(range(0, len(arr), step))[:len(arr2)]
return xs, arr2
# reentrancy guard + log
glw._in_update = False
def _log_qt(msg):
try:
with open("qt_plot_err.log", "a", encoding="utf-8") as f:
f.write(msg + "\n")
except Exception:
pass
def update():
if glw._in_update:
return
glw._in_update = True
try:
vi_local = self.vars.get(key)
if not vi_local:
return
vals = list(vi_local.history)[-samples:]
dels = list(vi_local.history_delta)[-samples:]
xs_v, vals_d = decimate(vals, maxpts)
xs_b, dels_d = decimate(dels, maxpts)
curve.setData(xs_v, vals_d)
bars.setData(xs_b, dels_d)
# progi i wskaźnik
for _ln in glw._thr_lines.values():
_ln.setVisible(False)
glw._tri.setVisible(False)
show_thr = self._safe_bool(self.display_show_thresholds_var, False)
if show_thr and vals_d:
thr = self._effective_thresholds(vi_local)
# UWAGA: bierz zakres z danych, nie z viewRange (viewRange potrafi być zmienne w trakcie resize)
ymin, ymax = min(vals_d), max(vals_d)
def within(y): return (y is not None) and (ymin <= y <= ymax)
for thr_name in ("dead_low","low","mid","high","extreme"):
y = thr.get(thr_name)
if within(y):
glw._thr_lines[thr_name].setPos(y)
glw._thr_lines[thr_name].setVisible(True)
cur = parse_first_float(vi_local.last_value)
candidates = [(n, thr[n]) for n in ("dead_low","low","mid","high","extreme") if thr.get(n) is not None]
if cur is not None and candidates:
k_best, y_best = min(candidates, key=lambda kv: abs(kv[1]-cur))
color_map = {"dead_low": (251,192,45), "low": (255,245,157), "mid": (46,204,113),
"high": (255,138,128), "extreme": (213,0,0)}
col = color_map.get(k_best, (46,204,113))
up = (y_best > cur); char = "▲" if up else "▼"
glw._tri.setColor(col); glw._tri.setText(char)
# ustaw w bezpiecznej pozycji przy lewej krawędzi, w obrębie danych
x_left = xs_v[0] if xs_v else 0
y_pos = ymin if up else ymax
glw._tri.setPos(x_left, y_pos)
glw._tri.setVisible(True)
# tytuł z aktualną wartością
try:
val_num = parse_first_float(vi_local.last_value)
glw.setWindowTitle(f"{vi_local.display_name}{val_num:.6g}" if val_num is not None else vi_local.display_name)
except Exception:
pass
except Exception as e:
_log_qt(f"[update] {type(e).__name__}: {e}")
finally:
glw._in_update = False
# jeden QTimer jako child okna
glw._timer = QtCore.QTimer(glw)
try:
ms = max(80, int(float(self.refresh_var.get()) * 1000))
except Exception:
ms = 160
glw._timer.timeout.connect(update)
glw._timer.start(ms)
update()
def count_plot_windows(self) -> int:
n_tk = len([w for w in getattr(self, "_plot_windows", {}).values() if getattr(w, "winfo_exists", lambda: False)()])
n_qt = len([w for w in getattr(self, "_plot_windows_qt", []) if getattr(w, "isVisible", lambda: False)()])
return n_tk + n_qt
# --- helpers: monitory (Windows) + fallback ---
def _enum_monitors(self):
monitors = []
try:
import ctypes
from ctypes import wintypes
class RECT(ctypes.Structure):
_fields_ = [("left", ctypes.c_long), ("top", ctypes.c_long),
("right", ctypes.c_long), ("bottom", ctypes.c_long)]
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: pojedynczy ekran wg Tk
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):
"""
Układa wszystkie otwarte okna wykresów (Tk-Matplotlib, Tk-Canvas, Qt-PyQtGraph)
w siatki na poszczególnych monitorach.
Zasady:
- grupujemy okna wg monitora, NA KTÓRYM AKTUALNIE SĄ otwarte,
- w każdej grupie sortujemy alfabetycznie wg nazwy zmiennej (display_name),
- wypełniamy siatkę od lewej do prawej, potem kolejny wiersz,
- jeśli n>=9 na danym monitorze → siatka pełnoekranowa,
jeśli n<9 → okna max ~połowa domyślnego rozmiaru (dodatkowo skalujemy w dół, gdy trzeba),
- zostawiamy marginesy na krawędziach (TOP_MARGIN, SIDE/BOTTOM), żeby tytuły nie „wychodziły” poza ekran.
"""
monitors = self._enum_monitors()
# Zbierz okna (Tk MPL)
tk_wins = []
for w in list(getattr(self, "_plot_windows", {}).values()):
if getattr(w, "winfo_exists", lambda: False)():
try:
x = w.winfo_rootx(); y = w.winfo_rooty()
ww = w.winfo_width(); hh = w.winfo_height()
if ww <= 1 or hh <= 1:
geo = w.geometry() # "WxH+X+Y"
parts = geo.replace("x", "+").split("+")
ww = int(parts[0]); hh = int(parts[1]); x = int(parts[2]); y = int(parts[3])
key = getattr(w, "_key", None)
name = None
if key and key in self.vars:
name = self.vars[key].display_name
if not name:
# fallback: tytuł okna
name = str(w.title() or "")
tk_wins.append(("tk", w, x, y, ww, hh, name))
except Exception:
pass
# Zbierz okna (Tk Canvas)
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 "")
tk_wins.append(("tk", w, x, y, ww, hh, name))
except Exception:
pass
# Zbierz okna (Qt pyqtgraph)
qt_wins = []
for qw in list(getattr(self, "_plot_windows_qt", [])):
if getattr(qw, "isVisible", lambda: False)():
try:
fg = qw.frameGeometry()
x = fg.x(); y = fg.y(); ww = fg.width(); hh = fg.height()
key = getattr(qw, "_key", None)
name = getattr(qw, "_display_name", None)
if not name and key and key in self.vars:
name = self.vars[key].display_name
if not name:
name = str(qw.windowTitle() or "")
qt_wins.append(("qt", qw, x, y, ww, hh, name))
except Exception:
pass
all_wins = tk_wins + qt_wins
if not all_wins:
tk.messagebox.showinfo("Arrange", "Brak otwartych okien wykresów.")
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))
# USTAWIENIA rozmiaru/marginesów
DEF_W, DEF_H = 760, 420
MAX_W, MAX_H = DEF_W // 2, DEF_H // 2
SIDE_MARGIN = 12
TOP_MARGIN = 40 # większy top, żeby tytuły Qt nie wychodziły
BOTTOM_MARGIN = 12
CELL_PAD = 10 # odstęp między kratkami
# Helper: najlepsza siatka (r,c)
def best_grid(n):
if n <= 0: return (1, 1)
c = int(math.ceil(math.sqrt(n)))
r = int(math.ceil(n / c))
return (r, c)
for midx, items in groups.items():
if not items:
continue
# Sortowanie alfabetycznie wg nazwy zmiennej (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)
full = (n >= 9)
# wstępny wymiar kratki
if full:
cell_w = max(1, (mon_w - (cols + 1) * CELL_PAD - 2 * SIDE_MARGIN) // cols)
cell_h = max(1, (mon_h - (rows + 1) * CELL_PAD - TOP_MARGIN - BOTTOM_MARGIN) // rows)
else:
cell_w = min(MAX_W, max(1, (mon_w - (cols + 1) * CELL_PAD - 2 * SIDE_MARGIN) // cols))
cell_h = min(MAX_H, max(1, (mon_h - (rows + 1) * CELL_PAD - TOP_MARGIN - BOTTOM_MARGIN) // rows))
# dopasowanie, jeśli dalej nie mieści
while (cell_w * cols + (cols + 1) * CELL_PAD + 2 * SIDE_MARGIN) > mon_w or \
(cell_h * rows + (rows + 1) * CELL_PAD + TOP_MARGIN + BOTTOM_MARGIN) > mon_h:
cell_w = max(120, int(cell_w * 0.9))
cell_h = max(100, int(cell_h * 0.9))
if cell_w <= 120 and cell_h <= 100:
break
# rozmiar całej siatki + pozycja startowa (centrowanie)
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)
cell_w_adj = max(50, cell_w - 8)
cell_h_adj = max(50, cell_h - 12)
# Rozmieszczaj od lewej do prawej, potem kolejny wiersz
i = 0
for c in range(cols):
for r in range(rows):
if i >= n:
break
kind, win, _nm = items[i]
x = origin_x + CELL_PAD + c * (cell_w + CELL_PAD)
y = origin_y + CELL_PAD + r * (cell_h + CELL_PAD)
# „clamp” do granic ekranu (z marginesami)
x = max(L + SIDE_MARGIN, min(x, R - SIDE_MARGIN - cell_w_adj))
y = max(T + TOP_MARGIN, min(y, B - BOTTOM_MARGIN - cell_h_adj))
try:
if kind == "tk":
win.geometry(f"{cell_w_adj}x{cell_h_adj}+{x}+{y}")
else:
win.setGeometry(x, y, cell_w_adj, cell_h_adj)
except Exception:
pass
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:
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)
def main() -> None:
app = App()
import atexit
def _graceful_shutdown():
try:
# jeżeli App jeszcze żyje wywołaj on_close
# (dopasuj, jeśli trzymasz referencję gdzie indziej)
app.on_close()
except Exception:
pass
atexit.register(_graceful_shutdown)
app.mainloop()
if __name__ == "__main__":
main()