diff --git a/nucleares_monitor/control_board_monitor.py b/nucleares_monitor/control_board_monitor.py index 4c19d9b..a066af6 100644 --- a/nucleares_monitor/control_board_monitor.py +++ b/nucleares_monitor/control_board_monitor.py @@ -1,21 +1,14 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- """ -Control Board Monitor — v1.0 - -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). +Control Board Monitor — v1.1 +What’s new: +- POSTs are now **exactly** like the sample app: + POST http://:/?variable=&value= (method=POST, no body) + (All previous POST modes removed.) +- Select Variables dialog: typing in the filter **does not clear** previously ticked checkboxes. +- Mouse wheel scrolling enabled (selector canvas, main variables tree, actions tree). """ import json @@ -43,18 +36,7 @@ SERVER_HOST: str = "localhost" SERVER_PORT: int = 8785 REFRESH_INTERVAL_S: int = 60 REQUEST_TIMEOUT_S: float = 5.0 -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 +USER_AGENT: str = "ControlBoardMonitor/1.1 (+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'] @@ -67,7 +49,6 @@ _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]]: @@ -96,10 +77,10 @@ def http_get(base_url: str, params: Dict[str, str]) -> Tuple[int, str, Dict[str, 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 # 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"}) +def http_post_query(base_url: str, params: Dict[str, str]) -> Tuple[int, str, Dict[str, str]]: + # EXACT behavior requested: POST with query string, no body. + url = base_url + "?" + urllib.parse.urlencode(params) + req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT, "Connection": "close"}, method="POST") return _request(req) def http_get_root(base_url: str) -> Tuple[int, str, Dict[str, str]]: @@ -233,6 +214,11 @@ class Thresholds: value_high: str = "1" action_extreme_high: Optional[str] = None value_extreme_high: str = "1" + # interval while-in-state (0 = once on enter) + action_dead_low_interval: int = 0 + action_low_interval: int = 0 + action_high_interval: int = 0 + action_extreme_high_interval: int = 0 # expression actions expr_dead_low: Optional[str] = None expr_low: Optional[str] = None @@ -246,9 +232,8 @@ class Thresholds: @dataclass class VarInfo: - # Canonical key is lower-case; display_name retains original case from discovery/selection - key: str - display_name: str + key: str # canonical lower-case key + display_name: str # original case as chosen last_value: str = "" last_updated: float = 0.0 last_status: int = 0 @@ -306,9 +291,8 @@ class ActionScheduler(threading.Thread): def run_once(self, name: str, value: str) -> Tuple[int, str, Dict[str, str]]: base = self.get_base_url_cb() - # Sample-app compatible body - data = {POST_KEY_VARIABLE: name, POST_KEY_VALUE: value} - return http_post(base, data) + params = {"variable": name, "value": value} + return http_post_query(base, params) def run(self) -> None: while not self._stop.is_set(): @@ -369,7 +353,6 @@ class Poller(threading.Thread): if status == 200: values_map = parse_batch_values(body) if values_map: - # 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: @@ -377,7 +360,6 @@ class Poller(threading.Thread): else: self.ui_queue.put(("error", key, 206, "Not in BATCH_GET payload")) else: - # 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: @@ -385,7 +367,6 @@ class Poller(threading.Thread): else: self.ui_queue.put(("error", key, st, coerce_preview(b, 200))) else: - # fallback to per-variable for key in list(self.variables_keys): st, b, _h = http_get(base_url, {"variable": key}) if st == 200: @@ -393,7 +374,6 @@ class Poller(threading.Thread): else: self.ui_queue.put(("error", key, st, coerce_preview(b, 200))) - # sleep until next cycle, unless paused/stopped cycle_dt = time.time() - cycle_start remaining = self.refresh_interval - cycle_dt if remaining > 0: @@ -412,7 +392,7 @@ class Poller(threading.Thread): class App(tk.Tk): def __init__(self) -> None: super().__init__() - self.title("Control Board Monitor — v1.0") + self.title("Control Board Monitor — v1.1") self.geometry("1280x860") # Canonical registry: lower-case key -> VarInfo @@ -466,7 +446,11 @@ class App(tk.Tk): 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=20, selectmode="browse") - self.tree.pack(side=tk.TOP, fill=tk.BOTH, expand=True) + self.tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + vsb = ttk.Scrollbar(top_frame, orient="vertical", command=self.tree.yview) + vsb.pack(side=tk.RIGHT, fill=tk.Y) + self.tree.configure(yscrollcommand=vsb.set) + self._bind_mousewheel(self.tree) self.tree.heading("#0", text="Variable") self.tree.heading("value", text="Value") @@ -506,14 +490,11 @@ class App(tk.Tk): self.actions_tree.heading(col, text=hdr) self.actions_tree.column(col, width=w, anchor="center") - self.actions_tree.pack(side=tk.TOP, fill=tk.BOTH, expand=True) - act_controls.pack(side=tk.TOP, fill=tk.X) - - self.actions_menu = tk.Menu(self, tearoff=False) - self.actions_menu.add_command(label="Toggle Enable/Disable", command=self.toggle_selected_task) - self.actions_menu.add_command(label="Run Now", command=self.run_selected_task_now) - self.actions_menu.add_command(label="Remove", command=self.remove_selected_task) - self.actions_tree.bind("", self.on_actions_right_click) + self.actions_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + vsb2 = ttk.Scrollbar(bottom, orient="vertical", command=self.actions_tree.yview) + vsb2.pack(side=tk.RIGHT, fill=tk.Y) + self.actions_tree.configure(yscrollcommand=vsb2.set) + self._bind_mousewheel(self.actions_tree) main.add(top_frame, weight=3) main.add(bottom, weight=2) @@ -527,6 +508,8 @@ class App(tk.Tk): self.functions_list = list(DEFAULT_FUNCTIONS) self.func_combo["values"] = self.functions_list + if self.functions_list and not self.func_name_var.get(): + self.func_name_var.set(self.functions_list[0]) # Stable IDs self._group_ids: Dict[str, str] = {} @@ -534,6 +517,18 @@ class App(tk.Tk): # Color tag cache: color_hex -> tag_name self._color_tags: Dict[str, str] = {} + # Track repeating actions per (var_key,state) + self.state_tasks: Dict[tuple, int] = {} + + # Trigger discovery shortly after startup so function list populates + self.after(200, self.reload_discovery) + + # Actions context menu + self.actions_menu = tk.Menu(self, tearoff=False) + self.actions_menu.add_command(label="Toggle Enable/Disable", command=self.toggle_selected_task) + self.actions_menu.add_command(label="Run Now", command=self.run_selected_task_now) + self.actions_menu.add_command(label="Remove", command=self.remove_selected_task) + self.actions_tree.bind("", self.on_actions_right_click) # --- Discovery --- def reload_discovery(self) -> None: @@ -559,7 +554,6 @@ class App(tk.Tk): if s2 == 200 and b2: values_map = parse_batch_values(b2) if values_map: - # Dedup preserving first seen, case-insensitive seen=set() for k in values_map.keys(): kl = k.lower() @@ -589,6 +583,8 @@ class App(tk.Tk): # Apply self.functions_list = discovered_funcs self.func_combo["values"] = self.functions_list + if self.functions_list and not self.func_name_var.get(): + self.func_name_var.set(self.functions_list[0]) self.show_selector_dialog(discovered_vars) # Status @@ -603,16 +599,10 @@ class App(tk.Tk): # --- Select Vars dialog --- def open_selector(self) -> None: - # 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.update(DEFAULT_VARS) + union = set([vi.display_name for vi in self.vars.values()]) or set(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() @@ -625,6 +615,8 @@ class App(tk.Tk): dlg.transient(self) dlg.grab_set() + temp_selected = {k: True for k in self.variables_keys} + top = ttk.Frame(dlg, padding=8) top.pack(side=tk.TOP, fill=tk.X) ttk.Label(top, text="Filter:").pack(side=tk.LEFT) @@ -644,8 +636,22 @@ 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_keys) # already lower-case - rows: List[Tuple[str, str, tk.BooleanVar]] = [] # (display, key, var) + def _mw(event): + if event.delta: + canvas.yview_scroll(int(-1*(event.delta/120)), "units") + return "break" + def _mw_up(_e): + canvas.yview_scroll(-1, "units"); return "break" + def _mw_down(_e): + canvas.yview_scroll(1, "units"); return "break" + canvas.bind("", _mw) + inner.bind("", _mw) + canvas.bind("", _mw_up) + canvas.bind("", _mw_down) + inner.bind("", _mw_up) + inner.bind("", _mw_down) + + rows: List[Tuple[str, str, tk.BooleanVar]] = [] def rebuild(): for child in inner.winfo_children(): @@ -656,38 +662,49 @@ class App(tk.Tk): if f and f not in name.lower(): continue key = name.lower() - var = tk.BooleanVar(value=(key in current_set)) + var = tk.BooleanVar(value=temp_selected.get(key, False)) + def bind_trace(k=key, v=var): + def _(*_a): + temp_selected[k] = v.get() + v.trace_add("write", _) + bind_trace() cb = ttk.Checkbutton(inner, text=name, variable=var) cb.pack(anchor="w", pady=2, padx=4) rows.append((name, key, var)) rebuild() + filt_var.trace_add("write", lambda *_: rebuild()) - def 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]) + def select_all(): + for _, key, var in rows: + var.set(True); temp_selected[key] = True + def clear_all(): + for _, key, var in rows: + var.set(False); temp_selected[key] = False + sel_all.configure(command=select_all) + sel_none.configure(command=clear_all) bot = ttk.Frame(dlg, padding=8) bot.pack(side=tk.BOTTOM, fill=tk.X) def apply_and_close(): - selected_keys = [key for _, key, var in rows if var.get()] - # preserve order: existing first, then new in displayed order + selected_keys = [k for k, sel in temp_selected.items() if sel] new_order = [k for k in self.variables_keys if k in selected_keys] - for _, key, var in rows: - if var.get() and key not in new_order: + for name in disp: + key = name.lower() + if temp_selected.get(key, False) and key not in new_order: new_order.append(key) self.variables_keys = new_order - # 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 + # If poller is running, update its live list + if self.poller and self.poller.is_alive(): + self.poller.variables_keys = self.variables_keys[:] self.refresh_tree() dlg.destroy() ttk.Button(bot, text="Apply", command=apply_and_close).pack(side=tk.RIGHT, padx=4) @@ -709,7 +726,7 @@ class App(tk.Tk): return val = self.func_value_var.get() status, body, _ = self.scheduler.run_once(fname, val) - messagebox.showinfo("Run Once", f"POST: {fname}={val}\nHTTP {status}\n{coerce_preview(body, 300)}") + messagebox.showinfo("Run Once", f"POST ?variable={fname}&value={val}\nHTTP {status}\n{coerce_preview(body, 300)}") def add_schedule_dialog(self): fname = self.func_name_var.get().strip() @@ -750,7 +767,7 @@ class App(tk.Tk): t = tasks.get(tid) if not t: return status, body, _ = self.scheduler.run_once(t.name, t.value) - messagebox.showinfo("Run Now", f"POST: {t.name}={t.value}\nHTTP {status}\n{coerce_preview(body, 300)}") + messagebox.showinfo("Run Now", f"POST ?variable={t.name}&value={t.value}\nHTTP {status}\n{coerce_preview(body, 300)}") def remove_selected_task(self): sel = self.actions_tree.selection() @@ -896,13 +913,15 @@ class App(tk.Tk): alarm_vars[keyname] = var 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"), - ("High", "action_high", "value_high"), - ("Extreme High", "action_extreme_high", "value_extreme_high")] + rows = [("Dead Low", "action_dead_low", "value_dead_low", "action_dead_low_interval"), + ("Low", "action_low", "value_low", "action_low_interval"), + ("High", "action_high", "value_high", "action_high_interval"), + ("Extreme High", "action_extreme_high", "value_extreme_high", "action_extreme_high_interval")] action_vars = {} value_vars = {} - for i, (label, action_key, value_key) in enumerate(rows): + interval_vars = {} + 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.set(getattr(t, action_key) or "") @@ -911,6 +930,9 @@ class App(tk.Tk): 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) value_vars[value_key] = sv + iv = tk.IntVar(value=getattr(t, interval_key)) + 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) @@ -937,11 +959,9 @@ class App(tk.Tk): 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) - def bind_setter(varkey=tkey, combo=s): - def _(): - expr_target_vars[varkey].set(combo.get()) - combo.bind("<>", lambda *_: _()) - bind_setter() + def _on_combo_select(event, varkey=tkey, combo=s): + expr_target_vars[varkey].set(combo.get()) + s.bind("<>", _on_combo_select) def save_and_close(): def _f(v): @@ -964,6 +984,10 @@ class App(tk.Tk): value_high=value_vars["value_high"].get(), action_extreme_high=action_vars["action_extreme_high"].get() or None, value_extreme_high=value_vars["value_extreme_high"].get(), + action_dead_low_interval=int(interval_vars["action_dead_low_interval"].get()), + action_low_interval=int(interval_vars["action_low_interval"].get()), + action_high_interval=int(interval_vars["action_high_interval"].get()), + action_extreme_high_interval=int(interval_vars["action_extreme_high_interval"].get()), expr_dead_low=expr_vars["expr_dead_low"].get().strip() or None, expr_low=expr_vars["expr_low"].get().strip() or None, expr_high=expr_vars["expr_high"].get().strip() or None, @@ -992,7 +1016,6 @@ class App(tk.Tk): 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") @@ -1010,12 +1033,22 @@ class App(tk.Tk): state = "EXTREME_HIGH" if state != prev: - self.on_enter_state(vi, state, x) + self.on_state_change(vi, prev, state, x) vi.last_state = state + + def on_state_change(self, vi: VarInfo, prev_state: str, new_state: str, x: Optional[float]) -> None: + # stop any repeating task associated with previous state + tid = self.state_tasks.pop((vi.key, prev_state), None) + if tid is not None: + try: + self.scheduler.remove_task(tid) + except Exception: + pass + # then handle enter actions for new_state + self.on_enter_state(vi, new_state, x) def on_enter_state(self, vi: VarInfo, state: str, x: Optional[float]) -> None: t = vi.thresholds - # Alarms default enabled for DEAD_LOW & EXTREME_HIGH alarm_map = { "DEAD_LOW": t.alarm_dead_low, "LOW": t.alarm_low, @@ -1033,7 +1066,6 @@ class App(tk.Tk): try: self.bell() except Exception: pass - # 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" @@ -1049,14 +1081,13 @@ 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())) - data = {POST_KEY_VARIABLE: target_func, POST_KEY_VALUE: str(computed)} - http_post(base, data) + params = {"variable": target_func, "value": str(computed)} + http_post_query(base, params) else: self.status_lbl.configure(text=f"Expr computed for {vi.display_name} [{state}], no target function set") except Exception as e: messagebox.showwarning("Expression error", f"{vi.display_name}: expression failed on enter {state}: {e}") - # Function action act_map = { "DEAD_LOW": (t.action_dead_low, t.value_dead_low), "LOW": (t.action_low, t.value_low), @@ -1065,7 +1096,19 @@ class App(tk.Tk): } act, val = act_map.get(state, (None, "1")) if act: - self.scheduler.add_task(ActionTask(name=act, value=val, interval_s=0)) + # Once on enter if interval == 0 + interval_map = { + "DEAD_LOW": t.action_dead_low_interval, + "LOW": t.action_low_interval, + "HIGH": t.action_high_interval, + "EXTREME_HIGH": t.action_extreme_high_interval, + } + interval = int(interval_map.get(state, 0) or 0) + if interval <= 0: + self.scheduler.add_task(ActionTask(name=act, value=val, interval_s=0)) + else: + tid = self.scheduler.add_task(ActionTask(name=act, value=val, interval_s=max(1, interval))) + self.state_tasks[(vi.key, state)] = tid # --- Tree helpers & rendering --- def _ensure_group(self, group: str) -> str: @@ -1101,29 +1144,16 @@ class App(tk.Tk): 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 + frac = 1.0 - (x - t.dead_low) / span r = 255 g = 248 - frac * (248 - 226) b = 204 - frac * (204 - 138) @@ -1132,21 +1162,17 @@ class App(tk.Tk): 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 + frac = (x - t.high) / span 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 (case-insensitive) groups: Dict[str, List[VarInfo]] = {} for key, vi in self.vars.items(): text = vi.display_name @@ -1155,7 +1181,6 @@ class App(tk.Tk): prefix = text.split("_", 1)[0] if "_" in text else "MISC" groups.setdefault(prefix, []).append(vi) - # Preserve current selection sel = self.tree.selection() sel_iid = sel[0] if sel else None @@ -1178,14 +1203,12 @@ class App(tk.Tk): else: value_preview = coerce_preview(vi.last_value, 120) - # compute color tag x = parse_first_float(vi.last_value) color = self._state_color(vi, x) tag = self._get_color_tag(color) self.tree.item(vid, text=vi.display_name, values=(value_preview, updated_str, vi.last_status or "—"), tags=(tag,)) - # Remove stale 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(key, None) @@ -1193,10 +1216,22 @@ class App(tk.Tk): 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 _bind_mousewheel(self, widget): + def _mw(event): + if event.delta: + widget.yview_scroll(int(-1*(event.delta/120)), "units") + return "break" + def _mw_up(_e): + widget.yview_scroll(-1, "units"); return "break" + def _mw_down(_e): + widget.yview_scroll(1, "units"); return "break" + widget.bind("", _mw) + widget.bind("", _mw_up) + widget.bind("", _mw_down) + def on_close(self) -> None: try: if self.poller and self.poller.is_alive(): @@ -1230,7 +1265,6 @@ def eval_user_expression(expr: str, x: float): 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} - # If it's multiline or contains statements, try exec and read 'result' if "\n" in expr or ";" in expr: code = compile(expr, "", "exec") exec(code, safe_globals, safe_locals)