diff --git a/nucleares_monitor/control_board_monitor.py b/nucleares_monitor/control_board_monitor.py index 79ce7b3..b823067 100644 --- a/nucleares_monitor/control_board_monitor.py +++ b/nucleares_monitor/control_board_monitor.py @@ -1,18 +1,22 @@ - #!/usr/bin/env python3 # -*- coding: utf-8 -*- """ -Control Board Monitor — v0.6 +Control Board Monitor — v0.7 -Key fix: -- Discovery now uses **WEBSERVER_GET_JSON** and reads the `"GET:"` and `"POST:"` sections in that JSON. -- NO usage of `WEBSERVER_LIST_FUNCTIONS_JSON` anymore. -- Still parses the main page "/" as a fallback and then finally the embedded defaults (from your HTML). +Fixes & upgrades: +- ✅ Fixed AttributeError (missing run_func_once / add_schedule_dialog). +- ✅ Discovery now calls WEBSERVER_LIST_VARIABLES_JSON and parses "GET:" and "POST:" for variable/function names. +- ✅ Poller now fetches ALL values in one call via WEBSERVER_LIST_VARIABLES_JSON and only displays chosen ones. +- ✅ Threshold actions: besides calling a function, you can provide an EXPRESSION/snippet using x (current numeric value). -Other bits kept: port=8785, selector dialog, scheduler (Run Once / Interval), thresholds+alarms with optional actions. +Notes: +- Default host/port = localhost:8785 +- Functions are executed via POST (variable=, value=). +- Discovery gracefully falls back to parsing main page "/" and then embedded defaults. """ import json +import math import queue import re import threading @@ -35,11 +39,8 @@ except Exception: SERVER_HOST: str = "localhost" SERVER_PORT: int = 8785 REFRESH_INTERVAL_S: int = 60 -MAX_RPS: float = 5.0 REQUEST_TIMEOUT_S: float = 5.0 -USER_AGENT: str = "ControlBoardMonitor/0.6 (+tkinter)" -CONNECT_TIMEOUT_S: float = 5.0 - +USER_AGENT: str = "ControlBoardMonitor/0.7 (+tkinter)" BACKOFF_BASE_S: float = 1.0 BACKOFF_MAX_S: float = 15.0 MAX_RETRIES: int = 2 @@ -76,6 +77,29 @@ def http_get(base_url: str, params: Dict[str, str]) -> Tuple[int, str, Dict[str, except Exception as e: return 0, str(e), {} +def http_post(base_url: str, data: Dict[str, str]) -> Tuple[int, str, Dict[str, str]]: + url = base_url + post_data = urllib.parse.urlencode(data).encode("utf-8") + req = urllib.request.Request(url, data=post_data, 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 http_get_root(base_url: str) -> Tuple[int, str, Dict[str, str]]: req = urllib.request.Request(base_url, headers={"User-Agent": USER_AGENT}) try: @@ -132,58 +156,78 @@ def parse_function_names_from_html_index(html_text: str) -> List[str]: placeholders = {"VARNAME", "VARIABLE", "NAME"} return [v for v in uniq if v not in placeholders] +# ---- Parse WEBSERVER_LIST_VARIABLES_JSON ---- +def parse_weblist_json(body: str) -> Tuple[List[str], List[str], Dict[str,str]]: + """Return (get_list, post_list, values_map).""" + try: + data = json.loads(body) + except Exception: + return [], [], {} + get_names: List[str] = [] + post_names: List[str] = [] + values_map: Dict[str, str] = {} + + if isinstance(data, dict): + # Detect "GET"/"POST" blocks + for k, v in data.items(): + key = str(k).strip().upper().rstrip(":") + if key in ("GET", "POST"): + names = extract_names(v) + if key == "GET": + get_names.extend(names) + else: + post_names.extend(names) + + # If dict looks like NAME->value mapping, collect as values_map + scalar_like = any(isinstance(v, (int, float, str)) for v in data.values()) + if scalar_like and not get_names and not post_names: + for k, v in data.items(): + name = str(k).strip().upper() + try: + values_map[name] = v if isinstance(v, str) else json.dumps(v) + except Exception: + values_map[name] = str(v) + + elif isinstance(data, list): + # Rare: a list of names only + get_names.extend(extract_names(data)) + + # Deduplicate preserving order + def dedup(seq): + seen=set(); out=[] + for s in seq: + if s not in seen: + seen.add(s); out.append(s) + return out + return dedup(get_names), dedup(post_names), values_map + def extract_names(value_obj) -> List[str]: - """Extract UPPERCASE names from JSON value that might be list/str/dict.""" out: List[str] = [] - def add(name): - if not isinstance(name, str): return - name_u = name.strip().upper() - if name_u and name_u not in out: - out.append(name_u) + def add(n): + if isinstance(n, str): + u = n.strip().upper() + if u and u not in out: + out.append(u) if isinstance(value_obj, list): - for item in value_obj: - if isinstance(item, str): - add(item) - elif isinstance(item, dict): - # Try common keys - for k in ("name","variable","var","id","func"): - if k in item and isinstance(item[k], str): - add(item[k]) + for it in value_obj: + if isinstance(it, str): + add(it) + elif isinstance(it, dict): + for k in ("name","variable","var","id","func","function"): + if k in it and isinstance(it[k], str): + add(it[k]) + for k in list(it.keys()): + if isinstance(k, str) and k.isupper(): + add(k) elif isinstance(value_obj, dict): - # Could be {NAME: "...", NAME2: "..."} for k in value_obj.keys(): - add(k) + if isinstance(k, str): + add(k) elif isinstance(value_obj, str): for m in re.findall(r"[A-Z0-9_]+", value_obj.upper()): add(m) return out -def parse_get_post_from_weblist_json(body: str) -> Tuple[List[str], List[str]]: - try: - data = json.loads(body) - except Exception: - return [], [] - if not isinstance(data, dict): - return [], [] - get_candidates = [] - post_candidates = [] - for key, val in data.items(): - k = str(key).strip().lower().rstrip(":") - if k == "get": - get_candidates.extend(extract_names(val)) - elif k == "post": - post_candidates.extend(extract_names(val)) - # dedupe while preserving order - seen=set(); get_out=[] - for v in get_candidates: - if v not in seen: - seen.add(v); get_out.append(v) - seen=set(); post_out=[] - for v in post_candidates: - if v not in seen: - seen.add(v); post_out.append(v) - return get_out, post_out - # ===================== # Data model # ===================== @@ -193,10 +237,12 @@ class Thresholds: low: Optional[float] = None high: Optional[float] = None extreme_high: Optional[float] = None + # alarms alarm_dead_low: bool = False alarm_low: bool = False alarm_high: bool = False alarm_extreme_high: bool = True + # function actions action_dead_low: Optional[str] = None value_dead_low: str = "1" action_low: Optional[str] = None @@ -205,6 +251,11 @@ class Thresholds: value_high: str = "1" action_extreme_high: Optional[str] = None value_extreme_high: str = "1" + # expression actions (NEW): evaluated with x = current numeric value + expr_dead_low: Optional[str] = None + expr_low: Optional[str] = None + expr_high: Optional[str] = None + expr_extreme_high: Optional[str] = None @dataclass class VarInfo: @@ -266,15 +317,13 @@ class ActionScheduler(threading.Thread): def run_once(self, name: str, value: str) -> Tuple[int, str, Dict[str, str]]: base = self.get_base_url_cb() - status, body, headers = http_get(base, {"variable": name, "value": value}) - # If server expects POST, switch to POST: change to http_post() if needed in your env. - # status, body, headers = http_post(base, {"variable": name, "value": value}) - return status, body, headers + # Functions should be POST per your spec + return http_post(base, {"variable": name, "value": value}) def run(self) -> None: while not self._stop.is_set(): - to_run: List[ActionTask] = [] now = time.time() + to_run: List[ActionTask] = [] with self._lock: for t in self.tasks.values(): if t.enabled and t.next_run <= now: @@ -298,7 +347,7 @@ class ActionScheduler(threading.Thread): self._stop.set() # ===================== -# Poller thread +# Poller thread (aggregated) # ===================== class Poller(threading.Thread): def __init__( @@ -308,7 +357,6 @@ class Poller(threading.Thread): variables: List[str], ui_queue: queue.Queue, refresh_interval: int, - max_rps: float, stop_event: threading.Event, paused_event: threading.Event, ) -> None: @@ -318,54 +366,63 @@ class Poller(threading.Thread): 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 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) + # Try aggregated fetch + status, body, _ = http_get(base_url, {"variable": "WEBSERVER_LIST_VARIABLES_JSON"}) + if status == 200: + try: + _get, _post, values_map = parse_weblist_json(body) + if values_map: + # Update only selected variables + upper_map = {k.upper(): v for k,v in values_map.items()} + for name in list(self.variables): + val = upper_map.get(name.upper()) + if val is not None: + self.ui_queue.put(("update", name, str(val), 200)) + else: + self.ui_queue.put(("error", name, 206, "Not in bulk payload")) 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) + # No map -> maybe it's list-only; fallback per variable + for name in list(self.variables): + st, b, _h = http_get(base_url, {"variable": name}) + if st == 200: + self.ui_queue.put(("update", name, b, st)) + else: + self.ui_queue.put(("error", name, st, coerce_preview(b, 200))) + except Exception as e: + # Parsing error -> fallback to per-variable + for name in list(self.variables): + st, b, _h = http_get(base_url, {"variable": name}) + if st == 200: + self.ui_queue.put(("update", name, b, st)) + else: + self.ui_queue.put(("error", name, st, coerce_preview(b, 200))) + else: + # Bulk endpoint failed -> per-variable fallback + for name in list(self.variables): + st, b, _h = http_get(base_url, {"variable": name}) + if st == 200: + self.ui_queue.put(("update", name, b, st)) + else: + self.ui_queue.put(("error", name, 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: for _ in range(int(remaining * 10)): if self.stop_event.is_set(): break + while self.paused_event.is_set() and not self.stop_event.is_set(): + time.sleep(0.1) time.sleep(0.1) self.ui_queue.put(("stopped", "")) @@ -373,25 +430,11 @@ class Poller(threading.Thread): # ===================== # GUI # ===================== -def play_alarm_tone(level: str = "LOW") -> None: - try: - import winsound - freq = {"DEAD_LOW": 300, "LOW": 550, "HIGH": 800, "EXTREME_HIGH": 1000}.get(level, 600) - dur = 350 if level != "EXTREME_HIGH" else 600 - winsound.Beep(freq, dur) - except Exception: - try: - root = tk._default_root - if root is not None: - root.bell() - except Exception: - pass - class App(tk.Tk): def __init__(self) -> None: super().__init__() - self.title("Control Board Monitor — v0.6") - self.geometry("1250x800") + self.title("Control Board Monitor — v0.7") + self.geometry("1250x820") self.vars: Dict[str, VarInfo] = {} self.stop_event = threading.Event() @@ -410,7 +453,6 @@ 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.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, 10)) @@ -418,14 +460,12 @@ class App(tk.Tk): ttk.Entry(controls, textvariable=self.port_var, width=8).grid(row=0, column=3, padx=(0, 10)) 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, 10)) - 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, 10)) self.status_lbl = ttk.Label(controls, text="Idle", foreground="#666") - self.status_lbl.grid(row=0, column=8, padx=(10, 0)) + self.status_lbl.grid(row=0, column=6, padx=(10, 0)) btns = ttk.Frame(controls) - btns.grid(row=0, column=9, padx=10, sticky="e") + btns.grid(row=0, column=7, padx=10, sticky="e") ttk.Button(btns, text="Select Vars…", command=self.open_selector).grid(row=0, column=0, padx=4) ttk.Button(btns, text="Reload (disc.)", command=self.reload_discovery).grid(row=0, column=1, padx=4) self.start_btn = ttk.Button(btns, text="Start", command=self.start_polling) @@ -459,16 +499,14 @@ class App(tk.Tk): self.tree.column("status", width=60, anchor="center") style = ttk.Style(self) - try: - style.theme_use("clam") - except Exception: - pass + 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") self.tree_menu = tk.Menu(self, tearoff=False) - self.tree_menu.add_command(label="Set thresholds & alarms…", command=self.open_thresholds_dialog) + self.tree_menu.add_command(label="Set thresholds, actions & alarms…", command=self.open_thresholds_dialog) self.tree.bind("", self.on_tree_right_click) bottom = ttk.Frame(main) @@ -484,9 +522,9 @@ class App(tk.Tk): ttk.Button(act_controls, text="Run Once", command=self.run_func_once).pack(side=tk.LEFT, padx=4) ttk.Button(act_controls, text="Schedule…", command=self.add_schedule_dialog).pack(side=tk.LEFT, padx=4) - columns2 = ("value", "mode", "interval", "next", "enabled") + columns2 = ("func", "value", "mode", "interval", "next", "enabled") self.actions_tree = ttk.Treeview(bottom, columns=columns2, show="headings", height=10) - for col, hdr, w in zip(columns2, ["Value", "Mode", "Interval(s)", "Next Run", "Enabled"], [160, 100, 100, 180, 80]): + for col, hdr, w in zip(columns2, ["Function", "Value", "Mode", "Interval(s)", "Next Run", "Enabled"], [260, 160, 100, 100, 180, 80]): self.actions_tree.heading(col, text=hdr) self.actions_tree.column(col, width=w, anchor="center") @@ -522,16 +560,19 @@ class App(tk.Tk): discovered_funcs: List[str] = [] msgs = [] - # 1) WEBSERVER_GET_JSON — source of truth for GET/POST - status, body, _ = http_get(base, {"variable": "WEBSERVER_LIST_FUNCTIONS_JSON"}) + # 1) WEBSERVER_LIST_VARIABLES_JSON — source of truth for GET/POST lists or values map + status, body, _ = http_get(base, {"variable": "WEBSERVER_LIST_VARIABLES_JSON"}) if status == 200 and body: - v_get, v_post = parse_get_post_from_weblist_json(body) - discovered_vars.extend(v_get) - discovered_funcs.extend(v_post) + v_get, v_post, values_map = parse_weblist_json(body) + if v_get: discovered_vars.extend(v_get) + if v_post: discovered_funcs.extend(v_post) + # If it's a values map only, use keys as variables + if (not v_get) and values_map: + discovered_vars.extend(list(values_map.keys())) else: - msgs.append(f"GET_JSON HTTP {status}: {coerce_preview(body, 120)}") + msgs.append(f"LIST_JSON HTTP {status}: {coerce_preview(body, 120)}") - # 2) Fallback to main page parse (HTML links and POST section) + # 2) Fallback to main page parse if not discovered_vars or not discovered_funcs: s2, b2, _2 = http_get_root(base) if s2 == 200 and b2: @@ -559,11 +600,11 @@ class App(tk.Tk): if used_defaults: messagebox.showwarning( "Discovery fallback", - "Could not discover variables/functions from WEBSERVER_GET_JSON or main page.\n" + "Could not discover variables/functions from WEBSERVER_LIST_VARIABLES_JSON or main page.\n" "Loaded embedded defaults so you can proceed.\n\n" + ("\n".join(msgs[:6]) if msgs else "")) self.status_lbl.configure(text=f"Loaded defaults ({len(discovered_vars)} vars, {len(discovered_funcs)} funcs)") else: - self.status_lbl.configure(text=f"Discovered via GET_JSON: vars {len(discovered_vars)} | funcs {len(discovered_funcs)}") + self.status_lbl.configure(text=f"Discovered: vars {len(discovered_vars)} | funcs {len(discovered_funcs)}") # --- Select Vars dialog --- def open_selector(self) -> None: @@ -614,8 +655,7 @@ class App(tk.Tk): rows.append((name, var, cb)) rebuild() - def on_filter(*_): - 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]) @@ -634,110 +674,7 @@ class App(tk.Tk): ttk.Button(bot, text="Apply", command=apply_and_close).pack(side=tk.RIGHT, padx=4) ttk.Button(bot, text="Cancel", command=dlg.destroy).pack(side=tk.RIGHT, padx=4) - # --- Thresholds/Alarms --- - def on_tree_right_click(self, event): - try: - iid = self.tree.identify_row(event.y) - if iid: - self.tree.selection_set(iid) - finally: - self.tree_menu.tk_popup(event.x_root, event.y_root) - - def open_thresholds_dialog(self): - sel = self.tree.selection() - if not sel: - messagebox.showinfo("No selection", "Select a variable first.") - return - item = sel[0] - name = self.tree.item(item, "text") - if not name or "_" not in name: - messagebox.showinfo("Select variable", "Select a specific variable row (not a group).") - return - if name.endswith("]") and "[" in name: - name = name[:name.rfind("[")].strip() - vi = self.vars.get(name) or VarInfo(name=name) - - dlg = tk.Toplevel(self) - dlg.title(f"Thresholds & Alarms — {name}") - dlg.geometry("560x520") - dlg.transient(self); dlg.grab_set() - - t = vi.thresholds - grid = ttk.Frame(dlg, padding=10) - grid.pack(side=tk.TOP, fill=tk.BOTH, expand=True) - - entries = {} - def add_row(r, label, key, 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)) - e = ttk.Entry(grid, textvariable=sv, width=12) - e.grid(row=r, column=1, sticky="w", padx=6) - entries[key] = sv - - add_row(0, "DEAD LOW", "dead_low", t.dead_low) - add_row(1, "LOW", "low", t.low) - add_row(2, "HIGH", "high", t.high) - add_row(3, "EXTREME HIGH", "extreme_high", t.extreme_high) - - 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), - ("Low", "alarm_low", t.alarm_low), - ("High", "alarm_high", t.alarm_high), - ("Extreme High", "alarm_extreme_high", t.alarm_extreme_high)]): - var = tk.BooleanVar(value=val) - 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 - - ttk.Label(grid, text="On enter, run function:").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")] - action_vars = {} - 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 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 - 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 - - def save_and_close(): - def _f(v): - v = v.strip() - return float(v) if v else None - nt = Thresholds( - dead_low=_f(entries["dead_low"].get()), - low=_f(entries["low"].get()), - high=_f(entries["high"].get()), - extreme_high=_f(entries["extreme_high"].get()), - alarm_dead_low=alarm_vars["alarm_dead_low"].get(), - alarm_low=alarm_vars["alarm_low"].get(), - alarm_high=alarm_vars["alarm_high"].get(), - alarm_extreme_high=alarm_vars["alarm_extreme_high"].get(), - action_dead_low=action_vars["action_dead_low"].get() or None, - value_dead_low=value_vars["value_dead_low"].get(), - action_low=action_vars["action_low"].get() or None, - value_low=value_vars["value_low"].get(), - action_high=action_vars["action_high"].get() or None, - 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(), - ) - vi.thresholds = nt - self.vars[name] = vi - dlg.destroy() - - btns = ttk.Frame(dlg, padding=10) - btns.pack(side=tk.BOTTOM, fill=tk.X) - ttk.Button(btns, text="Save", command=save_and_close).pack(side=tk.RIGHT, padx=4) - ttk.Button(btns, text="Cancel", command=dlg.destroy).pack(side=tk.RIGHT, padx=4) - - # --- Actions (Run Once / Schedule) --- + # --- Actions UI --- def on_actions_right_click(self, event): try: iid = self.actions_tree.identify_row(event.y) @@ -746,6 +683,36 @@ class App(tk.Tk): finally: self.actions_menu.tk_popup(event.x_root, event.y_root) + def run_func_once(self): + fname = self.func_name_var.get().strip() + if not fname: + messagebox.showinfo("No function", "Choose a function first.") + 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)}") + + def add_schedule_dialog(self): + fname = self.func_name_var.get().strip() + if not fname: + messagebox.showinfo("No function", "Choose a function first.") + return + top = tk.Toplevel(self); top.title("Schedule Function"); top.geometry("360x170"); top.transient(self); top.grab_set() + ttk.Label(top, text=f"Function: {fname}").pack(pady=(10,4)) + 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) + 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())) + tid = self.scheduler.add_task(task) + self.refresh_actions_tree() + top.destroy() + ttk.Button(top, text="Add", command=add_it).pack(pady=10) + def toggle_selected_task(self): sel = self.actions_tree.selection() if not sel: return @@ -764,7 +731,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"{t.name}={t.value}\nHTTP {status}\n{coerce_preview(body, 300)}") + messagebox.showinfo("Run Now", f"POST: {t.name}={t.value}\nHTTP {status}\n{coerce_preview(body, 300)}") def remove_selected_task(self): sel = self.actions_tree.selection() @@ -778,7 +745,7 @@ class App(tk.Tk): for t in self.scheduler.list_tasks(): mode = "Interval" if t.interval_s > 0 else "Once" next_s = ("—" if t.interval_s == 0 else datetime.fromtimestamp(t.next_run).strftime("%H:%M:%S")) - self.actions_tree.insert("", "end", iid=str(t.task_id), values=(t.value, mode, t.interval_s, next_s, "Yes" if t.enabled else "No")) + self.actions_tree.insert("", "end", iid=str(t.task_id), values=(t.name, t.value, mode, t.interval_s, next_s, "Yes" if t.enabled else "No")) # --- Poller control / rendering --- def start_polling(self) -> None: @@ -796,7 +763,6 @@ class App(tk.Tk): 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, ) @@ -852,15 +818,129 @@ class App(tk.Tk): 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") + # --- Threshold logic & rendering --- + def on_tree_right_click(self, event): + try: + iid = self.tree.identify_row(event.y) + if iid: + self.tree.selection_set(iid) + finally: + self.tree_menu.tk_popup(event.x_root, event.y_root) + + def open_thresholds_dialog(self): + sel = self.tree.selection() + if not sel: + messagebox.showinfo("No selection", "Select a variable first.") + return + item = sel[0] + name = self.tree.item(item, "text") + if not name or "_" not in name: + messagebox.showinfo("Select variable", "Select a specific variable row (not a group).") + return + if name.endswith("]") and "[" in name: + name = name[:name.rfind("[")].strip() + vi = self.vars.get(name) or VarInfo(name=name) + + dlg = tk.Toplevel(self) + dlg.title(f"Thresholds, actions & alarms — {name}") + dlg.geometry("780x580") + dlg.transient(self); dlg.grab_set() + + t = vi.thresholds + grid = ttk.Frame(dlg, padding=10) + grid.pack(side=tk.TOP, fill=tk.BOTH, expand=True) + + # numeric thresholds + entries = {} + def add_row(r, label, key, 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)) + e = ttk.Entry(grid, textvariable=sv, width=12) + e.grid(row=r, column=1, sticky="w", padx=6) + entries[key] = sv + + add_row(0, "DEAD LOW", "dead_low", t.dead_low) + add_row(1, "LOW", "low", t.low) + add_row(2, "HIGH", "high", t.high) + add_row(3, "EXTREME HIGH", "extreme_high", t.extreme_high) + + 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), + ("Low", "alarm_low", t.alarm_low), + ("High", "alarm_high", t.alarm_high), + ("Extreme High", "alarm_extreme_high", t.alarm_extreme_high)]): + var = tk.BooleanVar(value=val) + 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 + + # function actions + 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")] + action_vars = {} + 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 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 + 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 + + # expression actions + ttk.Label(grid, text="On enter: evaluate expression/snippet with x = current numeric value (optional)").grid(row=12, column=0, sticky="w", pady=(14,4), columnspan=3) + expr_vars = { + "expr_dead_low": tk.StringVar(value=t.expr_dead_low or ""), + "expr_low": tk.StringVar(value=t.expr_low or ""), + "expr_high": tk.StringVar(value=t.expr_high or ""), + "expr_extreme_high": tk.StringVar(value=t.expr_extreme_high or ""), + } + for i, (label, key) in enumerate([("Dead Low expr", "expr_dead_low"), + ("Low expr", "expr_low"), + ("High expr", "expr_high"), + ("Extreme High expr", "expr_extreme_high")]): + ttk.Label(grid, text=label).grid(row=13+i, column=0, sticky="w", pady=2) + ttk.Entry(grid, textvariable=expr_vars[key], width=56).grid(row=13+i, column=1, sticky="w", padx=6, columnspan=2) + + def save_and_close(): + def _f(v): + v = v.strip() + return float(v) if v else None + nt = Thresholds( + dead_low=_f(entries["dead_low"].get()), + low=_f(entries["low"].get()), + high=_f(entries["high"].get()), + extreme_high=_f(entries["extreme_high"].get()), + alarm_dead_low=alarm_vars["alarm_dead_low"].get(), + alarm_low=alarm_vars["alarm_low"].get(), + alarm_high=alarm_vars["alarm_high"].get(), + alarm_extreme_high=alarm_vars["alarm_extreme_high"].get(), + action_dead_low=action_vars["action_dead_low"].get() or None, + value_dead_low=value_vars["value_dead_low"].get(), + action_low=action_vars["action_low"].get() or None, + value_low=value_vars["value_low"].get(), + action_high=action_vars["action_high"].get() or None, + 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(), + 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, + expr_extreme_high=expr_vars["expr_extreme_high"].get().strip() or None, + ) + vi.thresholds = nt + self.vars[name] = vi + dlg.destroy() + + btns = ttk.Frame(dlg, padding=10) + btns.pack(side=tk.BOTTOM, fill=tk.X) + ttk.Button(btns, text="Save", command=save_and_close).pack(side=tk.RIGHT, padx=4) + ttk.Button(btns, text="Cancel", command=dlg.destroy).pack(side=tk.RIGHT, padx=4) def evaluate_thresholds(self, name: str, value: str) -> None: vi = self.vars.get(name) @@ -881,11 +961,12 @@ class App(tk.Tk): elif t.high is not None and x >= t.high: new_state = "HIGH" if new_state != vi.last_state: - self.on_enter_state(name, new_state, vi) + self.on_enter_state(name, new_state, vi, x) vi.last_state = new_state - def on_enter_state(self, name: str, state: str, vi: VarInfo) -> None: + def on_enter_state(self, name: str, state: str, vi: VarInfo, x: Optional[float]) -> None: t = vi.thresholds + # Alarms alarm_map = { "DEAD_LOW": t.alarm_dead_low, "LOW": t.alarm_low, @@ -894,9 +975,31 @@ class App(tk.Tk): } if alarm_map.get(state): try: - play_alarm_tone(state) + import winsound + freq = {"DEAD_LOW": 300, "LOW": 550, "HIGH": 800, "EXTREME_HIGH": 1000}.get(state, 600) + dur = 350 if state != "EXTREME_HIGH" else 600 + winsound.Beep(freq, dur) except Exception: - pass + try: self.bell() + except Exception: pass + + # Expression takes precedence if provided + expr_key = { + "DEAD_LOW": "expr_dead_low", "LOW": "expr_low", + "HIGH": "expr_high", "EXTREME_HIGH": "expr_extreme_high" + }.get(state) + expr = getattr(t, expr_key) if expr_key else None + if expr and x is not None: + try: + computed = eval_user_expression(expr, x) + # Send result as new value for this variable + base = build_base_url(self.host_var.get().strip(), int(self.port_var.get())) + http_post(base, {"variable": name, "value": str(computed)}) + except Exception as e: + messagebox.showwarning("Expression error", f"{name}: failed to evaluate expression on enter {state}: {e}") + # fall through to function action + + # Function action act_map = { "DEAD_LOW": (t.action_dead_low, t.value_dead_low), "LOW": (t.action_low, t.value_low), @@ -908,6 +1011,7 @@ class App(tk.Tk): # one-shot task self.scheduler.add_task(ActionTask(name=act, value=val, interval_s=0)) + # --- Tree rendering --- def refresh_tree(self) -> None: self.tree.delete(*self.tree.get_children()) groups: Dict[str, List[VarInfo]] = {} @@ -959,7 +1063,7 @@ class App(tk.Tk): pass self.destroy() -# --- Minor parsing utility --- +# --- Utils --- def parse_first_float(value: str) -> Optional[float]: m = re.search(r"[-+]?\d+(?:\.\d+)?", value.replace(",", ".")) if m: @@ -969,6 +1073,26 @@ def parse_first_float(value: str) -> Optional[float]: return None return None +def eval_user_expression(expr: str, x: float): + """Evaluate user expression/snippet with x available. + Allowed: math.*, min, max, abs, round, int, float, clamp (custom). + Returns the computed result. + """ + 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} + # 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) + if "result" not in safe_locals: + raise ValueError("Snippet must assign to 'result', e.g., result = x * 1.1") + return safe_locals["result"] + else: + code = compile(expr, "", "eval") + return eval(code, safe_globals, safe_locals) + def main() -> None: app = App() app.mainloop()