From c8a439c2f3937beaa7c2ef3bc9460eaa2f80ad08 Mon Sep 17 00:00:00 2001 From: Michal Tuszowski Date: Mon, 20 Oct 2025 18:26:09 +0200 Subject: [PATCH] 0.6 --- nucleares_monitor/control_board_monitor.py | 478 ++++++++++----------- 1 file changed, 217 insertions(+), 261 deletions(-) diff --git a/nucleares_monitor/control_board_monitor.py b/nucleares_monitor/control_board_monitor.py index a57154a..79ce7b3 100644 --- a/nucleares_monitor/control_board_monitor.py +++ b/nucleares_monitor/control_board_monitor.py @@ -1,14 +1,15 @@ + #!/usr/bin/env python3 # -*- coding: utf-8 -*- """ -Control Board Monitor - v0.4 +Control Board Monitor — v0.6 -Fixes & additions: -- Default port changed to 8785. -- Discovery now shows clear error/fallback messages and *never* leaves you empty. -- If discovery fails, it loads a built-in default list (from your provided HTML). -- "Select Vars…" opens with defaults if nothing was discovered yet. -Everything else from v0.3 (selectors, functions/scheduler, thresholds/alarms) is included. +Key fix: +- Discovery now uses **WEBSERVER_GET_JSON** and reads the `"GET:"` and `"POST:"` sections in that JSON. +- NO usage of `WEBSERVER_LIST_FUNCTIONS_JSON` anymore. +- Still parses the main page "/" as a fallback and then finally the embedded defaults (from your HTML). + +Other bits kept: port=8785, selector dialog, scheduler (Run Once / Interval), thresholds+alarms with optional actions. """ import json @@ -24,48 +25,35 @@ from typing import Dict, List, Optional, Tuple try: import tkinter as tk - from tkinter import ttk, messagebox, simpledialog, filedialog -except Exception as e: + from tkinter import ttk, messagebox +except Exception: raise SystemExit("Tkinter is required to run this app.") -# ======= Windows beep support ======= -def play_alarm_tone(level: str = "LOW") -> None: - try: - import winsound # type: ignore - freq = {"DEAD_LOW": 300, "LOW": 550, "HIGH": 800, "EXTREME_HIGH": 1000}.get(level, 600) - dur = 350 if level != "EXTREME_HIGH" else 600 - winsound.Beep(freq, dur) - except Exception: - try: - root = tk._default_root - if root is not None: - root.bell() - except Exception: - pass - - # ===================== # Global configuration # ===================== SERVER_HOST: str = "localhost" -SERVER_PORT: int = 8785 # changed as requested +SERVER_PORT: int = 8785 REFRESH_INTERVAL_S: int = 60 MAX_RPS: float = 5.0 REQUEST_TIMEOUT_S: float = 5.0 -USER_AGENT: str = "ControlBoardMonitor/0.4 (+tkinter)" +USER_AGENT: str = "ControlBoardMonitor/0.6 (+tkinter)" CONNECT_TIMEOUT_S: float = 5.0 BACKOFF_BASE_S: float = 1.0 BACKOFF_MAX_S: float = 15.0 MAX_RETRIES: int = 2 -# Embedded defaults (from provided 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', 'CHEM_TRUCK_PUMP_SPEED', 'CHEM_TRUCK_SAFETY_VALVE_CLOSED', 'CHEM_TRUCK_STATION_VALVE_CLOSED', 'COOLANT_CORE_CIRCULATION_PUMP_0_ACTUAL_SPEED', 'COOLANT_CORE_CIRCULATION_PUMP_0_ORDERED_SPEED', 'COOLANT_CORE_CIRCULATION_PUMP_0_STATUS', 'COOLANT_CORE_CIRCULATION_PUMP_1_ACTUAL_SPEED', 'COOLANT_CORE_CIRCULATION_PUMP_1_ORDERED_SPEED', 'COOLANT_CORE_CIRCULATION_PUMP_1_STATUS', 'COOLANT_CORE_FLOW_IN', 'COOLANT_CORE_FLOW_OUT', 'COOLANT_CORE_PRESSURE', 'COOLANT_PRIMARY_PRESSURE', 'COOLANT_SECONDARY_PRESSURE', 'COOLANT_TEMPERATURE_IN', 'COOLANT_TEMPERATURE_OUT', 'CONDENSER_PRESSURE', 'CONDENSER_TEMPERATURE', 'CONDENSER_VACUUM', 'CONDENSER_VACUUM_PUMP_0_STATUS', 'CONDENSER_VACUUM_PUMP_1_STATUS', '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_CONTROL_RODS_INSERTION_PERCENT', 'CORE_INTEGRITY', 'CORE_PRESSURE', 'CORE_STATE', 'CORE_TEMP', 'CRANE_ARM_POSITION', 'CRANE_GRABBER_CLOSED', 'CRANE_GRABBER_POSITION', 'CRANE_GRABBER_WEIGHT_KG', 'CRANE_LOCATION', 'CRANE_TORCH_ON', 'CRANE_TORCH_POWER', 'EMERGENCY_STOP', 'MAIN_POWER_SWITCH', 'POWER_DEMAND_MW', 'POWER_FROM_TURBINE_KW', 'RADIATION_BUILDING_CONTAINMENT', 'RADIATION_BUILDING_TURBINE', 'RADIATION_CONTROL_ROOM', 'RADIATION_OUTSIDE', 'RESISTOR_0_CURRENT_A', 'RESISTOR_0_ENABLED', 'RESISTOR_0_TEMP', 'RESISTOR_0_VOLTAGE_V', 'RESISTOR_1_CURRENT_A', 'RESISTOR_1_ENABLED', 'RESISTOR_1_TEMP', 'RESISTOR_1_VOLTAGE_V', 'RESISTOR_2_CURRENT_A', 'RESISTOR_2_ENABLED', 'RESISTOR_2_TEMP', 'RESISTOR_2_VOLTAGE_V', 'RESISTOR_3_CURRENT_A', 'RESISTOR_3_ENABLED', 'RESISTOR_3_TEMP', 'RESISTOR_3_VOLTAGE_V', 'STEAM_PRESSURE_AFTER_CONDENSER', 'STEAM_PRESSURE_AFTER_TURBINE', 'STEAM_PRESSURE_BEFORE_CONDENSER', 'STEAM_PRESSURE_BEFORE_TURBINE', 'STEAM_TEMPERATURE_AFTER_CONDENSER', 'STEAM_TEMPERATURE_AFTER_TURBINE', 'STEAM_TEMPERATURE_BEFORE_CONDENSER', 'STEAM_TEMPERATURE_BEFORE_TURBINE', 'STEAM_TURBINE_0_RPM', 'STEAM_TURBINE_1_RPM', 'STEAM_TURBINE_2_RPM', 'STEAM_TURBINE_VALVE_CLOSED', 'TIME', 'TIME_STAMP', 'VALVE_CLOSED', 'VALVE_OPEN', 'WEBSERVER_LIST_FUNCTIONS', 'WEBSERVER_LIST_FUNCTIONS_JSON', 'WEBSERVER_LIST_VARIABLES', 'WEBSERVER_LIST_VARIABLES_JSON'] -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', 'COOLANT_CORE_CIRCULATION_PUMP_0_ORDERED_SPEED', 'COOLANT_CORE_CIRCULATION_PUMP_0_SWITCH', 'COOLANT_CORE_CIRCULATION_PUMP_1_ORDERED_SPEED', 'COOLANT_CORE_CIRCULATION_PUMP_1_SWITCH', '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', 'CRANE_GRABBER_CLOSED', 'CRANE_TORCH_POWER', 'FUN_ALARM', 'FUN_BREAKER_TRIP', 'FUN_DECREASE_INTEGRITY', 'FUN_FIRE_DRILL', 'FUN_INCREASE_INTEGRITY', 'FUN_RESET_ALARMS', 'MAIN_POWER_SWITCH', 'RESISTOR_0_ENABLED', 'RESISTOR_1_ENABLED', 'RESISTOR_2_ENABLED', 'RESISTOR_3_ENABLED', 'STEAM_TURBINE_VALVE_CLOSED', 'VALVE_OFF', 'VALVE_OPEN'] +# 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'] # ===================== -# Helper utilities +# Helpers # ===================== +def build_base_url(host: str, port: int) -> str: + return f"http://{host}:{port}/" + 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}) @@ -88,11 +76,8 @@ def http_get(base_url: str, params: Dict[str, str]) -> Tuple[int, str, Dict[str, except Exception as e: return 0, str(e), {} - -def http_post(base_url: str, data: Dict[str, str]) -> Tuple[int, str, Dict[str, str]]: - url = base_url - post_data = urllib.parse.urlencode(data).encode("utf-8") - req = urllib.request.Request(url, data=post_data, headers={"User-Agent": USER_AGENT}) +def http_get_root(base_url: str) -> Tuple[int, str, Dict[str, str]]: + req = urllib.request.Request(base_url, headers={"User-Agent": USER_AGENT}) try: with urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT_S) as resp: status = resp.getcode() @@ -112,13 +97,11 @@ def http_post(base_url: str, data: Dict[str, str]) -> Tuple[int, str, Dict[str, except Exception as e: return 0, str(e), {} - -def build_base_url(host: str, port: int) -> str: - return f"http://{host}:{port}/" - +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_variable_names_from_html_index(html_text: str) -> List[str]: - import urllib.parse tokens: List[str] = [] for m in re.finditer(r'href\s*=\s*["\']([^"\']+)["\']', html_text, flags=re.I): href = m.group(1) @@ -136,7 +119,6 @@ def parse_variable_names_from_html_index(html_text: str) -> List[str]: placeholders = {"VARNAME", "VARIABLE", "NAME"} return [v for v in uniq if v not in placeholders] - def parse_function_names_from_html_index(html_text: str) -> List[str]: start = html_text.find("==== POST ====") if start == -1: @@ -150,32 +132,57 @@ def parse_function_names_from_html_index(html_text: str) -> List[str]: placeholders = {"VARNAME", "VARIABLE", "NAME"} return [v for v in uniq if v not in placeholders] +def extract_names(value_obj) -> List[str]: + """Extract UPPERCASE names from JSON value that might be list/str/dict.""" + out: List[str] = [] + def add(name): + if not isinstance(name, str): return + name_u = name.strip().upper() + if name_u and name_u not in out: + out.append(name_u) + if isinstance(value_obj, list): + for item in value_obj: + if isinstance(item, str): + add(item) + elif isinstance(item, dict): + # Try common keys + for k in ("name","variable","var","id","func"): + if k in item and isinstance(item[k], str): + add(item[k]) + elif isinstance(value_obj, dict): + # Could be {NAME: "...", NAME2: "..."} + for k in value_obj.keys(): + add(k) + elif isinstance(value_obj, str): + for m in re.findall(r"[A-Z0-9_]+", value_obj.upper()): + add(m) + return out -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 infer_status_color(value: str) -> str: - val = value.strip().lower() - if val in {"1", "true", "on", "open", "active", "ok", "running", "enabled"}: - return "ok" - if val in {"0", "false", "off", "closed", "inactive", "error", "fault", "disabled"}: - return "bad" - if val == "" or "exception" in val or "error" in val: - return "bad" - return "neutral" - - -def parse_first_float(value: str) -> Optional[float]: - m = re.search(r"[-+]?\d+(?:\.\d+)?", value.replace(",", ".")) - if m: - try: - return float(m.group(0)) - except Exception: - return None - return None - +def parse_get_post_from_weblist_json(body: str) -> Tuple[List[str], List[str]]: + try: + data = json.loads(body) + except Exception: + return [], [] + if not isinstance(data, dict): + return [], [] + get_candidates = [] + post_candidates = [] + for key, val in data.items(): + k = str(key).strip().lower().rstrip(":") + if k == "get": + get_candidates.extend(extract_names(val)) + elif k == "post": + post_candidates.extend(extract_names(val)) + # dedupe while preserving order + seen=set(); get_out=[] + for v in get_candidates: + if v not in seen: + seen.add(v); get_out.append(v) + seen=set(); post_out=[] + for v in post_candidates: + if v not in seen: + seen.add(v); post_out.append(v) + return get_out, post_out # ===================== # Data model @@ -199,7 +206,6 @@ class Thresholds: action_extreme_high: Optional[str] = None value_extreme_high: str = "1" - @dataclass class VarInfo: name: str @@ -210,7 +216,6 @@ class VarInfo: thresholds: Thresholds = field(default_factory=Thresholds) last_state: str = "UNKNOWN" - @dataclass class ActionTask: name: str @@ -221,7 +226,6 @@ class ActionTask: next_run: float = 0.0 task_id: int = 0 - # ===================== # Scheduler thread # ===================== @@ -262,7 +266,9 @@ class ActionScheduler(threading.Thread): def run_once(self, name: str, value: str) -> Tuple[int, str, Dict[str, str]]: base = self.get_base_url_cb() - status, body, headers = http_post(base, {"variable": name, "value": value}) + status, body, headers = http_get(base, {"variable": name, "value": value}) + # If server expects POST, switch to POST: change to http_post() if needed in your env. + # status, body, headers = http_post(base, {"variable": name, "value": value}) return status, body, headers def run(self) -> None: @@ -291,21 +297,102 @@ class ActionScheduler(threading.Thread): def stop(self) -> None: self._stop.set() +# ===================== +# Poller thread +# ===================== +class Poller(threading.Thread): + def __init__( + self, + host: str, + port: int, + variables: List[str], + ui_queue: queue.Queue, + refresh_interval: int, + max_rps: float, + stop_event: threading.Event, + paused_event: threading.Event, + ) -> None: + super().__init__(daemon=True) + self.host = host + self.port = port + self.variables = variables + self.ui_queue = ui_queue + self.refresh_interval = int(max(5, refresh_interval)) + self.max_rps = max(0.5, max_rps) + self.stop_event = stop_event + self.paused_event = paused_event + + def run(self) -> None: + base_url = build_base_url(self.host, self.port) + min_spacing = 1.0 / self.max_rps + while not self.stop_event.is_set(): + cycle_start = time.time() + self.ui_queue.put(("cycle_start", datetime.now().strftime("%H:%M:%S"))) + for name in list(self.variables): + while self.paused_event.is_set() and not self.stop_event.is_set(): + time.sleep(0.1) + if self.stop_event.is_set(): + break + + attempt = 0 + backoff = BACKOFF_BASE_S + while attempt <= MAX_RETRIES and not self.stop_event.is_set(): + attempt += 1 + t0 = time.time() + status, body, headers = http_get(base_url, {"variable": name}) + if status == 200: + self.ui_queue.put(("update", name, body, status)) + break + + if status in (429, 500, 502, 503, 504, 0): + self.ui_queue.put(("error", name, status, coerce_preview(body, 200))) + time.sleep(backoff) + backoff = min(BACKOFF_MAX_S, backoff * 2.0) + else: + self.ui_queue.put(("error", name, status, coerce_preview(body, 200))) + break + + dt = time.time() - t0 + if dt < min_spacing: + time.sleep(min_spacing - dt) + + dt = time.time() - t0 + if dt < min_spacing: + time.sleep(min_spacing - dt) + + cycle_dt = time.time() - cycle_start + remaining = self.refresh_interval - cycle_dt + if remaining > 0: + for _ in range(int(remaining * 10)): + if self.stop_event.is_set(): + break + time.sleep(0.1) + + self.ui_queue.put(("stopped", "")) # ===================== # GUI # ===================== -class App(tk.Tk): - def __init__(self) -> None: - super().__init__() - self.title("Control Board Monitor — v0.4") - self.geometry("1250x800") +def play_alarm_tone(level: str = "LOW") -> None: + try: + import winsound + freq = {"DEAD_LOW": 300, "LOW": 550, "HIGH": 800, "EXTREME_HIGH": 1000}.get(level, 600) + dur = 350 if level != "EXTREME_HIGH" else 600 + winsound.Beep(freq, dur) + except Exception: try: - self.iconbitmap(default="") + root = tk._default_root + if root is not None: + root.bell() except Exception: pass - # State +class App(tk.Tk): + def __init__(self) -> None: + super().__init__() + self.title("Control Board Monitor — v0.6") + self.geometry("1250x800") + self.vars: Dict[str, VarInfo] = {} self.stop_event = threading.Event() self.paused_event = threading.Event() @@ -314,11 +401,9 @@ class App(tk.Tk): self.variables_list: List[str] = [] self.functions_list: List[str] = [] - # Scheduler self.scheduler = ActionScheduler(lambda: build_base_url(self.host_var.get().strip(), int(self.port_var.get()))) self.scheduler.start() - # ---- Top controls ---- controls = ttk.Frame(self, padding=10) controls.pack(side=tk.TOP, fill=tk.X) @@ -348,18 +433,15 @@ class App(tk.Tk): self.pause_btn = ttk.Button(btns, text="Pause", command=self.toggle_pause, state="disabled") self.pause_btn.grid(row=0, column=3, padx=4) - # ---- Split panes ---- main = ttk.Panedwindow(self, orient=tk.VERTICAL) main.pack(side=tk.TOP, fill=tk.BOTH, expand=True, padx=10, pady=(0,10)) - # Filter/Search 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) - # Top: Variable monitor tree top_frame = ttk.Frame(main) search_frame.pack(in_=top_frame, side=tk.TOP, fill=tk.X) columns = ("value", "updated", "status") @@ -385,12 +467,10 @@ class App(tk.Tk): self.tree.tag_configure("bad", background="#ffecec") self.tree.tag_configure("neutral", background="#f5f7fb") - # Context menu to configure thresholds self.tree_menu = tk.Menu(self, tearoff=False) self.tree_menu.add_command(label="Set thresholds & alarms…", command=self.open_thresholds_dialog) self.tree.bind("", self.on_tree_right_click) - # Bottom: Actions/Scheduler tree + controls bottom = ttk.Frame(main) act_controls = ttk.Frame(bottom, padding=(0,6,0,6)) @@ -413,7 +493,6 @@ class App(tk.Tk): self.actions_tree.pack(side=tk.TOP, fill=tk.BOTH, expand=True) act_controls.pack(side=tk.TOP, fill=tk.X) - # Actions tree 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) @@ -423,7 +502,6 @@ class App(tk.Tk): main.add(top_frame, weight=3) main.add(bottom, weight=2) - # Footer footer = ttk.Frame(self, padding=10) footer.pack(side=tk.BOTTOM, fill=tk.X) self.cycle_lbl = ttk.Label(footer, text="Last cycle: —") @@ -431,13 +509,10 @@ class App(tk.Tk): self.protocol("WM_DELETE_WINDOW", self.on_close) - # Initial: don't assume server works; just preload defaults into selector - self.variables_list = [] self.functions_list = list(DEFAULT_FUNCTIONS) self.func_combo["values"] = self.functions_list - # optionally, we could auto-open selector; but keep consistent with your flow. - # ------------- Discovery & selector ------------- + # --- Discovery --- def reload_discovery(self) -> None: host = self.host_var.get().strip() port = int(self.port_var.get()) @@ -445,56 +520,29 @@ class App(tk.Tk): discovered_vars: List[str] = [] discovered_funcs: List[str] = [] - msgs = [] - # Try JSON vars - status, body, _ = http_get(base, {"variable": "WEBSERVER_LIST_VARIABLES_JSON"}) - if status == 200: - try: - data = json.loads(body) - if isinstance(data, list): - for x in data: - if isinstance(x, str): - v = x.strip().upper() - if v and v not in discovered_vars: - discovered_vars.append(v) - except Exception as e: - msgs.append(f"Vars JSON parse error: {e}") + # 1) WEBSERVER_GET_JSON — source of truth for GET/POST + status, body, _ = http_get(base, {"variable": "WEBSERVER_LIST_FUNCTIONS_JSON"}) + if status == 200 and body: + v_get, v_post = parse_get_post_from_weblist_json(body) + discovered_vars.extend(v_get) + discovered_funcs.extend(v_post) else: - msgs.append(f"Vars JSON HTTP {status}: {coerce_preview(body, 120)}") + msgs.append(f"GET_JSON HTTP {status}: {coerce_preview(body, 120)}") - # Try HTML vars + functions - status_h, body_h, _h = http_get(base, {"variable": "WEBSERVER_LIST_VARIABLES"}) - if status_h == 200 and body_h: - cand = parse_variable_names_from_html_index(body_h) - for v in cand: - if v not in discovered_vars: - discovered_vars.append(v) - f_cand = parse_function_names_from_html_index(body_h) - for f in f_cand: - if f not in discovered_funcs: - discovered_funcs.append(f) - else: - msgs.append(f"Vars HTML HTTP {status_h}: {coerce_preview(body_h, 120)}") + # 2) Fallback to main page parse (HTML links and POST section) + if not discovered_vars or not discovered_funcs: + s2, b2, _2 = http_get_root(base) + if s2 == 200 and b2: + if not discovered_vars: + discovered_vars = parse_variable_names_from_html_index(b2) or discovered_vars + if not discovered_funcs: + discovered_funcs = parse_function_names_from_html_index(b2) or discovered_funcs + else: + msgs.append(f"Main page HTTP {s2}: {coerce_preview(b2, 120)}") - # Optional functions JSON - status_f, body_f, _f = http_get(base, {"variable": "WEBSERVER_LIST_FUNCTIONS_JSON"}) - if status_f == 200: - try: - dataf = json.loads(body_f) - if isinstance(dataf, list): - for x in dataf: - if isinstance(x, str): - fx = x.strip().upper() - if fx and fx not in discovered_funcs: - discovered_funcs.append(fx) - except Exception as e: - msgs.append(f"Funcs JSON parse error: {e}") - elif status_f != 0: - msgs.append(f"Funcs JSON HTTP {status_f}: {coerce_preview(body_f, 120)}") - - # Fallbacks + # 3) Final fallback to embedded defaults used_defaults = False if not discovered_vars: discovered_vars = list(DEFAULT_VARS) @@ -507,21 +555,20 @@ class App(tk.Tk): self.func_combo["values"] = self.functions_list self.show_selector_dialog(discovered_vars) - # User-visible status + # Status if used_defaults: messagebox.showwarning( - "Discovery failed — loaded defaults", - "Could not discover variables from the server.\n" - "Loaded a built-in list from your spec so you can proceed.\n\n" - + ("\n".join(msgs[:6]) if msgs else "No server responses.")) + "Discovery fallback", + "Could not discover variables/functions from WEBSERVER_GET_JSON or main page.\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)} | functions: {len(discovered_funcs)}") + self.status_lbl.configure(text=f"Discovered via GET_JSON: vars {len(discovered_vars)} | funcs {len(discovered_funcs)}") + # --- Select Vars dialog --- def open_selector(self) -> None: union = sorted(set(self.variables_list) | set(self.vars.keys())) if not union: - # use defaults if nothing yet union = list(DEFAULT_VARS) self.show_selector_dialog(union) @@ -587,7 +634,7 @@ class App(tk.Tk): 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) - # ------------- Thresholds dialog ------------- + # --- Thresholds/Alarms --- def on_tree_right_click(self, event): try: iid = self.tree.identify_row(event.y) @@ -603,11 +650,9 @@ class App(tk.Tk): return item = sel[0] name = self.tree.item(item, "text") - # If user right-clicked a group header, bail if not name or "_" not in name: messagebox.showinfo("Select variable", "Select a specific variable row (not a group).") return - # remove any state suffix in brackets if name.endswith("]") and "[" in name: name = name[:name.rfind("[")].strip() vi = self.vars.get(name) or VarInfo(name=name) @@ -692,37 +737,7 @@ class App(tk.Tk): ttk.Button(btns, text="Save", command=save_and_close).pack(side=tk.RIGHT, padx=4) ttk.Button(btns, text="Cancel", command=dlg.destroy).pack(side=tk.RIGHT, padx=4) - # ------------- Actions (POST) ------------- - 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"{fname}={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("360x160"); 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.IntVar(value=30) - ttk.Label(top, text="Interval (seconds):").pack(pady=(6,0)) - ttk.Entry(top, textvariable=int_var, width=10).pack() - - def add_it(): - task = ActionTask(name=fname, value=val_var.get(), interval_s=max(1, int_var.get())) - tid = self.scheduler.add_task(task) - self.refresh_actions_tree() - top.destroy() - ttk.Button(top, text="Add", command=add_it).pack(pady=10) - + # --- Actions (Run Once / Schedule) --- def on_actions_right_click(self, event): try: iid = self.actions_tree.identify_row(event.y) @@ -736,7 +751,7 @@ class App(tk.Tk): if not sel: return tid = int(sel[0]) tasks = {t.task_id: t for t in self.scheduler.list_tasks()} - t = tasks.get(tid) + t = tasks.get(tid); if not t: return self.scheduler.set_enabled(tid, not t.enabled) self.refresh_actions_tree() @@ -765,9 +780,9 @@ class App(tk.Tk): 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.value, mode, t.interval_s, next_s, "Yes" if t.enabled else "No")) - # ------------- Poller control ------------- + # --- Poller control / rendering --- def start_polling(self) -> None: - if self.poller and self.poller.is_alive(): + 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_list: @@ -847,7 +862,6 @@ class App(tk.Tk): self.start_btn.configure(state="normal") self.pause_btn.configure(state="disabled", text="Pause") - # ------------- Threshold logic & rendering ------------- def evaluate_thresholds(self, name: str, value: str) -> None: vi = self.vars.get(name) if not vi: @@ -879,7 +893,10 @@ class App(tk.Tk): "EXTREME_HIGH": t.alarm_extreme_high, } if alarm_map.get(state): - play_alarm_tone(state) + try: + play_alarm_tone(state) + except Exception: + pass act_map = { "DEAD_LOW": (t.action_dead_low, t.value_dead_low), "LOW": (t.action_low, t.value_low), @@ -888,9 +905,9 @@ class App(tk.Tk): } act, val = act_map.get(state, (None, "1")) if act: + # one-shot task self.scheduler.add_task(ActionTask(name=act, value=val, interval_s=0)) - # ------------- Tree rendering ------------- def refresh_tree(self) -> None: self.tree.delete(*self.tree.get_children()) groups: Dict[str, List[VarInfo]] = {} @@ -915,7 +932,12 @@ class App(tk.Tk): else: value_preview = coerce_preview(vi.last_value, 120) - tag = infer_status_color(vi.last_value if not vi.error else "error") + tag = "neutral" + if vi.error: + tag = "bad" + else: + tag = "ok" if value_preview not in ("",) else "neutral" + suffix = "" if vi.last_state in {"UNKNOWN", "NORMAL"} else f" [{vi.last_state}]" self.tree.insert( parent, @@ -925,10 +947,10 @@ class App(tk.Tk): tags=(tag,), ) - # ------------- Shutdown ------------- def on_close(self) -> None: try: - self.stop_polling() + if self.poller and self.poller.is_alive(): + self.stop_event.set() except Exception: pass try: @@ -937,85 +959,19 @@ class App(tk.Tk): pass self.destroy() - -# ===================== -# Poller thread -# ===================== -class Poller(threading.Thread): - def __init__( - self, - host: str, - port: int, - variables: List[str], - ui_queue: queue.Queue, - refresh_interval: int, - max_rps: float, - stop_event: threading.Event, - paused_event: threading.Event, - ) -> None: - super().__init__(daemon=True) - self.host = host - self.port = port - self.variables = variables - self.ui_queue = ui_queue - self.refresh_interval = int(max(5, refresh_interval)) - self.max_rps = max(0.5, max_rps) - self.stop_event = stop_event - self.paused_event = paused_event - - def run(self) -> None: - base_url = build_base_url(self.host, self.port) - min_spacing = 1.0 / self.max_rps - while not self.stop_event.is_set(): - cycle_start = time.time() - self.ui_queue.put(("cycle_start", datetime.now().strftime("%H:%M:%S"))) - for name in list(self.variables): - while self.paused_event.is_set() and not self.stop_event.is_set(): - time.sleep(0.1) - if self.stop_event.is_set(): - break - - attempt = 0 - backoff = BACKOFF_BASE_S - while attempt <= MAX_RETRIES and not self.stop_event.is_set(): - attempt += 1 - t0 = time.time() - status, body, headers = http_get(base_url, {"variable": name}) - if status == 200: - self.ui_queue.put(("update", name, body, status)) - break - - if status in (429, 500, 502, 503, 504, 0): - self.ui_queue.put(("error", name, status, coerce_preview(body, 200))) - time.sleep(backoff) - backoff = min(BACKOFF_MAX_S, backoff * 2.0) - else: - self.ui_queue.put(("error", name, status, coerce_preview(body, 200))) - break - - dt = time.time() - t0 - if dt < min_spacing: - time.sleep(min_spacing - dt) - - dt = time.time() - t0 - if dt < min_spacing: - time.sleep(min_spacing - dt) - - cycle_dt = time.time() - cycle_start - remaining = self.refresh_interval - cycle_dt - if remaining > 0: - for _ in range(int(remaining * 10)): - if self.stop_event.is_set(): - break - time.sleep(0.1) - - self.ui_queue.put(("stopped", "")) - +# --- Minor parsing utility --- +def parse_first_float(value: str) -> Optional[float]: + m = re.search(r"[-+]?\d+(?:\.\d+)?", value.replace(",", ".")) + if m: + try: + return float(m.group(0)) + except Exception: + return None + return None def main() -> None: app = App() app.mainloop() - if __name__ == "__main__": main()