diff --git a/nucleares_monitor/control_board_monitor.py b/nucleares_monitor/control_board_monitor.py index e54cc26..a57154a 100644 --- a/nucleares_monitor/control_board_monitor.py +++ b/nucleares_monitor/control_board_monitor.py @@ -1,25 +1,14 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- """ -Control Board Monitor - v0.3 (proposal build) -Features added per request: - 1) Variable Selector dialog: after discovery, shows all variables with checkboxes. - Pre-checks currently-monitored set; user can add/remove before starting. - 2) Functions (POST) Runner + Scheduler: - - Discovers writable variables/functions from the HTML POST section and optional JSON. - - "Run Once" with a value parameter. - - "Schedule" to run at an interval; can pause/resume/remove at any time. - 3) Thresholds & Alarms per monitored value: - - Configure LOW/HIGH and optional DEAD_LOW/EXTREME_HIGH per variable. - - Detect state transitions (into LOW/HIGH/DEAD_LOW/EXTREME_HIGH from previous state). - - Optional alarm sound on entering a state. - - Optional function to invoke on entering a state (select any discovered function + value). - 4) Tree-structured GUI: - - Top: Variable monitor tree (grouped by prefix, as before). - - Bottom: Actions/Scheduler tree. - - Menu/Buttons to open Variable Selector and Thresholds dialogs. +Control Board Monitor - v0.4 -Stdlib-only: Tkinter, threading, urllib. Tested on Windows with Python 3.10+. +Fixes & additions: +- Default port changed to 8785. +- Discovery now shows clear error/fallback messages and *never* leaves you empty. +- If discovery fails, it loads a built-in default list (from your provided HTML). +- "Select Vars…" opens with defaults if nothing was discovered yet. +Everything else from v0.3 (selectors, functions/scheduler, thresholds/alarms) is included. """ import json @@ -41,7 +30,6 @@ except Exception as e: # ======= Windows beep support ======= def play_alarm_tone(level: str = "LOW") -> None: - """Best-effort alarm sound; uses winsound on Windows or tk.bell() otherwise.""" try: import winsound # type: ignore freq = {"DEAD_LOW": 300, "LOW": 550, "HIGH": 800, "EXTREME_HIGH": 1000}.get(level, 600) @@ -49,7 +37,6 @@ def play_alarm_tone(level: str = "LOW") -> None: winsound.Beep(freq, dur) except Exception: try: - # Fallback to Tk bell if available (non-blocking/chip) root = tk._default_root if root is not None: root.bell() @@ -61,17 +48,20 @@ def play_alarm_tone(level: str = "LOW") -> None: # Global configuration # ===================== SERVER_HOST: str = "localhost" -SERVER_PORT: int = 8785 -REFRESH_INTERVAL_S: int = 1 +SERVER_PORT: int = 8785 # changed as requested +REFRESH_INTERVAL_S: int = 60 MAX_RPS: float = 5.0 REQUEST_TIMEOUT_S: float = 5.0 -USER_AGENT: str = "ControlBoardMonitor/0.3 (+tkinter)" +USER_AGENT: str = "ControlBoardMonitor/0.4 (+tkinter)" CONNECT_TIMEOUT_S: float = 5.0 BACKOFF_BASE_S: float = 1.0 BACKOFF_MAX_S: float = 15.0 MAX_RETRIES: int = 2 +# Embedded defaults (from provided 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', 'CHEM_TRUCK_PUMP_SPEED', 'CHEM_TRUCK_SAFETY_VALVE_CLOSED', 'CHEM_TRUCK_STATION_VALVE_CLOSED', 'COOLANT_CORE_CIRCULATION_PUMP_0_ACTUAL_SPEED', 'COOLANT_CORE_CIRCULATION_PUMP_0_ORDERED_SPEED', 'COOLANT_CORE_CIRCULATION_PUMP_0_STATUS', 'COOLANT_CORE_CIRCULATION_PUMP_1_ACTUAL_SPEED', 'COOLANT_CORE_CIRCULATION_PUMP_1_ORDERED_SPEED', 'COOLANT_CORE_CIRCULATION_PUMP_1_STATUS', 'COOLANT_CORE_FLOW_IN', 'COOLANT_CORE_FLOW_OUT', 'COOLANT_CORE_PRESSURE', 'COOLANT_PRIMARY_PRESSURE', 'COOLANT_SECONDARY_PRESSURE', 'COOLANT_TEMPERATURE_IN', 'COOLANT_TEMPERATURE_OUT', 'CONDENSER_PRESSURE', 'CONDENSER_TEMPERATURE', 'CONDENSER_VACUUM', 'CONDENSER_VACUUM_PUMP_0_STATUS', 'CONDENSER_VACUUM_PUMP_1_STATUS', '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_CONTROL_RODS_INSERTION_PERCENT', 'CORE_INTEGRITY', 'CORE_PRESSURE', 'CORE_STATE', 'CORE_TEMP', 'CRANE_ARM_POSITION', 'CRANE_GRABBER_CLOSED', 'CRANE_GRABBER_POSITION', 'CRANE_GRABBER_WEIGHT_KG', 'CRANE_LOCATION', 'CRANE_TORCH_ON', 'CRANE_TORCH_POWER', 'EMERGENCY_STOP', 'MAIN_POWER_SWITCH', 'POWER_DEMAND_MW', 'POWER_FROM_TURBINE_KW', 'RADIATION_BUILDING_CONTAINMENT', 'RADIATION_BUILDING_TURBINE', 'RADIATION_CONTROL_ROOM', 'RADIATION_OUTSIDE', 'RESISTOR_0_CURRENT_A', 'RESISTOR_0_ENABLED', 'RESISTOR_0_TEMP', 'RESISTOR_0_VOLTAGE_V', 'RESISTOR_1_CURRENT_A', 'RESISTOR_1_ENABLED', 'RESISTOR_1_TEMP', 'RESISTOR_1_VOLTAGE_V', 'RESISTOR_2_CURRENT_A', 'RESISTOR_2_ENABLED', 'RESISTOR_2_TEMP', 'RESISTOR_2_VOLTAGE_V', 'RESISTOR_3_CURRENT_A', 'RESISTOR_3_ENABLED', 'RESISTOR_3_TEMP', 'RESISTOR_3_VOLTAGE_V', 'STEAM_PRESSURE_AFTER_CONDENSER', 'STEAM_PRESSURE_AFTER_TURBINE', 'STEAM_PRESSURE_BEFORE_CONDENSER', 'STEAM_PRESSURE_BEFORE_TURBINE', 'STEAM_TEMPERATURE_AFTER_CONDENSER', 'STEAM_TEMPERATURE_AFTER_TURBINE', 'STEAM_TEMPERATURE_BEFORE_CONDENSER', 'STEAM_TEMPERATURE_BEFORE_TURBINE', 'STEAM_TURBINE_0_RPM', 'STEAM_TURBINE_1_RPM', 'STEAM_TURBINE_2_RPM', 'STEAM_TURBINE_VALVE_CLOSED', 'TIME', 'TIME_STAMP', 'VALVE_CLOSED', 'VALVE_OPEN', 'WEBSERVER_LIST_FUNCTIONS', 'WEBSERVER_LIST_FUNCTIONS_JSON', 'WEBSERVER_LIST_VARIABLES', 'WEBSERVER_LIST_VARIABLES_JSON'] +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', 'COOLANT_CORE_CIRCULATION_PUMP_0_ORDERED_SPEED', 'COOLANT_CORE_CIRCULATION_PUMP_0_SWITCH', 'COOLANT_CORE_CIRCULATION_PUMP_1_ORDERED_SPEED', 'COOLANT_CORE_CIRCULATION_PUMP_1_SWITCH', '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', 'CRANE_GRABBER_CLOSED', 'CRANE_TORCH_POWER', 'FUN_ALARM', 'FUN_BREAKER_TRIP', 'FUN_DECREASE_INTEGRITY', 'FUN_FIRE_DRILL', 'FUN_INCREASE_INTEGRITY', 'FUN_RESET_ALARMS', 'MAIN_POWER_SWITCH', 'RESISTOR_0_ENABLED', 'RESISTOR_1_ENABLED', 'RESISTOR_2_ENABLED', 'RESISTOR_3_ENABLED', 'STEAM_TURBINE_VALVE_CLOSED', 'VALVE_OFF', 'VALVE_OPEN'] # ===================== # Helper utilities @@ -128,32 +118,26 @@ def build_base_url(host: str, port: int) -> str: def parse_variable_names_from_html_index(html_text: str) -> List[str]: - """Extract readable variables (GET) from an index-like HTML page.""" import urllib.parse 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().upper() if vv and vv not in seen: seen.add(vv); uniq.append(vv) - placeholders = {"VARNAME", "VARIABLE", "NAME"} return [v for v in uniq if v not in placeholders] def parse_function_names_from_html_index(html_text: str) -> List[str]: - """Extract writable variables / functions from the POST section of the HTML.""" start = html_text.find("==== POST ====") if start == -1: return [] @@ -206,7 +190,6 @@ class Thresholds: alarm_low: bool = False alarm_high: bool = False alarm_extreme_high: bool = True - # Optional function triggers on entering each state action_dead_low: Optional[str] = None value_dead_low: str = "1" action_low: Optional[str] = None @@ -225,18 +208,18 @@ class VarInfo: last_status: int = 0 error: Optional[str] = None thresholds: Thresholds = field(default_factory=Thresholds) - last_state: str = "UNKNOWN" # one of UNKNOWN/NORMAL/DEAD_LOW/LOW/HIGH/EXTREME_HIGH + last_state: str = "UNKNOWN" @dataclass class ActionTask: - name: str # function/variable name to POST - value: str # value to send - interval_s: int # 0 for one-shot + name: str + value: str + interval_s: int enabled: bool = True last_run: float = 0.0 next_run: float = 0.0 - task_id: int = 0 # assigned by scheduler + task_id: int = 0 # ===================== @@ -290,7 +273,6 @@ class ActionScheduler(threading.Thread): for t in self.tasks.values(): if t.enabled and t.next_run <= now: to_run.append(t) - # Execute outside lock for t in to_run: try: status, body, _ = self.run_once(t.name, t.value) @@ -298,10 +280,8 @@ class ActionScheduler(threading.Thread): if t.interval_s > 0: t.next_run = now + t.interval_s else: - # one-shot: remove after run self.remove_task(t.task_id) except Exception: - # swallow to keep scheduler alive if t.interval_s > 0: t.next_run = now + t.interval_s else: @@ -318,7 +298,7 @@ class ActionScheduler(threading.Thread): class App(tk.Tk): def __init__(self) -> None: super().__init__() - self.title("Control Board Monitor — v0.3") + self.title("Control Board Monitor — v0.4") self.geometry("1250x800") try: self.iconbitmap(default="") @@ -451,8 +431,11 @@ class App(tk.Tk): self.protocol("WM_DELETE_WINDOW", self.on_close) - # Initial discovery (does not auto-start polling) - self.after(200, self.reload_discovery) + # Initial: don't assume server works; just preload defaults into selector + self.variables_list = [] + self.functions_list = list(DEFAULT_FUNCTIONS) + self.func_combo["values"] = self.functions_list + # optionally, we could auto-open selector; but keep consistent with your flow. # ------------- Discovery & selector ------------- def reload_discovery(self) -> None: @@ -463,7 +446,9 @@ class App(tk.Tk): discovered_vars: List[str] = [] discovered_funcs: List[str] = [] - # JSON for variables + msgs = [] + + # Try JSON vars status, body, _ = http_get(base, {"variable": "WEBSERVER_LIST_VARIABLES_JSON"}) if status == 200: try: @@ -474,23 +459,26 @@ class App(tk.Tk): v = x.strip().upper() if v and v not in discovered_vars: discovered_vars.append(v) - except Exception: - pass + except Exception as e: + msgs.append(f"Vars JSON parse error: {e}") + else: + msgs.append(f"Vars JSON HTTP {status}: {coerce_preview(body, 120)}") - # HTML + # Try HTML vars + functions status_h, body_h, _h = http_get(base, {"variable": "WEBSERVER_LIST_VARIABLES"}) if status_h == 200 and body_h: cand = parse_variable_names_from_html_index(body_h) for v in cand: if v not in discovered_vars: discovered_vars.append(v) - # Functions from POST section f_cand = parse_function_names_from_html_index(body_h) for f in f_cand: if f not in discovered_funcs: discovered_funcs.append(f) + else: + msgs.append(f"Vars HTML HTTP {status_h}: {coerce_preview(body_h, 120)}") - # Optional JSON for functions + # Optional functions JSON status_f, body_f, _f = http_get(base, {"variable": "WEBSERVER_LIST_FUNCTIONS_JSON"}) if status_f == 200: try: @@ -501,24 +489,40 @@ class App(tk.Tk): fx = x.strip().upper() if fx and fx not in discovered_funcs: discovered_funcs.append(fx) - except Exception: - pass + except Exception as e: + msgs.append(f"Funcs JSON parse error: {e}") + elif status_f != 0: + msgs.append(f"Funcs JSON HTTP {status_f}: {coerce_preview(body_f, 120)}") - # If empty, keep previous; else present selector dialog - if discovered_vars: - self.show_selector_dialog(discovered_vars) - if discovered_funcs: - self.functions_list = discovered_funcs - self.func_combo["values"] = self.functions_list + # Fallbacks + used_defaults = False + if not discovered_vars: + discovered_vars = list(DEFAULT_VARS) + used_defaults = True + if not discovered_funcs: + discovered_funcs = list(DEFAULT_FUNCTIONS) - self.status_lbl.configure(text=f"Discovered vars: {len(discovered_vars)} | functions: {len(self.functions_list)}") + # Apply + self.functions_list = discovered_funcs + self.func_combo["values"] = self.functions_list + self.show_selector_dialog(discovered_vars) + + # User-visible status + if used_defaults: + messagebox.showwarning( + "Discovery failed — loaded defaults", + "Could not discover variables from the server.\n" + "Loaded a built-in list from your spec so you can proceed.\n\n" + + ("\n".join(msgs[:6]) if msgs else "No server responses.")) + self.status_lbl.configure(text=f"Loaded defaults ({len(discovered_vars)} vars, {len(discovered_funcs)} funcs)") + else: + self.status_lbl.configure(text=f"Discovered vars: {len(discovered_vars)} | functions: {len(discovered_funcs)}") def open_selector(self) -> None: - # Manual open (uses last discovered set = union of current + variables_list) union = sorted(set(self.variables_list) | set(self.vars.keys())) if not union: - messagebox.showinfo("No variables", "No discovered variables yet. Use Reload (disc.) first.") - return + # use defaults if nothing yet + union = list(DEFAULT_VARS) self.show_selector_dialog(union) def show_selector_dialog(self, discovered: List[str]) -> None: @@ -528,7 +532,6 @@ class App(tk.Tk): dlg.transient(self) dlg.grab_set() - # Filter entry top = ttk.Frame(dlg, padding=8) top.pack(side=tk.TOP, fill=tk.X) ttk.Label(top, text="Filter:").pack(side=tk.LEFT) @@ -538,7 +541,6 @@ class App(tk.Tk): sel_none = ttk.Button(top, text="Clear") sel_all.pack(side=tk.LEFT, padx=4); sel_none.pack(side=tk.LEFT, padx=4) - # Scrollable checkbox list frame = ttk.Frame(dlg) frame.pack(side=tk.TOP, fill=tk.BOTH, expand=True, padx=8, pady=8) canvas = tk.Canvas(frame, borderwidth=0) @@ -549,7 +551,6 @@ class App(tk.Tk): canvas.configure(yscrollcommand=vsb.set) canvas.pack(side="left", fill="both", expand=True); vsb.pack(side="right", fill="y") - # Build vars current_set = set(self.variables_list or list(self.vars.keys())) rows: List[Tuple[str, tk.BooleanVar, ttk.Checkbutton]] = [] def rebuild(): @@ -566,15 +567,12 @@ class App(tk.Tk): rows.append((name, var, cb)) rebuild() - # Wire filter/select-all 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]) - # Buttons bot = ttk.Frame(dlg, padding=8) bot.pack(side=tk.BOTTOM, fill=tk.X) def apply_and_close(): @@ -583,7 +581,6 @@ class App(tk.Tk): if not messagebox.askyesno("Empty selection", "No variables selected. Continue?"): return self.variables_list = selected - # reset var info dictionary to selected self.vars = {v: self.vars.get(v, VarInfo(name=v)) for v in self.variables_list} self.refresh_tree() dlg.destroy() @@ -604,12 +601,15 @@ class App(tk.Tk): if not sel: messagebox.showinfo("No selection", "Select a variable first.") return - # Resolve to a variable item (skip group headers) item = sel[0] name = self.tree.item(item, "text") - if not name or name.upper() in {"MISC","CORE","COOLANT","STEAM","CHEM","POWER","VALVE","RESISTOR"}: + # If user right-clicked a group header, bail + if not name or "_" not in name: messagebox.showinfo("Select variable", "Select a specific variable row (not a group).") return + # remove any state suffix in brackets + if name.endswith("]") and "[" in name: + name = name[:name.rfind("[")].strip() vi = self.vars.get(name) or VarInfo(name=name) dlg = tk.Toplevel(self) @@ -617,7 +617,6 @@ class App(tk.Tk): dlg.geometry("560x520") dlg.transient(self); dlg.grab_set() - # Entries t = vi.thresholds grid = ttk.Frame(dlg, padding=10) grid.pack(side=tk.TOP, fill=tk.BOTH, expand=True) @@ -635,7 +634,6 @@ class App(tk.Tk): add_row(2, "HIGH", "high", t.high) add_row(3, "EXTREME HIGH", "extreme_high", t.extreme_high) - # Alarm checkboxes 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), @@ -646,9 +644,7 @@ class App(tk.Tk): 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 - # Action bindings ttk.Label(grid, text="On enter, run function:").grid(row=7, column=0, sticky="w", pady=(12,4)) - # Function selectors + value entries for each state rows = [("Dead Low", "action_dead_low", "value_dead_low"), ("Low", "action_low", "value_low"), ("High", "action_high", "value_high"), @@ -657,7 +653,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, width=38, state="readonly") + s = ttk.Combobox(grid, values=self.functions_list or DEFAULT_FUNCTIONS, width=38, 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 @@ -666,7 +662,6 @@ class App(tk.Tk): value_vars[value_key] = sv def save_and_close(): - # Parse floats or None def _f(v): v = v.strip() return float(v) if v else None @@ -821,7 +816,6 @@ class App(tk.Tk): vi.last_status = status vi.error = None self.vars[name] = vi - # Evaluate thresholds & trigger events if needed self.evaluate_thresholds(name, value) elif kind == "error": _, name, status, msg = item @@ -861,11 +855,9 @@ class App(tk.Tk): t = vi.thresholds x = parse_first_float(value) if x is None: - # can't evaluate; treat as NORMAL unless error new_state = "NORMAL" else: new_state = "NORMAL" - # Order of precedence: dead_low < low < high < extreme_high 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: @@ -874,15 +866,12 @@ class App(tk.Tk): new_state = "EXTREME_HIGH" elif t.high is not None and x >= t.high: new_state = "HIGH" - if new_state != vi.last_state: - # Entering a state: run actions / alarms self.on_enter_state(name, new_state, vi) vi.last_state = new_state def on_enter_state(self, name: str, state: str, vi: VarInfo) -> None: t = vi.thresholds - # Alarm alarm_map = { "DEAD_LOW": t.alarm_dead_low, "LOW": t.alarm_low, @@ -891,8 +880,6 @@ class App(tk.Tk): } if alarm_map.get(state): play_alarm_tone(state) - - # Function trigger act_map = { "DEAD_LOW": (t.action_dead_low, t.value_dead_low), "LOW": (t.action_low, t.value_low), @@ -901,7 +888,6 @@ class App(tk.Tk): } act, val = act_map.get(state, (None, "1")) if act: - # fire-and-forget one-shot via scheduler self.scheduler.add_task(ActionTask(name=act, value=val, interval_s=0)) # ------------- Tree rendering ------------- @@ -930,7 +916,6 @@ class App(tk.Tk): value_preview = coerce_preview(vi.last_value, 120) tag = infer_status_color(vi.last_value if not vi.error else "error") - # Append state suffix if thresholds defined suffix = "" if vi.last_state in {"UNKNOWN", "NORMAL"} else f" [{vi.last_state}]" self.tree.insert( parent, @@ -954,7 +939,7 @@ class App(tk.Tk): # ===================== -# Poller thread (after App to use its type hints without quotes) +# Poller thread # ===================== class Poller(threading.Thread): def __init__(