From 23084f4cb43f9c4919c1453aeb34f5d32a63e67f Mon Sep 17 00:00:00 2001 From: Michal Tuszowski Date: Mon, 20 Oct 2025 18:27:06 +0200 Subject: [PATCH] 0.9 --- nucleares_monitor/control_board_monitor.py | 334 ++++++++++++--------- 1 file changed, 196 insertions(+), 138 deletions(-) diff --git a/nucleares_monitor/control_board_monitor.py b/nucleares_monitor/control_board_monitor.py index abd05e6..ac78e03 100644 --- a/nucleares_monitor/control_board_monitor.py +++ b/nucleares_monitor/control_board_monitor.py @@ -1,15 +1,18 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- """ -Control Board Monitor — v0.8 +Control Board Monitor — v0.9 -Spec alignment: -- Discovery of functions (POST actions) via WEBSERVER_LIST_VARIABLES_JSON → "POST:" -- Discovery of variables & polling via WEBSERVER_BATCH_GET (bulk values) -- Fallbacks: main page parse (/, GET links / POST section) → embedded defaults -- Expressions/snippets on thresholds with x = current value (safe eval) -- Functions executed via POST (variable=, value=) -- Default host/port: localhost:8785 +Fixes: +- Selection flicker fixed: TreeView uses stable IDs and incremental updates. +- All HTTP (GET/POST) serialized via a global lock + "Connection: close" to prevent server resets when polling+posting. +- Expressions now post their result to a *chosen function* per state (not to the variable itself), avoiding "writable variable" errors. +- Function discovery preserves original case; posts use function names exactly as discovered. + +Spec: +- Functions (POST actions) discovered via WEBSERVER_LIST_VARIABLES_JSON → "POST:" +- Variables for selection & bulk polling via WEBSERVER_BATCH_GET (keys) +- Default host:port = localhost:8785 """ import json @@ -35,9 +38,9 @@ except Exception: # ===================== SERVER_HOST: str = "localhost" SERVER_PORT: int = 8785 -REFRESH_INTERVAL_S: int = 60 +REFRESH_INTERVAL_S: int = 1 REQUEST_TIMEOUT_S: float = 5.0 -USER_AGENT: str = "ControlBoardMonitor/0.8 (+tkinter)" +USER_AGENT: str = "ControlBoardMonitor/0.9 (+tkinter)" BACKOFF_BASE_S: float = 1.0 BACKOFF_MAX_S: float = 15.0 MAX_RETRIES: int = 2 @@ -46,77 +49,51 @@ MAX_RETRIES: int = 2 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]]: + # Ensure single outstanding request at a time + 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}) - 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), {} + req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT, "Connection": "close"}) + return _request(req) 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}) - 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), {} + req = urllib.request.Request(url, data=post_data, headers={"User-Agent": USER_AGENT, "Connection": "close"}) + 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}) - 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), {} + 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", " ") @@ -134,11 +111,10 @@ def parse_variable_names_from_html_index(html_text: str) -> List[str]: tokens.append(m.group(1)) seen = set(); uniq: List[str] = [] for v in tokens: - vv = v.strip().strip(".,;)]}").strip().upper() - if vv and vv not in seen: + vv = v.strip().strip(".,;)]}").strip() + if vv and vv.upper() not in {"VARNAME", "VARIABLE", "NAME"} and vv not in seen: seen.add(vv); uniq.append(vv) - placeholders = {"VARNAME", "VARIABLE", "NAME"} - return [v for v in uniq if v not in placeholders] + return uniq def parse_function_names_from_html_index(html_text: str) -> List[str]: start = html_text.find("==== POST ====") @@ -150,12 +126,11 @@ def parse_function_names_from_html_index(html_text: str) -> List[str]: for n in names: if n not in seen: seen.add(n); uniq.append(n) - placeholders = {"VARNAME", "VARIABLE", "NAME"} - return [v for v in uniq if v not in placeholders] + return uniq # ---- Parse WEBSERVER_LIST_VARIABLES_JSON ---- def parse_weblist_names(body: str) -> Tuple[List[str], List[str]]: - """Return (get_list, post_list).""" + """Return (get_list, post_list) with original casing if possible.""" try: data = json.loads(body) except Exception: @@ -165,15 +140,15 @@ def parse_weblist_names(body: str) -> Tuple[List[str], List[str]]: if isinstance(data, dict): for k, v in data.items(): - key = str(k).strip().upper().rstrip(":") + key = str(k).strip().rstrip(":").upper() if key in ("GET", "POST"): - names = extract_names(v) + 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(data)) + get_names.extend(extract_names_preserve_case(data)) def dedup(seq): seen=set(); out=[] @@ -183,13 +158,11 @@ def parse_weblist_names(body: str) -> Tuple[List[str], List[str]]: return out return dedup(get_names), dedup(post_names) -def extract_names(value_obj) -> List[str]: +def extract_names_preserve_case(value_obj) -> List[str]: out: List[str] = [] def add(n): - if isinstance(n, str): - u = n.strip().upper() - if u and u not in out: - out.append(u) + 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): @@ -199,14 +172,14 @@ def extract_names(value_obj) -> List[str]: if k in it and isinstance(it[k], str): add(it[k]) for k in list(it.keys()): - if isinstance(k, str) and k.isupper(): + 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-Z0-9_]+", value_obj.upper()): + for m in re.findall(r"[A-Za-z0-9_]+", value_obj): add(m) return out @@ -221,11 +194,10 @@ def parse_batch_values(body: str) -> Dict[str, str]: for k, v in data.items(): if not isinstance(k, str): k = str(k) - key = k.strip().upper() try: - values[key] = v if isinstance(v, str) else json.dumps(v) + values[k] = v if isinstance(v, str) else json.dumps(v) except Exception: - values[key] = str(v) + values[k] = str(v) return values # ===================== @@ -251,11 +223,16 @@ class Thresholds: value_high: str = "1" action_extreme_high: Optional[str] = None value_extreme_high: str = "1" - # expression actions (NEW): evaluated with x = current numeric value + # expression actions expr_dead_low: Optional[str] = None expr_low: Optional[str] = None expr_high: Optional[str] = None expr_extreme_high: Optional[str] = None + # NEW: 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 @dataclass class VarInfo: @@ -317,7 +294,6 @@ class ActionScheduler(threading.Thread): def run_once(self, name: str, value: str) -> Tuple[int, str, Dict[str, str]]: base = self.get_base_url_cb() - # Functions use POST return http_post(base, {"variable": name, "value": value}) def run(self) -> None: @@ -379,9 +355,17 @@ class Poller(threading.Thread): if status == 200: values_map = parse_batch_values(body) if values_map: - upper_map = {k.upper(): v for k, v in values_map.items()} + # direct case-sensitive names from server for name in list(self.variables): - val = upper_map.get(name.upper()) + # find exact or case-insensitive match + val = None + if name in values_map: + val = values_map[name] + else: + # case-insensitive fallback + for k, v in values_map.items(): + if k.lower() == name.lower(): + val = v; break if val is not None: self.ui_queue.put(("update", name, str(val), 200)) else: @@ -422,8 +406,8 @@ class Poller(threading.Thread): class App(tk.Tk): def __init__(self) -> None: super().__init__() - self.title("Control Board Monitor — v0.8") - self.geometry("1250x820") + self.title("Control Board Monitor — v0.9") + self.geometry("1250x840") self.vars: Dict[str, VarInfo] = {} self.stop_event = threading.Event() @@ -474,7 +458,7 @@ class App(tk.Tk): top_frame = ttk.Frame(main) search_frame.pack(in_=top_frame, side=tk.TOP, fill=tk.X) columns = ("value", "updated", "status") - self.tree = ttk.Treeview(top_frame, columns=columns, show="tree headings", height=18) + self.tree = ttk.Treeview(top_frame, columns=columns, show="tree headings", height=18, selectmode="browse") self.tree.pack(side=tk.TOP, fill=tk.BOTH, expand=True) self.tree.heading("#0", text="Variable") @@ -482,8 +466,8 @@ class App(tk.Tk): self.tree.heading("updated", text="Updated") self.tree.heading("status", text="HTTP") - self.tree.column("#0", width=480, anchor="w") - self.tree.column("value", width=480, anchor="w") + self.tree.column("#0", width=520, anchor="w") + self.tree.column("value", width=460, anchor="w") self.tree.column("updated", width=120, anchor="center") self.tree.column("status", width=60, anchor="center") @@ -512,7 +496,7 @@ class App(tk.Tk): 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) + 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"], [260, 160, 100, 100, 180, 80]): self.actions_tree.heading(col, text=hdr) self.actions_tree.column(col, width=w, anchor="center") @@ -539,6 +523,10 @@ class App(tk.Tk): self.functions_list = list(DEFAULT_FUNCTIONS) self.func_combo["values"] = self.functions_list + # Stable IDs + self._group_ids: Dict[str, str] = {} + self._var_ids: Dict[str, str] = {} + # --- Discovery --- def reload_discovery(self) -> None: host = self.host_var.get().strip() @@ -568,7 +556,6 @@ class App(tk.Tk): msgs.append(f"BATCH_GET HTTP {s2}: {coerce_preview(b2,120)}") # 3) Fallbacks - # 3a: parse main page for vars/funcs if not discovered_vars or not discovered_funcs: s3, b3, _3 = http_get_root(base) if s3 == 200 and b3: @@ -579,11 +566,10 @@ class App(tk.Tk): else: msgs.append(f"Main page HTTP {s3}: {coerce_preview(b3,120)}") - # 3b: embedded defaults + # 4) embedded defaults used_defaults = False if not discovered_vars: - discovered_vars = list(DEFAULT_VARS) - used_defaults = True + discovered_vars = list(DEFAULT_VARS); used_defaults = True if not discovered_funcs: discovered_funcs = list(DEFAULT_FUNCTIONS) @@ -641,9 +627,9 @@ class App(tk.Tk): for child in inner.winfo_children(): child.destroy() rows.clear() - f = filt_var.get().strip().upper() + f = filt_var.get().strip().lower() for name in discovered: - if f and f not in name.upper(): + if f and f not in name.lower(): continue var = tk.BooleanVar(value=name in current_set) cb = ttk.Checkbutton(inner, text=name, variable=var) @@ -824,22 +810,30 @@ class App(tk.Tk): self.tree_menu.tk_popup(event.x_root, event.y_root) def open_thresholds_dialog(self): + # Find selected variable row (skip groups) sel = self.tree.selection() if not sel: messagebox.showinfo("No selection", "Select a variable first.") return - item = sel[0] - name = self.tree.item(item, "text") - if not name or "_" not in name: + iid = sel[0] + name = self.tree.item(iid, "text") + # If group, try to select first child + if name and iid.startswith("grp:"): + children = self.tree.get_children(iid) + if not children: + messagebox.showinfo("Empty group", "This group has no items.") + return + iid = children[0] + name = self.tree.item(iid, "text") + if not name or name.startswith("grp:"): messagebox.showinfo("Select variable", "Select a specific variable row (not a group).") return - if name.endswith("]") and "[" in name: - name = name[:name.rfind("[")].strip() + vi = self.vars.get(name) or VarInfo(name=name) dlg = tk.Toplevel(self) dlg.title(f"Thresholds, actions & alarms — {name}") - dlg.geometry("780x600") + dlg.geometry("900x650") dlg.transient(self); dlg.grab_set() t = vi.thresholds @@ -880,7 +874,7 @@ class App(tk.Tk): value_vars = {} for i, (label, action_key, value_key) in enumerate(rows): ttk.Label(grid, text=label).grid(row=8+i, column=0, sticky="w", pady=2) - s = ttk.Combobox(grid, values=self.functions_list or DEFAULT_FUNCTIONS, width=38, state="readonly") + s = ttk.Combobox(grid, values=self.functions_list or DEFAULT_FUNCTIONS, width=42, state="readonly") s.set(getattr(t, action_key) or "") s.grid(row=8+i, column=1, sticky="w", padx=6) action_vars[action_key] = s @@ -888,20 +882,38 @@ class App(tk.Tk): ttk.Entry(grid, textvariable=sv, width=12).grid(row=8+i, column=2, sticky="w", padx=6) value_vars[value_key] = sv - # expression actions - ttk.Label(grid, text="On enter: evaluate expression/snippet with x = current numeric value (optional)").grid(row=12, column=0, sticky="w", pady=(14,4), columnspan=3) + # expression actions (+ target function) + ttk.Label(grid, text="On enter: evaluate expression/snippet (x = current value) and optionally POST to a function").grid(row=12, column=0, sticky="w", pady=(14,4), columnspan=3) + expr_vars = { "expr_dead_low": tk.StringVar(value=t.expr_dead_low or ""), "expr_low": tk.StringVar(value=t.expr_low or ""), "expr_high": tk.StringVar(value=t.expr_high or ""), "expr_extreme_high": tk.StringVar(value=t.expr_extreme_high or ""), } - for i, (label, key) in enumerate([("Dead Low expr", "expr_dead_low"), - ("Low expr", "expr_low"), - ("High expr", "expr_high"), - ("Extreme High expr", "expr_extreme_high")]): + expr_target_vars = { + "expr_target_dead_low": tk.StringVar(value=t.expr_target_dead_low or ""), + "expr_target_low": tk.StringVar(value=t.expr_target_low or ""), + "expr_target_high": tk.StringVar(value=t.expr_target_high or ""), + "expr_target_extreme_high": tk.StringVar(value=t.expr_target_extreme_high or ""), + } + + for i, (label, key, tkey) in enumerate([("Dead Low expr", "expr_dead_low", "expr_target_dead_low"), + ("Low expr", "expr_low", "expr_target_low"), + ("High expr", "expr_high", "expr_target_high"), + ("Extreme High expr", "expr_extreme_high", "expr_target_extreme_high")]): ttk.Label(grid, text=label).grid(row=13+i, column=0, sticky="w", pady=2) - ttk.Entry(grid, textvariable=expr_vars[key], width=56).grid(row=13+i, column=1, sticky="w", padx=6, columnspan=2) + ttk.Entry(grid, textvariable=expr_vars[key], width=56).grid(row=13+i, column=1, sticky="w", padx=6) + ttk.Label(grid, text="→ POST to function").grid(row=13+i, column=2, sticky="e") + s = ttk.Combobox(grid, values=self.functions_list or DEFAULT_FUNCTIONS, width=30, state="readonly") + s.set(expr_target_vars[tkey].get()) + s.grid(row=13+i, column=3, sticky="w", padx=6) + # keep binding in sync + def bind_setter(varkey=tkey, combo=s): + def _(): + expr_target_vars[varkey].set(combo.get()) + combo.bind("<>", lambda *_: _()) + bind_setter() def save_and_close(): def _f(v): @@ -928,6 +940,10 @@ class App(tk.Tk): expr_low=expr_vars["expr_low"].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_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, ) vi.thresholds = nt self.vars[name] = vi @@ -979,18 +995,26 @@ class App(tk.Tk): try: self.bell() except Exception: pass - # Expression takes precedence if provided + # Expression (compute & optionally POST to chosen function) 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 if expr and x is not None: try: computed = eval_user_expression(expr, x) - # Send result as new value for this variable - base = build_base_url(self.host_var.get().strip(), int(self.port_var.get())) - http_post(base, {"variable": name, "value": str(computed)}) + if target_func: + base = build_base_url(self.host_var.get().strip(), int(self.port_var.get())) + http_post(base, {"variable": target_func, "value": str(computed)}) + else: + # No target selected; inform briefly + self.status_lbl.configure(text=f"Expression computed for {name} [{state}], no target function set") except Exception as e: messagebox.showwarning("Expression error", f"{name}: failed to evaluate expression on enter {state}: {e}") # fall through to function action @@ -1007,22 +1031,50 @@ class App(tk.Tk): # 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]] = {} - filt = self.filter_var.get().strip().upper() + # --- Tree incremental update --- + 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, name: str) -> str: + iid = self._var_ids.get(name) + if iid and self.tree.exists(iid): + return iid + iid = f"var:{name}" + self._var_ids[name] = iid + if not self.tree.exists(iid): + self.tree.insert(group_iid, "end", iid=iid, text=name, values=("", "", ""), tags=("neutral",)) + return iid + + def refresh_tree(self) -> None: + filt = self.filter_var.get().strip().lower() + # Build groups from current vars + groups: Dict[str, List[VarInfo]] = {} for name, vi in self.vars.items(): - if filt and filt not in name.upper(): - if vi.last_value and filt not in vi.last_value.upper(): - continue + if filt and filt not in name.lower() and (not vi.last_value or filt not in vi.last_value.lower()): + continue prefix = name.split("_", 1)[0] if "_" in name else "MISC" groups.setdefault(prefix, []).append(vi) + # Preserve current selection + 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()): - parent = self.tree.insert("", "end", text=g, values=("", "", ""), open=True) - for vi in sorted(groups[g], key=lambda x: x.name): + gid = self._ensure_group(g) + valid_group_iids.add(gid) + for vi in sorted(groups[g], key=lambda x: x.name.lower()): + vid = self._ensure_var(gid, vi.name) + valid_var_iids.add(vid) updated_str = "—" if vi.last_updated: updated_str = datetime.fromtimestamp(vi.last_updated).strftime("%H:%M:%S") @@ -1039,13 +1091,19 @@ class App(tk.Tk): 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, - "end", - text=vi.name + suffix, - values=(value_preview, updated_str, vi.last_status or "—"), - tags=(tag,), - ) + self.tree.item(vid, text=vi.name + suffix, values=(value_preview, updated_str, vi.last_status or "—"), tags=(tag,)) + + # Remove stale items + for name, 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(name, 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) + + # Restore selection if possible + if sel_iid and self.tree.exists(sel_iid): + self.tree.selection_set(sel_iid) def on_close(self) -> None: try: