From a2fbabedcea50f37e3c033d1494f70cf468b0db7 Mon Sep 17 00:00:00 2001 From: Michal Tuszowski Date: Mon, 20 Oct 2025 18:27:37 +0200 Subject: [PATCH] 1.0 --- nucleares_monitor/control_board_monitor.py | 472 ++++++++++++--------- 1 file changed, 283 insertions(+), 189 deletions(-) diff --git a/nucleares_monitor/control_board_monitor.py b/nucleares_monitor/control_board_monitor.py index ac78e03..4c19d9b 100644 --- a/nucleares_monitor/control_board_monitor.py +++ b/nucleares_monitor/control_board_monitor.py @@ -1,18 +1,21 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- """ -Control Board Monitor — v0.9 +Control Board Monitor — v1.0 -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. +Changes requested: +- POST behavior matches the sample app by default: POST "/" with form body "Variable" + "value". + Switch via POST_MODE = "sample" | "legacy" (default="sample"). +- Robust variable selection: case-insensitive canonical keys, no duplicates, stable Treeview IDs. +- Threshold logic exactly as specified, with color gradients: + DEAD_LOW (= extreme_high) → deep red. Default alarms ON for DEAD_LOW & EXTREME_HIGH. + +Spec alignment: +- Discover POST actions from WEBSERVER_LIST_VARIABLES_JSON ("POST:"). +- Discover variables & poll with WEBSERVER_BATCH_GET (bulk). -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 @@ -38,13 +41,21 @@ except Exception: # ===================== SERVER_HOST: str = "localhost" SERVER_PORT: int = 8785 -REFRESH_INTERVAL_S: int = 1 +REFRESH_INTERVAL_S: int = 60 REQUEST_TIMEOUT_S: float = 5.0 -USER_AGENT: str = "ControlBoardMonitor/0.9 (+tkinter)" +USER_AGENT: str = "ControlBoardMonitor/1.0 (+tkinter)" BACKOFF_BASE_S: float = 1.0 BACKOFF_MAX_S: float = 15.0 MAX_RETRIES: int = 2 +# ---- POST mode ---- +# "sample" → POST to "/" with fields {"Variable": , "value": } +# "legacy" → POST to "/" with fields {"variable": , "value": } +POST_MODE: str = "sample" # change to "legacy" if needed + +POST_KEY_VARIABLE = "Variable" if POST_MODE == "sample" else "variable" +POST_KEY_VALUE = "value" # both modes use 'value' as in the sample + # 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'] @@ -56,10 +67,10 @@ _HTTP_LOCK = threading.Lock() # Helpers # ===================== def build_base_url(host: str, port: int) -> str: + # Always end with "/" so POSTs go to root path 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: @@ -86,7 +97,7 @@ def http_get(base_url: str, params: Dict[str, str]) -> Tuple[int, str, Dict[str, return _request(req) def http_post(base_url: str, data: Dict[str, str]) -> Tuple[int, str, Dict[str, str]]: - url = base_url + url = base_url # always "/" post_data = urllib.parse.urlencode(data).encode("utf-8") req = urllib.request.Request(url, data=post_data, headers={"User-Agent": USER_AGENT, "Connection": "close"}) return _request(req) @@ -99,23 +110,6 @@ 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]: - 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", []): - tokens.append(v) - for m in re.finditer(r'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: - 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) - return uniq - def parse_function_names_from_html_index(html_text: str) -> List[str]: start = html_text.find("==== POST ====") if start == -1: @@ -128,9 +122,25 @@ def parse_function_names_from_html_index(html_text: str) -> List[str]: 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) with original casing if possible.""" + """Return (get_list, post_list). Keep discovered case for function names.""" try: data = json.loads(body) except Exception: @@ -209,8 +219,8 @@ class Thresholds: low: Optional[float] = None high: Optional[float] = None extreme_high: Optional[float] = None - # alarms - alarm_dead_low: bool = False + # 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 @@ -228,7 +238,7 @@ class Thresholds: expr_low: Optional[str] = None expr_high: Optional[str] = None expr_extreme_high: Optional[str] = None - # NEW: expression target function (per state) + # expression target function (per state) expr_target_dead_low: Optional[str] = None expr_target_low: Optional[str] = None expr_target_high: Optional[str] = None @@ -236,7 +246,9 @@ class Thresholds: @dataclass class VarInfo: - name: str + # Canonical key is lower-case; display_name retains original case from discovery/selection + key: str + display_name: str last_value: str = "" last_updated: float = 0.0 last_status: int = 0 @@ -294,7 +306,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() - return http_post(base, {"variable": name, "value": value}) + # Sample-app compatible body + data = {POST_KEY_VARIABLE: name, POST_KEY_VALUE: value} + return http_post(base, data) def run(self) -> None: while not self._stop.is_set(): @@ -330,7 +344,7 @@ class Poller(threading.Thread): self, host: str, port: int, - variables: List[str], + variables_keys: List[str], # canonical lower-case keys ui_queue: queue.Queue, refresh_interval: int, stop_event: threading.Event, @@ -339,7 +353,7 @@ class Poller(threading.Thread): super().__init__(daemon=True) self.host = host self.port = port - self.variables = variables + self.variables_keys = variables_keys self.ui_queue = ui_queue self.refresh_interval = int(max(5, refresh_interval)) self.stop_event = stop_event @@ -355,37 +369,29 @@ class Poller(threading.Thread): if status == 200: values_map = parse_batch_values(body) if values_map: - # direct case-sensitive names from server - for name in list(self.variables): - # find exact or case-insensitive match - val = None - if name in values_map: - val = values_map[name] + # Build case-insensitive map + lower_map = {k.lower(): v for k, v in values_map.items()} + for key in list(self.variables_keys): + if key in lower_map: + self.ui_queue.put(("update", key, str(lower_map[key]), 200)) 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: - self.ui_queue.put(("error", name, 206, "Not in BATCH_GET payload")) + self.ui_queue.put(("error", key, 206, "Not in BATCH_GET payload")) else: - # fallback to per-variable - for name in list(self.variables): - st, b, _h = http_get(base_url, {"variable": name}) + # fallback to per-variable (try exact casing then lowercase) + for key in list(self.variables_keys): + st, b, _h = http_get(base_url, {"variable": key}) if st == 200: - self.ui_queue.put(("update", name, b, st)) + self.ui_queue.put(("update", key, b, st)) else: - self.ui_queue.put(("error", name, st, coerce_preview(b, 200))) + self.ui_queue.put(("error", key, st, coerce_preview(b, 200))) else: # fallback to per-variable - for name in list(self.variables): - st, b, _h = http_get(base_url, {"variable": name}) + for key in list(self.variables_keys): + st, b, _h = http_get(base_url, {"variable": key}) if st == 200: - self.ui_queue.put(("update", name, b, st)) + self.ui_queue.put(("update", key, b, st)) else: - self.ui_queue.put(("error", name, st, coerce_preview(b, 200))) + self.ui_queue.put(("error", key, st, coerce_preview(b, 200))) # sleep until next cycle, unless paused/stopped cycle_dt = time.time() - cycle_start @@ -406,16 +412,17 @@ class Poller(threading.Thread): class App(tk.Tk): def __init__(self) -> None: super().__init__() - self.title("Control Board Monitor — v0.9") - self.geometry("1250x840") + self.title("Control Board Monitor — v1.0") + 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_list: List[str] = [] - self.functions_list: List[str] = [] + self.variables_keys: List[str] = [] # list of lower-case keys in selection order + self.functions_list: List[str] = [] # preserve original casing self.scheduler = ActionScheduler(lambda: build_base_url(self.host_var.get().strip(), int(self.port_var.get()))) self.scheduler.start() @@ -458,7 +465,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, selectmode="browse") + self.tree = ttk.Treeview(top_frame, columns=columns, show="tree headings", height=20, selectmode="browse") self.tree.pack(side=tk.TOP, fill=tk.BOTH, expand=True) self.tree.heading("#0", text="Variable") @@ -466,18 +473,16 @@ class App(tk.Tk): self.tree.heading("updated", text="Updated") self.tree.heading("status", text="HTTP") - self.tree.column("#0", width=520, anchor="w") - self.tree.column("value", width=460, anchor="w") + self.tree.column("#0", width=540, anchor="w") + self.tree.column("value", width=480, anchor="w") self.tree.column("updated", width=120, anchor="center") self.tree.column("status", width=60, anchor="center") style = ttk.Style(self) try: style.theme_use("clam") except Exception: pass - self.tree.tag_configure("ok", background="#eaffea") - self.tree.tag_configure("bad", background="#ffecec") - self.tree.tag_configure("neutral", background="#f5f7fb") + # 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.bind("", self.on_tree_right_click) @@ -487,7 +492,7 @@ class App(tk.Tk): 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=40, state="readonly") + self.func_combo = ttk.Combobox(act_controls, textvariable=self.func_name_var, width=44, state="readonly") self.func_combo.pack(side=tk.LEFT, padx=6) ttk.Label(act_controls, text="Value:").pack(side=tk.LEFT) self.func_value_var = tk.StringVar(value="1") @@ -497,7 +502,7 @@ class App(tk.Tk): 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"], [260, 160, 100, 100, 180, 80]): + 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") @@ -527,6 +532,9 @@ class App(tk.Tk): 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] = {} + # --- Discovery --- def reload_discovery(self) -> None: host = self.host_var.get().strip() @@ -551,7 +559,12 @@ class App(tk.Tk): if s2 == 200 and b2: values_map = parse_batch_values(b2) if values_map: - discovered_vars.extend(sorted(values_map.keys())) + # Dedup preserving first seen, case-insensitive + 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)}") @@ -590,15 +603,25 @@ class App(tk.Tk): # --- Select Vars dialog --- def open_selector(self) -> None: - union = sorted(set(self.variables_list) | set(self.vars.keys())) + # Use discovered list if we have it, else defaults or already loaded vars + union = set() + # include discovered func of vars in existing self.vars + union.update([vi.display_name for vi in self.vars.values()]) if not union: - union = list(DEFAULT_VARS) - self.show_selector_dialog(union) + union.update(DEFAULT_VARS) + self.show_selector_dialog(sorted(union)) def show_selector_dialog(self, discovered: List[str]) -> None: + # Case-insensitive dedup for display + 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("600x520") + dlg.geometry("600x540") dlg.transient(self) dlg.grab_set() @@ -621,36 +644,50 @@ class App(tk.Tk): canvas.configure(yscrollcommand=vsb.set) canvas.pack(side="left", fill="both", expand=True); vsb.pack(side="right", fill="y") - current_set = set(self.variables_list or list(self.vars.keys())) - rows: List[Tuple[str, tk.BooleanVar, ttk.Checkbutton]] = [] + current_set = set(self.variables_keys) # already lower-case + rows: List[Tuple[str, str, tk.BooleanVar]] = [] # (display, key, var) + def rebuild(): for child in inner.winfo_children(): child.destroy() rows.clear() f = filt_var.get().strip().lower() - for name in discovered: + for name in disp: if f and f not in name.lower(): continue - var = tk.BooleanVar(value=name in current_set) + key = name.lower() + var = tk.BooleanVar(value=(key in current_set)) cb = ttk.Checkbutton(inner, text=name, variable=var) cb.pack(anchor="w", pady=2, padx=4) - rows.append((name, var, cb)) + rows.append((name, key, var)) rebuild() def on_filter(*_): rebuild() filt_var.trace_add("write", on_filter) - sel_all.configure(command=lambda: [v.set(True) for _, v, _ in rows]) - sel_none.configure(command=lambda: [v.set(False) for _, v, _ in rows]) + sel_all.configure(command=lambda: [v.set(True) for _,_,v in rows]) + sel_none.configure(command=lambda: [v.set(False) for _,_,v in rows]) bot = ttk.Frame(dlg, padding=8) bot.pack(side=tk.BOTTOM, fill=tk.X) def apply_and_close(): - selected = [name for name, var, _ in rows if var.get()] - if not selected: - if not messagebox.askyesno("Empty selection", "No variables selected. Continue?"): - return - self.variables_list = selected - self.vars = {v: self.vars.get(v, VarInfo(name=v)) for v in self.variables_list} + selected_keys = [key for _, key, var in rows if var.get()] + # preserve order: existing first, then new in displayed order + new_order = [k for k in self.variables_keys if k in selected_keys] + for _, key, var in rows: + if var.get() and key not in new_order: + new_order.append(key) + self.variables_keys = new_order + # merge VarInfo with preserved thresholds and display names + new_vars: Dict[str, VarInfo] = {} + for key in self.variables_keys: + if key in self.vars: + vi = self.vars[key] + else: + # Find display name from rows + 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 self.refresh_tree() dlg.destroy() ttk.Button(bot, text="Apply", command=apply_and_close).pack(side=tk.RIGHT, padx=4) @@ -734,7 +771,7 @@ class App(tk.Tk): 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: + if not self.variables_keys: messagebox.showwarning("No variables", "Variable list is empty. Use Select Vars… first.") return self.stop_event.clear() @@ -742,7 +779,7 @@ class App(tk.Tk): self.poller = Poller( host=self.host_var.get().strip(), port=int(self.port_var.get()), - variables=self.variables_list, + variables_keys=self.variables_keys, ui_queue=self.ui_queue, refresh_interval=int(self.refresh_var.get()), stop_event=self.stop_event, @@ -772,21 +809,23 @@ class App(tk.Tk): item = self.ui_queue.get_nowait() kind = item[0] if kind == "update": - _, name, value, status = item - vi = self.vars.get(name) or VarInfo(name=name) + _, key, value, status = item + vi = self.vars.get(key) + if not vi: + vi = VarInfo(key=key, display_name=key) vi.last_value = value vi.last_updated = time.time() vi.last_status = status vi.error = None - self.vars[name] = vi - self.evaluate_thresholds(name, value) + self.vars[key] = vi + self.evaluate_thresholds(vi, value) elif kind == "error": - _, name, status, msg = item - vi = self.vars.get(name) or VarInfo(name=name) + _, 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[name] = vi + self.vars[key] = vi elif kind == "cycle_start": _, hhmmss = item self.cycle_lbl.configure(text=f"Last cycle start: {hhmmss}") @@ -810,44 +849,36 @@ 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 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:"): + if not iid.startswith("var:"): messagebox.showinfo("Select variable", "Select a specific variable row (not a group).") return - - vi = self.vars.get(name) or VarInfo(name=name) + 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 — {name}") - dlg.geometry("900x650") + dlg.title(f"Thresholds, actions & alarms — {vi.display_name}") + dlg.geometry("980x680") dlg.transient(self); dlg.grab_set() t = vi.thresholds grid = ttk.Frame(dlg, padding=10) grid.pack(side=tk.TOP, fill=tk.BOTH, expand=True) - # numeric thresholds entries = {} - def add_row(r, label, key, default): + def add_row(r, label, keyname, default): ttk.Label(grid, text=label).grid(row=r, column=0, sticky="w", pady=4) sv = tk.StringVar(value="" if default is None else str(default)) e = ttk.Entry(grid, textvariable=sv, width=12) e.grid(row=r, column=1, sticky="w", padx=6) - entries[key] = sv + entries[keyname] = sv add_row(0, "DEAD LOW", "dead_low", t.dead_low) add_row(1, "LOW", "low", t.low) @@ -856,15 +887,14 @@ class App(tk.Tk): ttk.Label(grid, text="Alarm on enter:").grid(row=4, column=0, sticky="w", pady=(12,4)) alarm_vars = {} - for i, (lbl, key, val) in enumerate([("Dead Low", "alarm_dead_low", t.alarm_dead_low), - ("Low", "alarm_low", t.alarm_low), - ("High", "alarm_high", t.alarm_high), - ("Extreme High", "alarm_extreme_high", t.alarm_extreme_high)]): + for i, (lbl, keyname, val) in enumerate([("Dead Low", "alarm_dead_low", t.alarm_dead_low), + ("Low", "alarm_low", t.alarm_low), + ("High", "alarm_high", t.alarm_high), + ("Extreme High", "alarm_extreme_high", t.alarm_extreme_high)]): var = tk.BooleanVar(value=val) ttk.Checkbutton(grid, text=lbl, variable=var).grid(row=5+i//2, column=i%2+0, sticky="w", padx=6, pady=2) - alarm_vars[key] = var + alarm_vars[keyname] = var - # function actions ttk.Label(grid, text="On enter: call function (optional)").grid(row=7, column=0, sticky="w", pady=(12,4)) rows = [("Dead Low", "action_dead_low", "value_dead_low"), ("Low", "action_low", "value_low"), @@ -874,7 +904,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=42, state="readonly") + s = ttk.Combobox(grid, values=self.functions_list or DEFAULT_FUNCTIONS, width=44, 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 @@ -882,7 +912,6 @@ 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 (+ 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 = { @@ -898,17 +927,16 @@ class App(tk.Tk): "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")]): + for i, (label, keyname, 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) + ttk.Entry(grid, textvariable=expr_vars[keyname], width=64).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 = ttk.Combobox(grid, values=self.functions_list or DEFAULT_FUNCTIONS, width=32, 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()) @@ -946,7 +974,7 @@ class App(tk.Tk): expr_target_extreme_high=expr_target_vars["expr_target_extreme_high"].get().strip() or None, ) vi.thresholds = nt - self.vars[name] = vi + self.vars[vi.key] = vi dlg.destroy() btns = ttk.Frame(dlg, padding=10) @@ -954,42 +982,52 @@ 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) - def evaluate_thresholds(self, name: str, value: str) -> None: - vi = self.vars.get(name) - if not vi: - return - t = vi.thresholds + # --- State evaluation per spec + color gradients --- + def evaluate_thresholds(self, vi: VarInfo, value: str) -> None: x = parse_first_float(value) - if x is None: - new_state = "NORMAL" - else: - new_state = "NORMAL" - if t.dead_low is not None and x <= t.dead_low: - new_state = "DEAD_LOW" - elif t.low is not None and x <= t.low: - new_state = "LOW" - elif t.extreme_high is not None and x >= t.extreme_high: - new_state = "EXTREME_HIGH" - elif t.high is not None and x >= t.high: - new_state = "HIGH" - if new_state != vi.last_state: - self.on_enter_state(name, new_state, vi, x) - vi.last_state = new_state - - def on_enter_state(self, name: str, state: str, vi: VarInfo, x: Optional[float]) -> None: t = vi.thresholds - # Alarms + prev = vi.last_state + state = "UNKNOWN" + + if x is None or (t.low is None and t.high is None and t.dead_low is None and t.extreme_high is None): + state = "OPERATING" + else: + # Define bounds with fallbacks so comparisons work + dl = t.dead_low if t.dead_low is not None else -float("inf") + lo = t.low if t.low is not None else -float("inf") + hi = t.high if t.high is not None else float("inf") + ex = t.extreme_high if t.extreme_high 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_enter_state(vi, state, x) + vi.last_state = state + + def on_enter_state(self, vi: VarInfo, state: str, x: Optional[float]) -> None: + t = vi.thresholds + # Alarms default enabled for DEAD_LOW & EXTREME_HIGH 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": 300, "LOW": 550, "HIGH": 800, "EXTREME_HIGH": 1000}.get(state, 600) - dur = 350 if state != "EXTREME_HIGH" else 600 + 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() @@ -1011,13 +1049,12 @@ class App(tk.Tk): computed = eval_user_expression(expr, x) 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)}) + data = {POST_KEY_VARIABLE: target_func, POST_KEY_VALUE: str(computed)} + http_post(base, data) else: - # No target selected; inform briefly - self.status_lbl.configure(text=f"Expression computed for {name} [{state}], no target function set") + 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"{name}: failed to evaluate expression on enter {state}: {e}") - # fall through to function action + messagebox.showwarning("Expression error", f"{vi.display_name}: expression failed on enter {state}: {e}") # Function action act_map = { @@ -1028,10 +1065,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 incremental update --- + # --- Tree helpers & rendering --- def _ensure_group(self, group: str) -> str: iid = self._group_ids.get(group) if iid and self.tree.exists(iid): @@ -1042,24 +1078,81 @@ class App(tk.Tk): 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) + 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:{name}" - self._var_ids[name] = 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=name, values=("", "", ""), tags=("neutral",)) + 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 _state_color(self, vi: VarInfo, x: Optional[float]) -> str: + # Gradients based on distance to thresholds + t = vi.thresholds + # Defaults + if x is None: + return "#f5f7fb" + + # helper to clamp int 0..255 + def ci(v): return max(0, min(255, int(v))) + + # Compute colors: + # OPERATING: green ~ #E6F5E6 + # LOW zone: yellow from #FFF8CC near LOW to #FFE28A near DEAD_LOW + # DEAD_LOW: orange-ish #FFC266 + # HIGH zone: red from #FFD6D6 near HIGH to #FF9E9E near EXTREME_HIGH + # EXTREME_HIGH: deep red #FF6B6B + if t.low is not None and t.high is not None and t.dead_low is not None and t.extreme_high is not None: + if x < t.dead_low: + return "#FFC266" + elif x < t.low: + # interpolate between LOW and DEAD_LOW + span = max(1e-9, t.low - t.dead_low) + frac = 1.0 - (x - t.dead_low) / span # 0 near low, 1 near dead_low + # from #FFF8CC to #FFE28A + r = 255 + g = 248 - frac * (248 - 226) + b = 204 - frac * (204 - 138) + return f"#{ci(r):02X}{ci(g):02X}{ci(b):02X}" + elif x < t.high: + return "#E6F5E6" + elif x < t.extreme_high: + span = max(1e-9, t.extreme_high - t.high) + frac = (x - t.high) / span # 0 near high, 1 near extreme + # from #FFD6D6 to #FF9E9E + r = 255 + g = 214 - frac * (214 - 158) + b = 214 - frac * (214 - 158) + return f"#{ci(r):02X}{ci(g):02X}{ci(b):02X}" + else: + return "#FF6B6B" + # Fallback simple mapping + return "#E6F5E6" + def refresh_tree(self) -> None: filt = self.filter_var.get().strip().lower() - # Build groups from current vars + + # Build groups from current vars (case-insensitive) groups: Dict[str, List[VarInfo]] = {} - for name, vi in self.vars.items(): - if filt and filt not in name.lower() and (not vi.last_value or filt not in vi.last_value.lower()): + 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 = name.split("_", 1)[0] if "_" in name else "MISC" + prefix = text.split("_", 1)[0] if "_" in text else "MISC" groups.setdefault(prefix, []).append(vi) # Preserve current selection @@ -1069,12 +1162,13 @@ class App(tk.Tk): valid_var_iids = set() valid_group_iids = set() - for g in sorted(groups.keys()): + 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.name.lower()): - vid = self._ensure_var(gid, vi.name) + 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") @@ -1084,19 +1178,17 @@ class App(tk.Tk): else: value_preview = coerce_preview(vi.last_value, 120) - tag = "neutral" - if vi.error: - tag = "bad" - else: - tag = "ok" if value_preview not in ("",) else "neutral" + # compute color tag + x = parse_first_float(vi.last_value) + color = self._state_color(vi, x) + tag = self._get_color_tag(color) - suffix = "" if vi.last_state in {"UNKNOWN", "NORMAL"} else f" [{vi.last_state}]" - self.tree.item(vid, text=vi.name + suffix, values=(value_preview, updated_str, vi.last_status or "—"), tags=(tag,)) + self.tree.item(vid, text=vi.display_name, values=(value_preview, updated_str, vi.last_status or "—"), tags=(tag,)) # Remove stale items - for name, vid in list(self._var_ids.items()): + 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(name, None) + 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) @@ -1119,7 +1211,9 @@ class App(tk.Tk): # --- Utils --- def parse_first_float(value: str) -> Optional[float]: - m = re.search(r"[-+]?\d+(?:\.\d+)?", value.replace(",", ".")) + if value is None: + return None + m = re.search(r"[-+]?\d+(?:\.\d+)?", str(value).replace(",", ".")) if m: try: return float(m.group(0))