diff --git a/nucleares_monitor/control_board_monitor.py b/nucleares_monitor/control_board_monitor.py index 1a1dad7..a2a5b4c 100644 --- a/nucleares_monitor/control_board_monitor.py +++ b/nucleares_monitor/control_board_monitor.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- """ -Control Board Monitor — v1.2 +Control Board Monitor — v1.3 What’s new: - POSTs are now **exactly** like the sample app: @@ -36,7 +36,7 @@ SERVER_HOST: str = "localhost" SERVER_PORT: int = 8785 REFRESH_INTERVAL_S: int = 1 REQUEST_TIMEOUT_S: float = 5.0 -USER_AGENT: str = "ControlBoardMonitor/1.2 (+tkinter)" +USER_AGENT: str = "ControlBoardMonitor/1.3 (+tkinter)" # Fallback defaults (extracted from your HTML) DEFAULT_VARS: List[str] = ['ALARMS_ACTIVE', 'AMBIENT_TEMPERATURE', 'CHEM_BORON_DOSAGE_ACTUAL', 'CHEM_BORON_DOSAGE_ORDERED', 'CHEM_BORON_FILTER_ACTUAL', 'CHEM_BORON_FILTER_ORDERED', 'CHEM_BORON_PPM', 'CHEM_TRUCK_CONNECTED', 'CHEM_TRUCK_IN_ZONE', 'CHEMICAL_CLEANING_PUMP_DRY_STATUS', 'CHEMICAL_CLEANING_PUMP_OVERLOAD_STATUS', 'CHEMICAL_CLEANING_PUMP_STATUS', 'CHEMICAL_DOSING_PUMP_DRY_STATUS', 'CHEMICAL_DOSING_PUMP_OVERLOAD_STATUS', 'CHEMICAL_DOSING_PUMP_STATUS', 'CHEMICAL_FILTER_PUMP_DRY_STATUS', 'CHEMICAL_FILTER_PUMP_OVERLOAD_STATUS', 'CHEMICAL_FILTER_PUMP_STATUS', 'CONDENSER_CIRCULATION_PUMP_ACTIVE', 'CONDENSER_CIRCULATION_PUMP_ORDERED_SPEED', 'CONDENSER_CIRCULATION_PUMP_OVERLOAD_STATUS', 'CONDENSER_CIRCULATION_PUMP_SPEED', 'CONDENSER_CIRCULATION_PUMP_SWITCH', 'CONDENSER_CONDENSATE_FLOW_RATE', 'CONDENSER_COOLANT_EVAPORATED', 'CONDENSER_EXTRACTION_FLOW_RATE', 'CONDENSER_PRESSURE', 'CONDENSER_TEMPERATURE', 'CONDENSER_VACUUM', 'CONDENSER_VACUUM_PUMP_ACTIVE', 'CONDENSER_VACUUM_PUMP_MODE', 'CONDENSER_VACUUM_PUMP_POWER', 'CONDENSER_VACUUM_RELIEF_VALVE_OPENING', 'CONDENSER_VAPOR_VOLUME', 'CONDENSER_VOLUME', 'COOLANT_CORE_CIRCULATION_PUMP_0_CAPACITY', 'COOLANT_CORE_CIRCULATION_PUMP_0_DRY_STATUS', 'COOLANT_CORE_CIRCULATION_PUMP_0_ORDERED_SPEED', 'COOLANT_CORE_CIRCULATION_PUMP_0_OVERLOAD_STATUS', 'COOLANT_CORE_CIRCULATION_PUMP_0_SPEED', 'COOLANT_CORE_CIRCULATION_PUMP_0_STATUS', 'COOLANT_CORE_CIRCULATION_PUMP_1_CAPACITY', 'COOLANT_CORE_CIRCULATION_PUMP_1_DRY_STATUS', 'COOLANT_CORE_CIRCULATION_PUMP_1_ORDERED_SPEED', 'COOLANT_CORE_CIRCULATION_PUMP_1_OVERLOAD_STATUS', 'COOLANT_CORE_CIRCULATION_PUMP_1_SPEED', 'COOLANT_CORE_CIRCULATION_PUMP_1_STATUS', 'COOLANT_CORE_CIRCULATION_PUMP_2_CAPACITY', 'COOLANT_CORE_CIRCULATION_PUMP_2_DRY_STATUS', 'COOLANT_CORE_CIRCULATION_PUMP_2_ORDERED_SPEED', 'COOLANT_CORE_CIRCULATION_PUMP_2_OVERLOAD_STATUS', 'COOLANT_CORE_CIRCULATION_PUMP_2_SPEED', 'COOLANT_CORE_CIRCULATION_PUMP_2_STATUS', 'COOLANT_CORE_FLOW_IN', 'COOLANT_CORE_FLOW_ORDERED_SPEED', 'COOLANT_CORE_FLOW_OUT', 'COOLANT_CORE_FLOW_REACHED_SPEED', 'COOLANT_CORE_FLOW_SPEED', 'COOLANT_CORE_MAX_PRESSURE', 'COOLANT_CORE_PRESSURE', 'COOLANT_CORE_PRIMARY_LOOP_LEVEL', 'COOLANT_CORE_QUANTITY_CIRCULATION_PUMPS_PRESENT', 'COOLANT_CORE_QUANTITY_FREIGHT_PUMPS_PRESENT', 'COOLANT_CORE_QUANTITY_IN_VESSEL', 'COOLANT_CORE_STATE', 'COOLANT_CORE_VESSEL_TEMPERATURE', 'COOLANT_SEC_0_LIQUID_VOLUME', 'COOLANT_SEC_0_PRESSURE', 'COOLANT_SEC_0_TEMPERATURE', 'COOLANT_SEC_0_VOLUME', 'COOLANT_SEC_1_LIQUID_VOLUME', 'COOLANT_SEC_1_PRESSURE', 'COOLANT_SEC_1_TEMPERATURE', 'COOLANT_SEC_1_VOLUME', 'COOLANT_SEC_2_LIQUID_VOLUME', 'COOLANT_SEC_2_PRESSURE', 'COOLANT_SEC_2_TEMPERATURE', 'COOLANT_SEC_2_VOLUME', 'COOLANT_SEC_CIRCULATION_PUMP_0_CAPACITY', 'COOLANT_SEC_CIRCULATION_PUMP_0_DRY_STATUS', 'COOLANT_SEC_CIRCULATION_PUMP_0_ORDERED_SPEED', 'COOLANT_SEC_CIRCULATION_PUMP_0_OVERLOAD_STATUS', 'COOLANT_SEC_CIRCULATION_PUMP_0_SPEED', 'COOLANT_SEC_CIRCULATION_PUMP_0_STATUS', 'COOLANT_SEC_CIRCULATION_PUMP_1_CAPACITY', 'COOLANT_SEC_CIRCULATION_PUMP_1_DRY_STATUS', 'COOLANT_SEC_CIRCULATION_PUMP_1_ORDERED_SPEED', 'COOLANT_SEC_CIRCULATION_PUMP_1_OVERLOAD_STATUS', 'COOLANT_SEC_CIRCULATION_PUMP_1_SPEED', 'COOLANT_SEC_CIRCULATION_PUMP_1_STATUS', 'COOLANT_SEC_CIRCULATION_PUMP_2_CAPACITY', 'COOLANT_SEC_CIRCULATION_PUMP_2_DRY_STATUS', 'COOLANT_SEC_CIRCULATION_PUMP_2_ORDERED_SPEED', 'COOLANT_SEC_CIRCULATION_PUMP_2_OVERLOAD_STATUS', 'COOLANT_SEC_CIRCULATION_PUMP_2_SPEED', 'COOLANT_SEC_CIRCULATION_PUMP_2_STATUS', 'CORE_BAY_1_HATCH_OPEN', 'CORE_BAY_1_STATE', 'CORE_BAY_2_HATCH_OPEN', 'CORE_BAY_2_STATE', 'CORE_BAY_3_HATCH_OPEN', 'CORE_BAY_3_STATE', 'CORE_BAY_4_HATCH_OPEN', 'CORE_BAY_4_STATE', 'CORE_BAY_5_HATCH_OPEN', 'CORE_BAY_5_STATE', 'CORE_BAY_6_HATCH_OPEN', 'CORE_BAY_6_STATE', 'CORE_BAY_7_HATCH_OPEN', 'CORE_BAY_7_STATE', 'CORE_BAY_8_HATCH_OPEN', 'CORE_BAY_8_STATE', 'CORE_BAY_9_HATCH_OPEN', 'CORE_BAY_9_STATE', 'CORE_CRITICAL_MASS_REACHED', 'CORE_CRITICAL_MASS_REACHED_COUNTER', 'CORE_EXTERNAL_COOLANT_RESERVOIR_VOLUME', 'CORE_FACTOR', 'CORE_FACTOR_CHANGE', 'CORE_FUEL_1_FISSIONABLE', 'CORE_FUEL_1_POWER_FACTOR', 'CORE_FUEL_1_TEMPERATURE', 'CORE_FUEL_2_FISSIONABLE', 'CORE_FUEL_2_POWER_FACTOR', 'CORE_FUEL_2_TEMPERATURE', 'CORE_FUEL_3_FISSIONABLE', 'CORE_FUEL_3_POWER_FACTOR', 'CORE_FUEL_3_TEMPERATURE', 'CORE_FUEL_4_FISSIONABLE', 'CORE_FUEL_4_POWER_FACTOR', 'CORE_FUEL_4_TEMPERATURE', 'CORE_FUEL_5_FISSIONABLE', 'CORE_FUEL_5_POWER_FACTOR', 'CORE_FUEL_5_TEMPERATURE', 'CORE_FUEL_6_FISSIONABLE', 'CORE_FUEL_6_POWER_FACTOR', 'CORE_FUEL_6_TEMPERATURE', 'CORE_FUEL_7_FISSIONABLE', 'CORE_FUEL_7_POWER_FACTOR', 'CORE_FUEL_7_TEMPERATURE', 'CORE_FUEL_8_FISSIONABLE', 'CORE_FUEL_8_POWER_FACTOR', 'CORE_FUEL_8_TEMPERATURE', 'CORE_FUEL_9_FISSIONABLE', 'CORE_FUEL_9_POWER_FACTOR', 'CORE_FUEL_9_TEMPERATURE', 'CORE_FUEL_AVG_FISSIONABLE', 'CORE_FUEL_AVG_POWER_FACTOR', 'CORE_FUEL_AVG_TEMPERATURE', 'CORE_HIGH_STEAM_PRESENT', 'CORE_IMMINENT_FUSION', 'CORE_INTEGRITY', 'CORE_IODINE_CUMULATIVE', 'CORE_IODINE_GENERATION', 'CORE_OPERATION_MODE', 'CORE_POOL_COOLANT_TANK_VOLUME', 'CORE_POOL_PUMP', 'CORE_PRESSURE', 'CORE_PRESSURE_MAX', 'CORE_PRESSURE_OPERATIVE', 'CORE_PRIMARY_CIRCUIT_COOLING_TANK_VOLUME', 'CORE_READY_FOR_START', 'CORE_STATE', 'CORE_STATE_CRITICALITY', 'CORE_STEAM_PRESENT', 'CORE_TEMP', 'CORE_TEMP_MAX', 'CORE_TEMP_MIN', 'CORE_TEMP_OPERATIVE', 'CORE_TEMP_RESIDUAL', 'CORE_WEAR', 'CORE_XENON_CUMULATIVE', 'CORE_XENON_GENERATION', 'EMERGENCY_BATTERIES_MODE', 'EMERGENCY_BATTERIES_POWER_OUTPUT_KW', 'EMERGENCY_GENERATOR_1_FUEL', 'EMERGENCY_GENERATOR_1_MAINTENANCE_NEEDED', 'EMERGENCY_GENERATOR_1_MODE', 'EMERGENCY_GENERATOR_1_PRESSURIZER', 'EMERGENCY_GENERATOR_1_STATUS', 'EMERGENCY_GENERATOR_2_FUEL', 'EMERGENCY_GENERATOR_2_MAINTENANCE_NEEDED', 'EMERGENCY_GENERATOR_2_MODE', 'EMERGENCY_GENERATOR_2_PRESSURIZER', 'EMERGENCY_GENERATOR_2_STATUS', 'EMERGENCY_GENERATOR_POWER_OUTPUT_KW', 'FREIGHT_PUMP_CONDENSER_ACTIVE', 'FREIGHT_PUMP_CONDENSER_SWITCH', 'FREIGHT_PUMP_EXTERNAL_ACTIVE', 'FREIGHT_PUMP_EXTERNAL_SWITCH', 'FREIGHT_PUMP_FEEDWATER_ACTIVE', 'FREIGHT_PUMP_FEEDWATER_SWITCH', 'FREIGHT_PUMP_INTERNAL_ACTIVE', 'FREIGHT_PUMP_INTERNAL_SWITCH', 'FUN_IS_ENABLED', 'GAME_DIFFICULTY', 'GAME_SIM_SPEED', 'GAME_VERSION', 'GENERATOR_0_A', 'GENERATOR_0_BREAKER', 'GENERATOR_0_HERTZ', 'GENERATOR_0_KW', 'GENERATOR_0_V', 'GENERATOR_1_A', 'GENERATOR_1_BREAKER', 'GENERATOR_1_HERTZ', 'GENERATOR_1_KW', 'GENERATOR_1_V', 'GENERATOR_2_A', 'GENERATOR_2_BREAKER', 'GENERATOR_2_HERTZ', 'GENERATOR_2_KW', 'GENERATOR_2_V', 'INSTALLED_LOOPS_JSON', 'INVENTORY_HTML', 'MAINTENANCE_REPORT_HTML', 'MSCV_0_OPENING_ACTUAL', 'MSCV_1_OPENING_ACTUAL', 'MSCV_2_OPENING_ACTUAL', 'POWER_DEMAND_MW', 'POWER_FROM_EXTERNAL_KW', 'POWER_FROM_TURBINE_KW', 'POWER_MAX_THEORETICAL_FINAL_PLANT_OUTPUT_MW', 'POWER_MAX_THEORETICAL_PLANT_OUTPUT_MW', 'RES_ABSORPTION_CAPACITY_MW', 'RES_DIVERT_SURPLUS_FROM_MW', 'RES_EFFECTIVELY_DERIVED_ENERGY_MW', 'RESISTOR_BANK_01_SWITCH', 'RESISTOR_BANK_02_SWITCH', 'RESISTOR_BANK_03_SWITCH', 'RESISTOR_BANK_04_SWITCH', 'RESISTOR_BANKS_JSON', 'RESISTOR_BANKS_MAIN_SWITCH', 'ROD_BANK_POS_0_ACTUAL', 'ROD_BANK_POS_0_ORDERED', 'ROD_BANK_POS_1_ACTUAL', 'ROD_BANK_POS_1_ORDERED', 'ROD_BANK_POS_2_ACTUAL', 'ROD_BANK_POS_2_ORDERED', 'ROD_BANK_POS_3_ACTUAL', 'ROD_BANK_POS_3_ORDERED', 'ROD_BANK_POS_4_ACTUAL', 'ROD_BANK_POS_4_ORDERED', 'ROD_BANK_POS_5_ACTUAL', 'ROD_BANK_POS_5_ORDERED', 'ROD_BANK_POS_6_ACTUAL', 'ROD_BANK_POS_6_ORDERED', 'ROD_BANK_POS_7_ACTUAL', 'ROD_BANK_POS_7_ORDERED', 'ROD_BANK_POS_8_ACTUAL', 'ROD_BANK_POS_8_ORDERED', 'RODS_ALIGNED', 'RODS_DEFORMED', 'RODS_MAX_TEMPERATURE', 'RODS_MOVEMENT_SPEED', 'RODS_MOVEMENT_SPEED_DECREASED_HIGH_TEMPERATURE', 'RODS_POS_ACTUAL', 'RODS_POS_ORDERED', 'RODS_POS_REACHED', 'RODS_QUANTITY', 'RODS_STATUS', 'RODS_TEMPERATURE', 'STEAM_EJECTOR_CONDENSER_RETURN_VALVE_ACTUAL', 'STEAM_EJECTOR_CONDENSER_RETURN_VALVE_ORDERED', 'STEAM_EJECTOR_MOTIVE', 'STEAM_EJECTOR_OPERATIONAL_MOTIVE_VALVE_ACTUAL', 'STEAM_EJECTOR_OPERATIONAL_MOTIVE_VALVE_ORDERED', 'STEAM_EJECTOR_STARTUP_MOTIVE_VALVE_ACTUAL', 'STEAM_EJECTOR_STARTUP_MOTIVE_VALVE_ORDERED', 'STEAM_GEN_0_BOILING_POINT', 'STEAM_GEN_0_EVAPORATED', 'STEAM_GEN_0_INLET', 'STEAM_GEN_0_OUTLET', 'STEAM_GEN_0_RETURN_FLOW_PLUS_CONDENSED', 'STEAM_GEN_0_STATUS', 'STEAM_GEN_0_VENT_SWITCH', 'STEAM_GEN_1_BOILING_POINT', 'STEAM_GEN_1_EVAPORATED', 'STEAM_GEN_1_INLET', 'STEAM_GEN_1_OUTLET', 'STEAM_GEN_1_RETURN_FLOW_PLUS_CONDENSED', 'STEAM_GEN_1_STATUS', 'STEAM_GEN_1_VENT_SWITCH', 'STEAM_GEN_2_BOILING_POINT', 'STEAM_GEN_2_EVAPORATED', 'STEAM_GEN_2_INLET', 'STEAM_GEN_2_OUTLET', 'STEAM_GEN_2_RETURN_FLOW_PLUS_CONDENSED', 'STEAM_GEN_2_STATUS', 'STEAM_GEN_2_VENT_SWITCH', 'STEAM_TURBINE_0_BYPASS_ACTUAL', 'STEAM_TURBINE_0_INSTALLED', 'STEAM_TURBINE_0_PRESSURE', 'STEAM_TURBINE_0_RPM', 'STEAM_TURBINE_0_TEMPERATURE', 'STEAM_TURBINE_0_TORQUE', 'STEAM_TURBINE_1_BYPASS_ACTUAL', 'STEAM_TURBINE_1_INSTALLED', 'STEAM_TURBINE_1_PRESSURE', 'STEAM_TURBINE_1_RPM', 'STEAM_TURBINE_1_TEMPERATURE', 'STEAM_TURBINE_1_TORQUE', 'STEAM_TURBINE_2_BYPASS_ACTUAL', 'STEAM_TURBINE_2_INSTALLED', 'STEAM_TURBINE_2_PRESSURE', 'STEAM_TURBINE_2_RPM', 'STEAM_TURBINE_2_TEMPERATURE', 'STEAM_TURBINE_2_TORQUE', 'TIME', 'TIME_DAY', 'TIME_STAMP', 'VACUUM_RETENTION_TANK_PRESSURE', 'VACUUM_RETENTION_TANK_VOLUME', 'VALVE_M01_OPEN', 'VALVE_M02_OPEN', 'VALVE_M03_OPEN', 'VALVE_PANEL_JSON', 'WEATHER_FORECAST_JSON', 'WEBSERVER_BATCH_GET', 'WEBSERVER_LIST_VARIABLES', 'WEBSERVER_LIST_VARIABLES_JSON', 'WEBSERVER_VIEW_VARIABLES'] @@ -229,6 +229,9 @@ class Thresholds: expr_target_low: Optional[str] = None expr_target_high: Optional[str] = None expr_target_extreme_high: Optional[str] = None + # sources for y and z + y_source: Optional[str] = None + z_source: Optional[str] = None @dataclass class VarInfo: @@ -394,7 +397,7 @@ class Poller(threading.Thread): class App(tk.Tk): def __init__(self) -> None: super().__init__() - self.title("Control Board Monitor — v1.2") + self.title("Control Board Monitor — v1.3") self.geometry("1280x860") # Canonical registry: lower-case key -> VarInfo @@ -478,8 +481,9 @@ 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=44, state="readonly") + self.func_combo = ttk.Combobox(act_controls, textvariable=self.func_name_var, width=44) self.func_combo.pack(side=tk.LEFT, padx=6) + self._attach_search_filter_to_combobox(self.func_combo, self.functions_list) ttk.Label(act_controls, text="Value:").pack(side=tk.LEFT) self.func_value_var = tk.StringVar(value="1") ttk.Entry(act_controls, textvariable=self.func_value_var, width=10).pack(side=tk.LEFT, padx=6) @@ -523,6 +527,7 @@ class App(tk.Tk): funmenu.add_separator() funmenu.add_command(label="Reload Discovery", command=self.reload_discovery) funmenu.add_command(label="Select Variables…", command=self.open_selector) + menubar.add_cascade(label="Function", menu=funmenu) self.config(menu=menubar) @@ -541,6 +546,7 @@ class App(tk.Tk): self.state_tasks: Dict[tuple, int] = {} # Latest values from batch (lowercased keys) self.latest_values: Dict[str, str] = {} + self.known_variables: List[str] = [] # Trigger discovery shortly after startup so function list populates self.after(200, self.reload_discovery) @@ -611,6 +617,8 @@ class App(tk.Tk): self.func_combo["values"] = self.functions_list if self.functions_list and not self.func_name_var.get(): self.func_name_var.set(self.functions_list[0]) + # Remember known variables for y/z source selectors + self.known_variables = list(discovered_vars) self.show_selector_dialog(discovered_vars) # Status @@ -877,6 +885,11 @@ class App(tk.Tk): _, mapping = item if isinstance(mapping, dict): self.latest_values.update(mapping) + # refresh known_variables list from batch keys (preserve case with existing where possible) + keys = list(mapping.keys()) + # prefer existing display names from self.vars, else use given keys + disp = [self.vars.get(k.lower()).display_name if self.vars.get(k.lower()) else k for k in keys] + self.known_variables = sorted(set(list(self.known_variables) + disp), key=str.lower) elif kind == "stopped": self.status_lbl.configure(text="Stopped") self.ui_queue.task_done() @@ -967,9 +980,10 @@ class App(tk.Tk): ttk.Label(grid, text="Interval(s) while in state (0 = once)").grid(row=7, column=3, sticky="w") for i, (label, action_key, value_key, interval_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=44, state="readonly") + s = ttk.Combobox(grid, values=self.functions_list or DEFAULT_FUNCTIONS, width=44) s.set(getattr(t, action_key) or "") s.grid(row=8+i, column=1, sticky="w", padx=6) + self._attach_search_filter_to_combobox(s, self.functions_list) action_vars[action_key] = s sv = tk.StringVar(value=getattr(t, value_key)) ttk.Entry(grid, textvariable=sv, width=12).grid(row=8+i, column=2, sticky="w", padx=6) @@ -978,7 +992,19 @@ class App(tk.Tk): ttk.Entry(grid, textvariable=iv, width=10).grid(row=8+i, column=3, sticky="w", padx=6) interval_vars[interval_key] = iv - 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) + # y/z source selectors (optional) + ttk.Label(grid, text="y source (optional)").grid(row=12, column=0, sticky="w", pady=(14,4)) + y_source_var = tk.StringVar(value=t.y_source or "") + y_combo = ttk.Combobox(grid, values=(self.known_variables or [vi.display_name]), textvariable=y_source_var, width=44) + y_combo.grid(row=12, column=1, sticky="w", padx=6) + self._attach_search_filter_to_combobox(y_combo, self.known_variables) + ttk.Label(grid, text="z source (optional)").grid(row=12, column=2, sticky="e") + z_source_var = tk.StringVar(value=t.z_source or "") + z_combo = ttk.Combobox(grid, values=(self.known_variables or [vi.display_name]), textvariable=z_source_var, width=32) + z_combo.grid(row=12, column=3, sticky="w", padx=6) + self._attach_search_filter_to_combobox(z_combo, self.known_variables) + + ttk.Label(grid, text="On enter: evaluate expression/snippet (x = current value) and optionally POST to a function").grid(row=13, column=0, sticky="w", pady=(14,4), columnspan=3) expr_vars = { "expr_dead_low": tk.StringVar(value=t.expr_dead_low or ""), @@ -997,12 +1023,13 @@ class App(tk.Tk): ("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[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=32, state="readonly") + ttk.Label(grid, text=label).grid(row=14+i, column=0, sticky="w", pady=2) + ttk.Entry(grid, textvariable=expr_vars[keyname], width=64).grid(row=14+i, column=1, sticky="w", padx=6) + ttk.Label(grid, text="→ POST to function").grid(row=14+i, column=2, sticky="e") + s = ttk.Combobox(grid, values=self.functions_list or DEFAULT_FUNCTIONS, width=32) s.set(expr_target_vars[tkey].get()) - s.grid(row=13+i, column=3, sticky="w", padx=6) + s.grid(row=14+i, column=3, sticky="w", padx=6) + self._attach_search_filter_to_combobox(s, self.functions_list) def _on_combo_select(event, varkey=tkey, combo=s): expr_target_vars[varkey].set(combo.get()) s.bind("<>", _on_combo_select) @@ -1040,6 +1067,8 @@ class App(tk.Tk): 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, + y_source=(y_source_var.get().strip() or None), + z_source=(z_source_var.get().strip() or None), ) vi.thresholds = nt self.vars[vi.key] = vi @@ -1145,29 +1174,32 @@ class App(tk.Tk): expr = getattr(t, expr_key) if expr_key else None target_func = getattr(t, target_key) if target_key else None - # Determine y (current value of the variable we intend to change) - y_val = None - y_source = None - if target_func: - y_source = target_func.lower() - else: - # fallback to action_* target name if set - action_map = {"DEAD_LOW": t.action_dead_low, "LOW": t.action_low, "HIGH": t.action_high, "EXTREME_HIGH": t.action_extreme_high} - fallback = action_map.get(state) - if fallback: y_source = fallback.lower() - if y_source: - # try latest batch first - if hasattr(self, "latest_values") and y_source in self.latest_values: - y_val = parse_first_float(self.latest_values.get(y_source)) + # Determine y/z current values (prefer explicit sources) + y_val = None; z_val = None + y_source = (t.y_source or '').strip() or None + z_source = (t.z_source or '').strip() or None + if not y_source: + if target_func: + y_source = target_func + else: + action_map = {"DEAD_LOW": t.action_dead_low, "LOW": t.action_low, "HIGH": t.action_high, "EXTREME_HIGH": t.action_extreme_high} + y_source = action_map.get(state) + for label, src_name in (('y', y_source), ('z', z_source)): + if not src_name: continue + key = src_name.lower() + val = None + if hasattr(self, 'latest_values') and key in self.latest_values: + val = parse_first_float(self.latest_values.get(key)) else: - # try direct GET base = build_base_url(self.host_var.get().strip(), int(self.port_var.get())) - st, bd, _h = http_get(base, {"variable": y_source}) + st, bd, _h = http_get(base, {"variable": key}) if st == 200: - y_val = parse_first_float(bd) + val = parse_first_float(bd) + if label == 'y': y_val = val + else: z_val = val if expr and x is not None: try: - computed = eval_user_expression(expr, x, y_val) + computed = eval_user_expression(expr, x, y_val, z_val) if target_func: base = build_base_url(self.host_var.get().strip(), int(self.port_var.get())) params = {"variable": target_func, "value": str(computed)} @@ -1320,6 +1352,17 @@ class App(tk.Tk): if sel_iid and self.tree.exists(sel_iid): self.tree.selection_set(sel_iid) + + def _attach_search_filter_to_combobox(self, combo: ttk.Combobox, source: List[str] | None): + if source is None: + source = [] + original = list(source) + combo.configure(state="normal") # allow typing to filter + def on_key(_event=None): + text = combo.get() + vals = [v for v in original if text.lower() in v.lower()] + combo["values"] = vals if vals else original + combo.bind("", on_key) def _bind_mousewheel(self, widget): def _mw(event): if event.delta: @@ -1353,7 +1396,8 @@ class App(tk.Tk): top = tk.Toplevel(self); top.title("Run Function Once"); top.geometry("420x180"); top.transient(self); top.grab_set() ttk.Label(top, text="Function:").pack(pady=(10,2)) name = tk.StringVar(value=(self.functions_list[0] if self.functions_list else "")) - cmb = ttk.Combobox(top, values=self.functions_list, textvariable=name, width=44, state="readonly") + cmb = ttk.Combobox(top, values=self.functions_list, textvariable=name, width=44) + self._attach_search_filter_to_combobox(cmb, self.functions_list) cmb.pack() ttk.Label(top, text="Value:").pack(pady=(6,2)) val = tk.StringVar(value="1") @@ -1370,7 +1414,8 @@ class App(tk.Tk): top = tk.Toplevel(self); top.title("Add Scheduled Function"); top.geometry("420x220"); top.transient(self); top.grab_set() ttk.Label(top, text="Function:").pack(pady=(10,2)) name = tk.StringVar(value=(self.functions_list[0] if self.functions_list else "")) - cmb = ttk.Combobox(top, values=self.functions_list, textvariable=name, width=44, state="readonly") + cmb = ttk.Combobox(top, values=self.functions_list, textvariable=name, width=44) + self._attach_search_filter_to_combobox(cmb, self.functions_list) cmb.pack() ttk.Label(top, text="Value:").pack(pady=(6,2)) val = tk.StringVar(value="1") @@ -1497,7 +1542,7 @@ def parse_first_float(value: str) -> Optional[float]: return None return None -def eval_user_expression(expr: str, x: float, y: float | None = None): +def eval_user_expression(expr: str, x: float, y: float | None = None, z: float | None = None): """Evaluate user expression/snippet with x available. Allowed: math.*, min, max, abs, round, int, float, clamp (custom). Returns the computed result. @@ -1505,7 +1550,7 @@ def eval_user_expression(expr: str, x: float, y: float | None = None): def clamp(v, lo, hi): return max(lo, min(hi, v)) safe_globals = {"__builtins__": {}, "math": math, "min": min, "max": max, "abs": abs, "round": round, "int": int, "float": float, "clamp": clamp} - safe_locals = {"x": x, "y": y} + safe_locals = {"x": x, "y": y, "z": z} if "\n" in expr or ";" in expr: code = compile(expr, "", "exec") exec(code, safe_globals, safe_locals)