#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Control Board Monitor - v0.2 - Robust discovery: merges WEBSERVER_LIST_VARIABLES_JSON + WEBSERVER_LIST_VARIABLES (HTML). - Better HTML parsing: extracts from hrefs and plain "variable=NAME" tokens; filters placeholders. - New: "Load HTML" button to parse a local HTML file (e.g., the documentation page you shared). - Same lightweight Tkinter GUI, request throttling, and backoff. """ import json import queue import re import threading import time import urllib.parse import urllib.request from dataclasses import dataclass from datetime import datetime from typing import Dict, List, Optional, Tuple try: import tkinter as tk from tkinter import ttk, messagebox, simpledialog, filedialog except Exception as e: raise SystemExit("Tkinter is required to run this app.") # ===================== # Global configuration # ===================== SERVER_HOST: str = "localhost" SERVER_PORT: int = 8785 REFRESH_INTERVAL_S: int = 1 MAX_RPS: float = 5.0 REQUEST_TIMEOUT_S: float = 5.0 USER_AGENT: str = "ControlBoardMonitor/0.2 (+tkinter)" CONNECT_TIMEOUT_S: float = 5.0 BACKOFF_BASE_S: float = 1.0 BACKOFF_MAX_S: float = 15.0 MAX_RETRIES: int = 2 # ===================== # Helper utilities # ===================== def http_get(base_url: str, params: Dict[str, str]) -> Tuple[int, str, Dict[str, str]]: url = base_url + "?" + urllib.parse.urlencode(params) req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) try: with urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT_S) as resp: status = resp.getcode() body_bytes = resp.read() try: body = body_bytes.decode("utf-8", errors="replace") except Exception: body = body_bytes.decode("latin-1", errors="replace") headers = {k.lower(): v for k, v in resp.getheaders()} return status, body, headers except urllib.error.HTTPError as e: try: body = e.read().decode("utf-8", errors="replace") except Exception: body = str(e) return e.code, body, dict(e.headers or {}) except Exception as e: return 0, str(e), {} def build_base_url(host: str, port: int) -> str: return f"http://{host}:{port}/" def parse_variable_names_from_html_index(html_text: str) -> List[str]: """Extract variable names from an index-like HTML page. Strategy: 1) Pull from all hrefs' query strings: ?variable=NAME (case-insensitive). 2) Also scan plain text "variable=NAME" tokens. 3) Normalize, de-dup, keep original order. 4) Filter placeholders like "VARNAME". """ import urllib.parse tokens: List[str] = [] # HREFs with any query string containing "variable" 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) # Plain text occurrences like variable=FOO (allow quotes, hyphens, periods) for m in re.finditer(r'variable\s*=\s*["\']?([A-Za-z0-9_.:-]+)', html_text, flags=re.I): tokens.append(m.group(1)) # De-duplicate while preserving order; uppercase for consistency 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) # Filter obvious placeholders placeholders = {"VARNAME", "VARIABLE", "NAME"} uniq = [v for v in uniq if v not in placeholders] return uniq def coerce_preview(value: str, maxlen: int = 80) -> str: v = value.strip().replace("\r", " ").replace("\n", " ") if len(v) > maxlen: return v[:maxlen - 1] + "…" return v def infer_status_color(value: str) -> str: val = value.strip().lower() if val in {"1", "true", "on", "open", "active", "ok", "running", "enabled"}: return "ok" if val in {"0", "false", "off", "closed", "inactive", "error", "fault", "disabled"}: return "bad" if val == "" or "exception" in val or "error" in val: return "bad" return "neutral" # ===================== # Data model # ===================== @dataclass class VarInfo: name: str last_value: str = "" last_updated: float = 0.0 last_status: int = 0 error: Optional[str] = None # ===================== # Poller thread # ===================== class Poller(threading.Thread): def __init__( self, host: str, port: int, variables: List[str], ui_queue: queue.Queue, refresh_interval: int, max_rps: float, stop_event: threading.Event, paused_event: threading.Event, ) -> None: super().__init__(daemon=True) self.host = host self.port = port self.variables = variables self.ui_queue = ui_queue self.refresh_interval = int(max(5, refresh_interval)) self.max_rps = max(0.5, max_rps) self.stop_event = stop_event self.paused_event = paused_event def run(self) -> None: base_url = build_base_url(self.host, self.port) min_spacing = 1.0 / self.max_rps # seconds between requests while not self.stop_event.is_set(): cycle_start = time.time() self.ui_queue.put(("cycle_start", datetime.now().strftime("%H:%M:%S"))) for name in list(self.variables): while self.paused_event.is_set() and not self.stop_event.is_set(): time.sleep(0.1) if self.stop_event.is_set(): break attempt = 0 backoff = BACKOFF_BASE_S while attempt <= MAX_RETRIES and not self.stop_event.is_set(): attempt += 1 t0 = time.time() status, body, headers = http_get(base_url, {"variable": name}) if status == 200: self.ui_queue.put(("update", name, body, status)) break if status in (429, 500, 502, 503, 504, 0): self.ui_queue.put(("error", name, status, coerce_preview(body, 200))) time.sleep(backoff) backoff = min(BACKOFF_MAX_S, backoff * 2.0) else: self.ui_queue.put(("error", name, status, coerce_preview(body, 200))) break dt = time.time() - t0 if dt < min_spacing: time.sleep(min_spacing - dt) dt = time.time() - t0 if dt < min_spacing: time.sleep(min_spacing - dt) cycle_dt = time.time() - cycle_start remaining = self.refresh_interval - cycle_dt if remaining > 0: for _ in range(int(remaining * 10)): if self.stop_event.is_set(): break time.sleep(0.1) self.ui_queue.put(("stopped", "")) # ===================== # GUI # ===================== class App(tk.Tk): def __init__(self) -> None: super().__init__() self.title("Control Board Monitor") self.geometry("1150x720") try: self.iconbitmap(default="") except Exception: pass # State self.vars: Dict[str, VarInfo] = {} self.stop_event = threading.Event() self.paused_event = threading.Event() self.ui_queue: queue.Queue = queue.Queue() self.poller: Optional[Poller] = None self.variables_list: List[str] = [] # Top controls controls = ttk.Frame(self, padding=10) controls.pack(side=tk.TOP, fill=tk.X) 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.rps_var = tk.DoubleVar(value=MAX_RPS) 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, 12)) ttk.Label(controls, text="Port:").grid(row=0, column=2, sticky="w") ttk.Entry(controls, textvariable=self.port_var, width=8).grid(row=0, column=3, padx=(0, 12)) ttk.Label(controls, text="Refresh (s):").grid(row=0, column=4, sticky="w") ttk.Entry(controls, textvariable=self.refresh_var, width=8).grid(row=0, column=5, padx=(0, 12)) ttk.Label(controls, text="Max RPS:").grid(row=0, column=6, sticky="w") ttk.Entry(controls, textvariable=self.rps_var, width=8).grid(row=0, column=7, padx=(0, 12)) self.status_lbl = ttk.Label(controls, text="Idle", foreground="#666") self.status_lbl.grid(row=0, column=8, padx=(10, 0)) btns = ttk.Frame(controls) btns.grid(row=0, column=9, padx=10, sticky="e") self.start_btn = ttk.Button(btns, text="Start", command=self.start_polling) self.start_btn.grid(row=0, column=0, padx=4) self.pause_btn = ttk.Button(btns, text="Pause", command=self.toggle_pause, state="disabled") self.pause_btn.grid(row=0, column=1, padx=4) self.reload_btn = ttk.Button(btns, text="Reload Vars", command=self.reload_variables) self.reload_btn.grid(row=0, column=2, padx=4) self.import_btn = ttk.Button(btns, text="Import List", command=self.import_list_dialog) self.import_btn.grid(row=0, column=3, padx=4) self.loadhtml_btn = ttk.Button(btns, text="Load HTML", command=self.load_html_dialog) self.loadhtml_btn.grid(row=0, column=4, padx=4) # Filter/Search search_frame = ttk.Frame(self, padding=(10, 0, 10, 10)) search_frame.pack(side=tk.TOP, fill=tk.X) ttk.Label(search_frame, text="Filter:").pack(side=tk.LEFT) self.filter_var = tk.StringVar(value="") self.filter_var.trace_add("write", lambda *_: self.refresh_tree()) ttk.Entry(search_frame, textvariable=self.filter_var, width=50).pack(side=tk.LEFT, padx=8) # Tree (control-board style with groups) columns = ("value", "updated", "status") self.tree = ttk.Treeview(self, columns=columns, show="tree headings", height=26) self.tree.pack(side=tk.TOP, fill=tk.BOTH, expand=True, padx=10, pady=10) self.tree.heading("#0", text="Variable") self.tree.heading("value", text="Value") self.tree.heading("updated", text="Updated") self.tree.heading("status", text="HTTP") self.tree.column("#0", width=450, anchor="w") self.tree.column("value", width=450, anchor="w") self.tree.column("updated", width=120, anchor="center") self.tree.column("status", width=60, anchor="center") # Style tags style = ttk.Style(self) try: style.theme_use("clam") except Exception: pass self.tree.tag_configure("ok", background="#eaffea") self.tree.tag_configure("bad", background="#ffecec") self.tree.tag_configure("neutral", background="#f5f7fb") # Footer footer = ttk.Frame(self, padding=10) footer.pack(side=tk.BOTTOM, fill=tk.X) self.cycle_lbl = ttk.Label(footer, text="Last cycle: —") self.cycle_lbl.pack(side=tk.LEFT) self.protocol("WM_DELETE_WINDOW", self.on_close) # Initial discovery self.after(200, self.reload_variables) # ------------- Variable discovery ------------- def reload_variables(self) -> None: host = self.host_var.get().strip() port = int(self.port_var.get()) base = build_base_url(host, port) discovered: List[str] = [] # Try JSON list and accumulate status, body, _ = http_get(base, {"variable": "WEBSERVER_LIST_VARIABLES_JSON"}) if status == 200: try: data = json.loads(body) if isinstance(data, list): for x in data: if isinstance(x, str): v = x.strip().upper() if v and v not in discovered: discovered.append(v) except Exception: pass # ignore malformed JSON # Also parse the human HTML and merge (covers cases where JSON is incomplete) 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: discovered.append(v) # If still empty, provide a small starter set if not discovered: discovered = [ "TIME", "TIME_STAMP", "CORE_TEMP", "CORE_PRESSURE", "CORE_STATE", "COOLANT_CORE_PRESSURE", "COOLANT_CORE_FLOW_IN", "COOLANT_CORE_FLOW_OUT", "POWER_FROM_TURBINE_KW", "POWER_DEMAND_MW", "CORE_IODINE_GENERATION" ] # Drop placeholders just in case discovered = [v for v in discovered if v not in {"VARNAME", "VARIABLE"}] self.variables_list = discovered self.vars.clear() for v in discovered: self.vars[v] = VarInfo(name=v) self.status_lbl.configure(text=f"Loaded {len(discovered)} vars") self.refresh_tree() def import_list_dialog(self) -> None: txt = simpledialog.askstring( "Import variable names", "Paste variable names separated by spaces, commas, or newlines:", parent=self, ) if not txt: return names = re.findall(r"[A-Za-z0-9_:-]+", txt.upper()) if not names: messagebox.showwarning("Nothing found", "No variable-like tokens found.") return uniq, seen = [], set() for n in names: if n not in seen: uniq.append(n); seen.add(n) self.variables_list = uniq self.vars.clear() for v in uniq: self.vars[v] = VarInfo(name=v) self.status_lbl.configure(text=f"Imported {len(uniq)} vars") self.refresh_tree() def load_html_dialog(self) -> None: """Load a local HTML index file and parse variables (handy for testing).""" path = filedialog.askopenfilename( title="Select HTML file", filetypes=[("HTML files", "*.html *.htm"), ("All files", "*.*")], ) if not path: return try: with open(path, "r", encoding="utf-8", errors="replace") as f: html = f.read() except Exception as e: messagebox.showerror("Read error", f"Could not read file:\n{e}") return cand = parse_variable_names_from_html_index(html) if not cand: messagebox.showwarning("No variables", "No variables recognized in that HTML.") return self.variables_list = cand self.vars.clear() for v in cand: self.vars[v] = VarInfo(name=v) self.status_lbl.configure(text=f"Loaded {len(cand)} from HTML") self.refresh_tree() # ------------- Poller control ------------- def start_polling(self) -> None: if self.poller and self.poller.is_alive(): messagebox.showinfo("Already running", "Polling is already active.") return if not self.variables_list: messagebox.showwarning("No variables", "Variable list is empty. Reload or import a list first.") return self.stop_event.clear() self.paused_event.clear() self.poller = Poller( host=self.host_var.get().strip(), port=int(self.port_var.get()), variables=self.variables_list, ui_queue=self.ui_queue, refresh_interval=int(self.refresh_var.get()), max_rps=float(self.rps_var.get()), stop_event=self.stop_event, paused_event=self.paused_event, ) self.poller.start() self.start_btn.configure(state="disabled") self.pause_btn.configure(state="normal", text="Pause") self.status_lbl.configure(text="Running…") self.after(100, self.drain_queue) def toggle_pause(self) -> None: if not (self.poller and self.poller.is_alive()): return if self.paused_event.is_set(): self.paused_event.clear() self.pause_btn.configure(text="Pause") self.status_lbl.configure(text="Running…") else: self.paused_event.set() self.pause_btn.configure(text="Resume") self.status_lbl.configure(text="Paused") def drain_queue(self) -> None: try: while True: item = self.ui_queue.get_nowait() kind = item[0] if kind == "update": _, name, value, status = item vi = self.vars.get(name) or VarInfo(name=name) vi.last_value = value vi.last_updated = time.time() vi.last_status = status vi.error = None self.vars[name] = vi elif kind == "error": _, name, status, msg = item vi = self.vars.get(name) or VarInfo(name=name) vi.last_status = status vi.error = msg vi.last_updated = time.time() self.vars[name] = vi elif kind == "cycle_start": _, hhmmss = item self.cycle_lbl.configure(text=f"Last cycle start: {hhmmss}") elif kind == "stopped": self.status_lbl.configure(text="Stopped") self.ui_queue.task_done() except queue.Empty: pass self.refresh_tree() if self.poller and self.poller.is_alive(): self.after(250, self.drain_queue) def stop_polling(self) -> None: if self.poller and self.poller.is_alive(): self.stop_event.set() for _ in range(20): if not self.poller.is_alive(): break time.sleep(0.05) self.start_btn.configure(state="normal") self.pause_btn.configure(state="disabled", text="Pause") # ------------- Tree rendering ------------- def refresh_tree(self) -> None: self.tree.delete(*self.tree.get_children()) groups: Dict[str, List[VarInfo]] = {} filt = self.filter_var.get().strip().upper() for name, vi in self.vars.items(): if filt and filt not in name.upper(): if vi.last_value and filt not in vi.last_value.upper(): continue prefix = name.split("_", 1)[0] if "_" in name else "MISC" groups.setdefault(prefix, []).append(vi) for g in sorted(groups.keys()): parent = self.tree.insert("", "end", text=g, values=("", "", ""), open=True) for vi in sorted(groups[g], key=lambda x: x.name): updated_str = "—" if vi.last_updated: updated_str = datetime.fromtimestamp(vi.last_updated).strftime("%H:%M:%S") if vi.error: value_preview = f"[ERR] {vi.error}" else: value_preview = coerce_preview(vi.last_value, 120) tag = infer_status_color(vi.last_value if not vi.error else "error") self.tree.insert( parent, "end", text=vi.name, values=(value_preview, updated_str, vi.last_status or "—"), tags=(tag,), ) # ------------- Shutdown ------------- def on_close(self) -> None: self.stop_polling() self.destroy() def main() -> None: app = App() app.mainloop() if __name__ == "__main__": main()