diff --git a/nucleares_monitor/control_board_monitor.py b/nucleares_monitor/control_board_monitor.py index a066af6..1a1dad7 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.1 +Control Board Monitor — v1.2 What’s new: - POSTs are now **exactly** like the sample app: @@ -34,9 +34,9 @@ except Exception: # ===================== SERVER_HOST: str = "localhost" SERVER_PORT: int = 8785 -REFRESH_INTERVAL_S: int = 60 +REFRESH_INTERVAL_S: int = 1 REQUEST_TIMEOUT_S: float = 5.0 -USER_AGENT: str = "ControlBoardMonitor/1.1 (+tkinter)" +USER_AGENT: str = "ControlBoardMonitor/1.2 (+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'] @@ -215,10 +215,10 @@ class Thresholds: 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 + action_dead_low_interval: float = 1.0 + action_low_interval: float = 1.0 + action_high_interval: float = 1.0 + action_extreme_high_interval: float = 1.0 # expression actions expr_dead_low: Optional[str] = None expr_low: Optional[str] = None @@ -245,7 +245,7 @@ class VarInfo: class ActionTask: name: str value: str - interval_s: int + interval_s: float enabled: bool = True last_run: float = 0.0 next_run: float = 0.0 @@ -330,7 +330,7 @@ class Poller(threading.Thread): port: int, variables_keys: List[str], # canonical lower-case keys ui_queue: queue.Queue, - refresh_interval: int, + refresh_interval: float, stop_event: threading.Event, paused_event: threading.Event, ) -> None: @@ -339,7 +339,7 @@ class Poller(threading.Thread): self.port = port self.variables_keys = variables_keys self.ui_queue = ui_queue - self.refresh_interval = int(max(5, refresh_interval)) + self.refresh_interval = float(max(0.1, refresh_interval)) self.stop_event = stop_event self.paused_event = paused_event @@ -354,6 +354,8 @@ class Poller(threading.Thread): values_map = parse_batch_values(body) if values_map: lower_map = {k.lower(): v for k, v in values_map.items()} + # push full batch to UI for 'y' lookups + self.ui_queue.put(("batch", lower_map)) for key in list(self.variables_keys): if key in lower_map: self.ui_queue.put(("update", key, str(lower_map[key]), 200)) @@ -392,7 +394,7 @@ class Poller(threading.Thread): class App(tk.Tk): def __init__(self) -> None: super().__init__() - self.title("Control Board Monitor — v1.1") + self.title("Control Board Monitor — v1.2") self.geometry("1280x860") # Canonical registry: lower-case key -> VarInfo @@ -412,7 +414,7 @@ class App(tk.Tk): self.host_var = tk.StringVar(value=SERVER_HOST) self.port_var = tk.IntVar(value=SERVER_PORT) - self.refresh_var = tk.IntVar(value=REFRESH_INTERVAL_S) + self.refresh_var = tk.DoubleVar(value=REFRESH_INTERVAL_S) ttk.Label(controls, text="Host:").grid(row=0, column=0, sticky="w") ttk.Entry(controls, textvariable=self.host_var, width=16).grid(row=0, column=1, padx=(0, 10)) @@ -506,6 +508,24 @@ class App(tk.Tk): self.protocol("WM_DELETE_WINDOW", self.on_close) + # Menubar + menubar = tk.Menu(self) + filemenu = tk.Menu(menubar, tearoff=False) + filemenu.add_command(label="Save Configuration...", command=self.save_config) + filemenu.add_command(label="Load Configuration...", command=self.load_config) + filemenu.add_separator() + filemenu.add_command(label="Exit", command=self.on_close) + menubar.add_cascade(label="File", menu=filemenu) + + funmenu = tk.Menu(menubar, tearoff=False) + funmenu.add_command(label="Run Function Once...", command=self.menu_run_once_dialog) + funmenu.add_command(label="Add Scheduled Function...", command=self.menu_schedule_dialog) + funmenu.add_separator() + funmenu.add_command(label="Reload Discovery", command=self.reload_discovery) + funmenu.add_command(label="Select Variables…", command=self.open_selector) + self.config(menu=menubar) + + self.functions_list = list(DEFAULT_FUNCTIONS) self.func_combo["values"] = self.functions_list if self.functions_list and not self.func_name_var.get(): @@ -519,10 +539,16 @@ class App(tk.Tk): self._color_tags: Dict[str, str] = {} # Track repeating actions per (var_key,state) self.state_tasks: Dict[tuple, int] = {} + # Latest values from batch (lowercased keys) + self.latest_values: Dict[str, str] = {} # Trigger discovery shortly after startup so function list populates self.after(200, self.reload_discovery) + # Live watch for refresh interval changes + self._last_refresh_val = float(self.refresh_var.get()) + self.after(2000, self._watch_refresh_interval) + # 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) @@ -738,12 +764,13 @@ class App(tk.Tk): val_var = tk.StringVar(value=self.func_value_var.get()) ttk.Label(top, text="Value:").pack() ttk.Entry(top, textvariable=val_var, width=16).pack() - int_var = tk.IntVar(value=30) + int_var = tk.DoubleVar(value=1.0) ttk.Label(top, text="Interval (seconds):").pack(pady=(6,0)) ttk.Entry(top, textvariable=int_var, width=10).pack() def add_it(): - task = ActionTask(name=fname, value=val_var.get(), interval_s=max(1, int_var.get())) + interval = float(int_var.get()) + task = ActionTask(name=fname, value=val_var.get(), interval_s=max(0.0, interval)) tid = self.scheduler.add_task(task) self.refresh_actions_tree() top.destroy() @@ -798,7 +825,7 @@ class App(tk.Tk): port=int(self.port_var.get()), variables_keys=self.variables_keys, ui_queue=self.ui_queue, - refresh_interval=int(self.refresh_var.get()), + refresh_interval=float(self.refresh_var.get()), stop_event=self.stop_event, paused_event=self.paused_event, ) @@ -846,6 +873,10 @@ class App(tk.Tk): elif kind == "cycle_start": _, hhmmss = item self.cycle_lbl.configure(text=f"Last cycle start: {hhmmss}") + elif kind == "batch": + _, mapping = item + if isinstance(mapping, dict): + self.latest_values.update(mapping) elif kind == "stopped": self.status_lbl.configure(text="Stopped") self.ui_queue.task_done() @@ -885,14 +916,27 @@ class App(tk.Tk): dlg.geometry("980x680") dlg.transient(self); dlg.grab_set() + t = vi.thresholds + # Auto-defaults based on current value if thresholds are empty + cur = parse_first_float(vi.last_value) + auto_defaults = {} + if cur is not None: + auto_defaults = { + "dead_low": round(cur * 0.8, 6), + "low": round(cur * 0.9, 6), + "high": round(cur * 1.1, 6), + "extreme_high": round(cur * 1.2, 6), + } + grid = ttk.Frame(dlg, padding=10) grid.pack(side=tk.TOP, fill=tk.BOTH, expand=True) entries = {} 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)) + dv = auto_defaults.get(keyname) if default is None else default + sv = tk.StringVar(value="" if dv is None else str(dv)) e = ttk.Entry(grid, textvariable=sv, width=12) e.grid(row=r, column=1, sticky="w", padx=6) entries[keyname] = sv @@ -930,7 +974,7 @@ 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)) + iv = tk.DoubleVar(value=getattr(t, interval_key) if getattr(t, interval_key) is not None else 1.0) ttk.Entry(grid, textvariable=iv, width=10).grid(row=8+i, column=3, sticky="w", padx=6) interval_vars[interval_key] = iv @@ -984,10 +1028,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()), + action_dead_low_interval=float(interval_vars["action_dead_low_interval"].get() or 1.0), + action_low_interval=float(interval_vars["action_low_interval"].get() or 1.0), + action_high_interval=float(interval_vars["action_high_interval"].get() or 1.0), + action_extreme_high_interval=float(interval_vars["action_extreme_high_interval"].get() or 1.0), 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, @@ -1009,7 +1053,19 @@ class App(tk.Tk): # --- State evaluation per spec + color gradients --- def evaluate_thresholds(self, vi: VarInfo, value: str) -> None: x = parse_first_float(value) + t = vi.thresholds + # Auto-defaults based on current value if thresholds are empty + cur = parse_first_float(vi.last_value) + auto_defaults = {} + if cur is not None: + auto_defaults = { + "dead_low": round(cur * 0.8, 6), + "low": round(cur * 0.9, 6), + "high": round(cur * 1.1, 6), + "extreme_high": round(cur * 1.2, 6), + } + prev = vi.last_state state = "UNKNOWN" @@ -1048,7 +1104,19 @@ class App(tk.Tk): # 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 + # Auto-defaults based on current value if thresholds are empty + cur = parse_first_float(vi.last_value) + auto_defaults = {} + if cur is not None: + auto_defaults = { + "dead_low": round(cur * 0.8, 6), + "low": round(cur * 0.9, 6), + "high": round(cur * 1.1, 6), + "extreme_high": round(cur * 1.2, 6), + } + alarm_map = { "DEAD_LOW": t.alarm_dead_low, "LOW": t.alarm_low, @@ -1074,11 +1142,32 @@ class App(tk.Tk): "DEAD_LOW": "expr_target_dead_low", "LOW": "expr_target_low", "HIGH": "expr_target_high", "EXTREME_HIGH": "expr_target_extreme_high" }.get(state) + expr = getattr(t, expr_key) if expr_key else None target_func = getattr(t, target_key) if target_key else None + # 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)) + 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}) + if st == 200: + y_val = parse_first_float(bd) if expr and x is not None: try: - computed = eval_user_expression(expr, x) + computed = eval_user_expression(expr, x, y_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)} @@ -1103,11 +1192,11 @@ class App(tk.Tk): "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)) + interval = float(interval_map.get(state, 0) or 0.0) + if interval <= 0.0: + self.scheduler.add_task(ActionTask(name=act, value=val, interval_s=0.0)) else: - tid = self.scheduler.add_task(ActionTask(name=act, value=val, interval_s=max(1, interval))) + tid = self.scheduler.add_task(ActionTask(name=act, value=val, interval_s=max(0.0, interval))) self.state_tasks[(vi.key, state)] = tid # --- Tree helpers & rendering --- @@ -1144,7 +1233,19 @@ class App(tk.Tk): return tag def _state_color(self, vi: VarInfo, x: Optional[float]) -> str: + t = vi.thresholds + # Auto-defaults based on current value if thresholds are empty + cur = parse_first_float(vi.last_value) + auto_defaults = {} + if cur is not None: + auto_defaults = { + "dead_low": round(cur * 0.8, 6), + "low": round(cur * 0.9, 6), + "high": round(cur * 1.1, 6), + "extreme_high": round(cur * 1.2, 6), + } + if x is None: return "#f5f7fb" def ci(v): return max(0, min(255, int(v))) @@ -1232,6 +1333,146 @@ class App(tk.Tk): widget.bind("", _mw_up) widget.bind("", _mw_down) + + def _watch_refresh_interval(self): + try: + val = float(self.refresh_var.get()) + except Exception: + val = self._last_refresh_val + if val != self._last_refresh_val: + self._last_refresh_val = val + if self.poller and self.poller.is_alive(): + try: + self.poller.refresh_interval = max(0.1, val) + self.status_lbl.configure(text=f"Running… (refresh {self.poller.refresh_interval}s)") + except Exception: + pass + self.after(2000, self._watch_refresh_interval) + + def menu_run_once_dialog(self): + 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.pack() + ttk.Label(top, text="Value:").pack(pady=(6,2)) + val = tk.StringVar(value="1") + ttk.Entry(top, textvariable=val, width=16).pack() + def go(): + fname = name.get().strip() + if not fname: return + status, body, _ = self.scheduler.run_once(fname, val.get()) + messagebox.showinfo("Run Once", f"POST ?variable={fname}&value={val.get()}\nHTTP {status}\n{coerce_preview(body, 300)}") + top.destroy() + ttk.Button(top, text="Run", command=go).pack(pady=10) + + def menu_schedule_dialog(self): + 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.pack() + ttk.Label(top, text="Value:").pack(pady=(6,2)) + val = tk.StringVar(value="1") + ttk.Entry(top, textvariable=val, width=16).pack() + ttk.Label(top, text="Interval (seconds, can be < 1.0):").pack(pady=(6,2)) + interval = tk.DoubleVar(value=1.0) + ttk.Entry(top, textvariable=interval, width=12).pack() + def go(): + fname = name.get().strip() + if not fname: return + tid = self.scheduler.add_task(ActionTask(name=fname, value=val.get(), interval_s=max(0.0, float(interval.get())))) + self.refresh_actions_tree() + top.destroy() + ttk.Button(top, text="Add", command=go).pack(pady=10) + + def save_config(self): + try: + from tkinter import filedialog + cfg = { + "host": self.host_var.get(), + "port": int(self.port_var.get()), + "refresh_interval": float(self.refresh_var.get()), + "variables_keys": self.variables_keys, + "vars": {k: self._serialize_varinfo(v) for k,v in self.vars.items() if k in self.variables_keys}, + "scheduled_tasks": [self._serialize_task(t) for t in self.scheduler.list_tasks()], + } + path = filedialog.asksaveasfilename(defaultextension=".json", filetypes=[("JSON","*.json")], title="Save Configuration") + if not path: return + import json + with open(path, "w", encoding="utf-8") as f: + json.dump(cfg, f, indent=2) + messagebox.showinfo("Saved", f"Configuration saved to:\n{path}") + except Exception as e: + messagebox.showerror("Save failed", str(e)) + + def load_config(self): + try: + from tkinter import filedialog + path = filedialog.askopenfilename(filetypes=[("JSON","*.json")], title="Load Configuration") + if not path: return + import json + with open(path, "r", encoding="utf-8") as f: + cfg = json.load(f) + self.host_var.set(cfg.get("host", self.host_var.get())) + self.port_var.set(cfg.get("port", self.port_var.get())) + self.refresh_var.set(cfg.get("refresh_interval", self.refresh_var.get())) + keys = cfg.get("variables_keys", []) + self.variables_keys = [k.lower() for k in keys] + new_vars = {} + for k, data in (cfg.get("vars") or {}).items(): + vi = self._deserialize_varinfo(k, data) + new_vars[k.lower()] = vi + self.vars = new_vars + # replace scheduled tasks + for t in [t.task_id for t in self.scheduler.list_tasks()]: + self.scheduler.remove_task(t) + for td in cfg.get("scheduled_tasks", []): + self.scheduler.add_task(self._deserialize_task(td)) + if self.poller and self.poller.is_alive(): + self.poller.variables_keys = self.variables_keys[:] + self.refresh_tree() + self.refresh_actions_tree() + messagebox.showinfo("Loaded", f"Configuration loaded from:\n{path}") + except Exception as e: + messagebox.showerror("Load failed", str(e)) + + def _serialize_varinfo(self, vi: VarInfo): + t = vi.thresholds + return { + "display_name": vi.display_name, + "thresholds": { + "dead_low": t.dead_low, "low": t.low, "high": t.high, "extreme_high": t.extreme_high, + "alarm_dead_low": t.alarm_dead_low, "alarm_low": t.alarm_low, "alarm_high": t.alarm_high, "alarm_extreme_high": t.alarm_extreme_high, + "action_dead_low": t.action_dead_low, "value_dead_low": t.value_dead_low, "action_dead_low_interval": t.action_dead_low_interval, + "action_low": t.action_low, "value_low": t.value_low, "action_low_interval": t.action_low_interval, + "action_high": t.action_high, "value_high": t.value_high, "action_high_interval": t.action_high_interval, + "action_extreme_high": t.action_extreme_high, "value_extreme_high": t.value_extreme_high, "action_extreme_high_interval": t.action_extreme_high_interval, + "expr_dead_low": t.expr_dead_low, "expr_low": t.expr_low, "expr_high": t.expr_high, "expr_extreme_high": t.expr_extreme_high, + "expr_target_dead_low": t.expr_target_dead_low, "expr_target_low": t.expr_target_low, "expr_target_high": t.expr_target_high, "expr_target_extreme_high": t.expr_target_extreme_high, + }, + } + + def _deserialize_varinfo(self, key: str, data: dict) -> VarInfo: + vi = VarInfo(key=key.lower(), display_name=data.get("display_name", key)) + td = data.get("thresholds") or {} + vi.thresholds = Thresholds( + dead_low=td.get("dead_low"), low=td.get("low"), high=td.get("high"), extreme_high=td.get("extreme_high"), + alarm_dead_low=td.get("alarm_dead_low", True), alarm_low=td.get("alarm_low", False), alarm_high=td.get("alarm_high", False), alarm_extreme_high=td.get("alarm_extreme_high", True), + action_dead_low=td.get("action_dead_low"), value_dead_low=td.get("value_dead_low", "1"), action_dead_low_interval=td.get("action_dead_low_interval", 1.0), + action_low=td.get("action_low"), value_low=td.get("value_low", "1"), action_low_interval=td.get("action_low_interval", 1.0), + action_high=td.get("action_high"), value_high=td.get("value_high", "1"), action_high_interval=td.get("action_high_interval", 1.0), + action_extreme_high=td.get("action_extreme_high"), value_extreme_high=td.get("value_extreme_high", "1"), action_extreme_high_interval=td.get("action_extreme_high_interval", 1.0), + expr_dead_low=td.get("expr_dead_low"), expr_low=td.get("expr_low"), expr_high=td.get("expr_high"), expr_extreme_high=td.get("expr_extreme_high"), + expr_target_dead_low=td.get("expr_target_dead_low"), expr_target_low=td.get("expr_target_low"), expr_target_high=td.get("expr_target_high"), expr_target_extreme_high=td.get("expr_target_extreme_high"), + ) + return vi + + def _serialize_task(self, t: ActionTask): + return {"name": t.name, "value": t.value, "interval_s": t.interval_s, "enabled": t.enabled} + + def _deserialize_task(self, d: dict) -> ActionTask: + return ActionTask(name=d.get("name",""), value=d.get("value","1"), interval_s=float(d.get("interval_s", 1.0)), enabled=d.get("enabled", True)) def on_close(self) -> None: try: if self.poller and self.poller.is_alive(): @@ -1256,7 +1497,7 @@ def parse_first_float(value: str) -> Optional[float]: return None return None -def eval_user_expression(expr: str, x: float): +def eval_user_expression(expr: str, x: float, y: float | None = None): """Evaluate user expression/snippet with x available. Allowed: math.*, min, max, abs, round, int, float, clamp (custom). Returns the computed result. @@ -1264,7 +1505,7 @@ def eval_user_expression(expr: str, x: float): 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} + safe_locals = {"x": x, "y": y} if "\n" in expr or ";" in expr: code = compile(expr, "", "exec") exec(code, safe_globals, safe_locals) @@ -1280,4 +1521,4 @@ def main() -> None: app.mainloop() if __name__ == "__main__": - main() + main() \ No newline at end of file