Files
vtt_work/nucleares_monitor/control_board_monitor.py
T
2025-10-20 18:24:38 +02:00

542 lines
20 KiB
Python

#!/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()