This commit is contained in:
2025-10-20 18:26:33 +02:00
parent c8a439c2f3
commit a73459ea87
+375 -251
View File
@@ -1,18 +1,22 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
Control Board Monitor — v0.6 Control Board Monitor — v0.7
Key fix: Fixes & upgrades:
- Discovery now uses **WEBSERVER_GET_JSON** and reads the `"GET:"` and `"POST:"` sections in that JSON. - ✅ Fixed AttributeError (missing run_func_once / add_schedule_dialog).
- NO usage of `WEBSERVER_LIST_FUNCTIONS_JSON` anymore. - ✅ Discovery now calls WEBSERVER_LIST_VARIABLES_JSON and parses "GET:" and "POST:" for variable/function names.
- Still parses the main page "/" as a fallback and then finally the embedded defaults (from your HTML). - ✅ 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=<FUNC>, value=<VAL>).
- Discovery gracefully falls back to parsing main page "/" and then embedded defaults.
""" """
import json import json
import math
import queue import queue
import re import re
import threading import threading
@@ -35,11 +39,8 @@ except Exception:
SERVER_HOST: str = "localhost" SERVER_HOST: str = "localhost"
SERVER_PORT: int = 8785 SERVER_PORT: int = 8785
REFRESH_INTERVAL_S: int = 60 REFRESH_INTERVAL_S: int = 60
MAX_RPS: float = 5.0
REQUEST_TIMEOUT_S: float = 5.0 REQUEST_TIMEOUT_S: float = 5.0
USER_AGENT: str = "ControlBoardMonitor/0.6 (+tkinter)" USER_AGENT: str = "ControlBoardMonitor/0.7 (+tkinter)"
CONNECT_TIMEOUT_S: float = 5.0
BACKOFF_BASE_S: float = 1.0 BACKOFF_BASE_S: float = 1.0
BACKOFF_MAX_S: float = 15.0 BACKOFF_MAX_S: float = 15.0
MAX_RETRIES: int = 2 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: except Exception as e:
return 0, str(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]]: def http_get_root(base_url: str) -> Tuple[int, str, Dict[str, str]]:
req = urllib.request.Request(base_url, headers={"User-Agent": USER_AGENT}) req = urllib.request.Request(base_url, headers={"User-Agent": USER_AGENT})
try: try:
@@ -132,58 +156,78 @@ def parse_function_names_from_html_index(html_text: str) -> List[str]:
placeholders = {"VARNAME", "VARIABLE", "NAME"} placeholders = {"VARNAME", "VARIABLE", "NAME"}
return [v for v in uniq if v not in placeholders] 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]: def extract_names(value_obj) -> List[str]:
"""Extract UPPERCASE names from JSON value that might be list/str/dict."""
out: List[str] = [] out: List[str] = []
def add(name): def add(n):
if not isinstance(name, str): return if isinstance(n, str):
name_u = name.strip().upper() u = n.strip().upper()
if name_u and name_u not in out: if u and u not in out:
out.append(name_u) out.append(u)
if isinstance(value_obj, list): if isinstance(value_obj, list):
for item in value_obj: for it in value_obj:
if isinstance(item, str): if isinstance(it, str):
add(item) add(it)
elif isinstance(item, dict): elif isinstance(it, dict):
# Try common keys for k in ("name","variable","var","id","func","function"):
for k in ("name","variable","var","id","func"): if k in it and isinstance(it[k], str):
if k in item and isinstance(item[k], str): add(it[k])
add(item[k]) for k in list(it.keys()):
if isinstance(k, str) and k.isupper():
add(k)
elif isinstance(value_obj, dict): elif isinstance(value_obj, dict):
# Could be {NAME: "...", NAME2: "..."}
for k in value_obj.keys(): for k in value_obj.keys():
add(k) if isinstance(k, str):
add(k)
elif isinstance(value_obj, str): elif isinstance(value_obj, str):
for m in re.findall(r"[A-Z0-9_]+", value_obj.upper()): for m in re.findall(r"[A-Z0-9_]+", value_obj.upper()):
add(m) add(m)
return out 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 # Data model
# ===================== # =====================
@@ -193,10 +237,12 @@ class Thresholds:
low: Optional[float] = None low: Optional[float] = None
high: Optional[float] = None high: Optional[float] = None
extreme_high: Optional[float] = None extreme_high: Optional[float] = None
# alarms
alarm_dead_low: bool = False alarm_dead_low: bool = False
alarm_low: bool = False alarm_low: bool = False
alarm_high: bool = False alarm_high: bool = False
alarm_extreme_high: bool = True alarm_extreme_high: bool = True
# function actions
action_dead_low: Optional[str] = None action_dead_low: Optional[str] = None
value_dead_low: str = "1" value_dead_low: str = "1"
action_low: Optional[str] = None action_low: Optional[str] = None
@@ -205,6 +251,11 @@ class Thresholds:
value_high: str = "1" value_high: str = "1"
action_extreme_high: Optional[str] = None action_extreme_high: Optional[str] = None
value_extreme_high: str = "1" 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 @dataclass
class VarInfo: class VarInfo:
@@ -266,15 +317,13 @@ class ActionScheduler(threading.Thread):
def run_once(self, name: str, value: str) -> Tuple[int, str, Dict[str, str]]: def run_once(self, name: str, value: str) -> Tuple[int, str, Dict[str, str]]:
base = self.get_base_url_cb() base = self.get_base_url_cb()
status, body, headers = http_get(base, {"variable": name, "value": value}) # Functions should be POST per your spec
# If server expects POST, switch to POST: change to http_post() if needed in your env. return http_post(base, {"variable": name, "value": value})
# status, body, headers = http_post(base, {"variable": name, "value": value})
return status, body, headers
def run(self) -> None: def run(self) -> None:
while not self._stop.is_set(): while not self._stop.is_set():
to_run: List[ActionTask] = []
now = time.time() now = time.time()
to_run: List[ActionTask] = []
with self._lock: with self._lock:
for t in self.tasks.values(): for t in self.tasks.values():
if t.enabled and t.next_run <= now: if t.enabled and t.next_run <= now:
@@ -298,7 +347,7 @@ class ActionScheduler(threading.Thread):
self._stop.set() self._stop.set()
# ===================== # =====================
# Poller thread # Poller thread (aggregated)
# ===================== # =====================
class Poller(threading.Thread): class Poller(threading.Thread):
def __init__( def __init__(
@@ -308,7 +357,6 @@ class Poller(threading.Thread):
variables: List[str], variables: List[str],
ui_queue: queue.Queue, ui_queue: queue.Queue,
refresh_interval: int, refresh_interval: int,
max_rps: float,
stop_event: threading.Event, stop_event: threading.Event,
paused_event: threading.Event, paused_event: threading.Event,
) -> None: ) -> None:
@@ -318,54 +366,63 @@ class Poller(threading.Thread):
self.variables = variables self.variables = variables
self.ui_queue = ui_queue self.ui_queue = ui_queue
self.refresh_interval = int(max(5, refresh_interval)) self.refresh_interval = int(max(5, refresh_interval))
self.max_rps = max(0.5, max_rps)
self.stop_event = stop_event self.stop_event = stop_event
self.paused_event = paused_event self.paused_event = paused_event
def run(self) -> None: def run(self) -> None:
base_url = build_base_url(self.host, self.port) base_url = build_base_url(self.host, self.port)
min_spacing = 1.0 / self.max_rps
while not self.stop_event.is_set(): while not self.stop_event.is_set():
cycle_start = time.time() cycle_start = time.time()
self.ui_queue.put(("cycle_start", datetime.now().strftime("%H:%M:%S"))) 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 # Try aggregated fetch
backoff = BACKOFF_BASE_S status, body, _ = http_get(base_url, {"variable": "WEBSERVER_LIST_VARIABLES_JSON"})
while attempt <= MAX_RETRIES and not self.stop_event.is_set(): if status == 200:
attempt += 1 try:
t0 = time.time() _get, _post, values_map = parse_weblist_json(body)
status, body, headers = http_get(base_url, {"variable": name}) if values_map:
if status == 200: # Update only selected variables
self.ui_queue.put(("update", name, body, status)) upper_map = {k.upper(): v for k,v in values_map.items()}
break for name in list(self.variables):
val = upper_map.get(name.upper())
if status in (429, 500, 502, 503, 504, 0): if val is not None:
self.ui_queue.put(("error", name, status, coerce_preview(body, 200))) self.ui_queue.put(("update", name, str(val), 200))
time.sleep(backoff) else:
backoff = min(BACKOFF_MAX_S, backoff * 2.0) self.ui_queue.put(("error", name, 206, "Not in bulk payload"))
else: else:
self.ui_queue.put(("error", name, status, coerce_preview(body, 200))) # No map -> maybe it's list-only; fallback per variable
break for name in list(self.variables):
st, b, _h = http_get(base_url, {"variable": name})
dt = time.time() - t0 if st == 200:
if dt < min_spacing: self.ui_queue.put(("update", name, b, st))
time.sleep(min_spacing - dt) else:
self.ui_queue.put(("error", name, st, coerce_preview(b, 200)))
dt = time.time() - t0 except Exception as e:
if dt < min_spacing: # Parsing error -> fallback to per-variable
time.sleep(min_spacing - dt) 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 cycle_dt = time.time() - cycle_start
remaining = self.refresh_interval - cycle_dt remaining = self.refresh_interval - cycle_dt
if remaining > 0: if remaining > 0:
for _ in range(int(remaining * 10)): for _ in range(int(remaining * 10)):
if self.stop_event.is_set(): if self.stop_event.is_set():
break break
while self.paused_event.is_set() and not self.stop_event.is_set():
time.sleep(0.1)
time.sleep(0.1) time.sleep(0.1)
self.ui_queue.put(("stopped", "")) self.ui_queue.put(("stopped", ""))
@@ -373,25 +430,11 @@ class Poller(threading.Thread):
# ===================== # =====================
# GUI # 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): class App(tk.Tk):
def __init__(self) -> None: def __init__(self) -> None:
super().__init__() super().__init__()
self.title("Control Board Monitor — v0.6") self.title("Control Board Monitor — v0.7")
self.geometry("1250x800") self.geometry("1250x820")
self.vars: Dict[str, VarInfo] = {} self.vars: Dict[str, VarInfo] = {}
self.stop_event = threading.Event() self.stop_event = threading.Event()
@@ -410,7 +453,6 @@ class App(tk.Tk):
self.host_var = tk.StringVar(value=SERVER_HOST) self.host_var = tk.StringVar(value=SERVER_HOST)
self.port_var = tk.IntVar(value=SERVER_PORT) self.port_var = tk.IntVar(value=SERVER_PORT)
self.refresh_var = tk.IntVar(value=REFRESH_INTERVAL_S) 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.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)) 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.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.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.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 = 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 = 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="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) 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) 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") self.tree.column("status", width=60, anchor="center")
style = ttk.Style(self) style = ttk.Style(self)
try: try: style.theme_use("clam")
style.theme_use("clam") except Exception: pass
except Exception:
pass
self.tree.tag_configure("ok", background="#eaffea") self.tree.tag_configure("ok", background="#eaffea")
self.tree.tag_configure("bad", background="#ffecec") self.tree.tag_configure("bad", background="#ffecec")
self.tree.tag_configure("neutral", background="#f5f7fb") self.tree.tag_configure("neutral", background="#f5f7fb")
self.tree_menu = tk.Menu(self, tearoff=False) 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("<Button-3>", self.on_tree_right_click) self.tree.bind("<Button-3>", self.on_tree_right_click)
bottom = ttk.Frame(main) 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="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) 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) 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.heading(col, text=hdr)
self.actions_tree.column(col, width=w, anchor="center") self.actions_tree.column(col, width=w, anchor="center")
@@ -522,16 +560,19 @@ class App(tk.Tk):
discovered_funcs: List[str] = [] discovered_funcs: List[str] = []
msgs = [] msgs = []
# 1) WEBSERVER_GET_JSON — source of truth for GET/POST # 1) WEBSERVER_LIST_VARIABLES_JSON — source of truth for GET/POST lists or values map
status, body, _ = http_get(base, {"variable": "WEBSERVER_LIST_FUNCTIONS_JSON"}) status, body, _ = http_get(base, {"variable": "WEBSERVER_LIST_VARIABLES_JSON"})
if status == 200 and body: if status == 200 and body:
v_get, v_post = parse_get_post_from_weblist_json(body) v_get, v_post, values_map = parse_weblist_json(body)
discovered_vars.extend(v_get) if v_get: discovered_vars.extend(v_get)
discovered_funcs.extend(v_post) 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: 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: if not discovered_vars or not discovered_funcs:
s2, b2, _2 = http_get_root(base) s2, b2, _2 = http_get_root(base)
if s2 == 200 and b2: if s2 == 200 and b2:
@@ -559,11 +600,11 @@ class App(tk.Tk):
if used_defaults: if used_defaults:
messagebox.showwarning( messagebox.showwarning(
"Discovery fallback", "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 "")) "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)") self.status_lbl.configure(text=f"Loaded defaults ({len(discovered_vars)} vars, {len(discovered_funcs)} funcs)")
else: 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 --- # --- Select Vars dialog ---
def open_selector(self) -> None: def open_selector(self) -> None:
@@ -614,8 +655,7 @@ class App(tk.Tk):
rows.append((name, var, cb)) rows.append((name, var, cb))
rebuild() rebuild()
def on_filter(*_): def on_filter(*_): rebuild()
rebuild()
filt_var.trace_add("write", on_filter) filt_var.trace_add("write", on_filter)
sel_all.configure(command=lambda: [v.set(True) for _, v, _ in rows]) sel_all.configure(command=lambda: [v.set(True) for _, v, _ in rows])
sel_none.configure(command=lambda: [v.set(False) 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="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) ttk.Button(bot, text="Cancel", command=dlg.destroy).pack(side=tk.RIGHT, padx=4)
# --- Thresholds/Alarms --- # --- Actions UI ---
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) ---
def on_actions_right_click(self, event): def on_actions_right_click(self, event):
try: try:
iid = self.actions_tree.identify_row(event.y) iid = self.actions_tree.identify_row(event.y)
@@ -746,6 +683,36 @@ class App(tk.Tk):
finally: finally:
self.actions_menu.tk_popup(event.x_root, event.y_root) 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): def toggle_selected_task(self):
sel = self.actions_tree.selection() sel = self.actions_tree.selection()
if not sel: return if not sel: return
@@ -764,7 +731,7 @@ class App(tk.Tk):
t = tasks.get(tid) t = tasks.get(tid)
if not t: return if not t: return
status, body, _ = self.scheduler.run_once(t.name, t.value) 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): def remove_selected_task(self):
sel = self.actions_tree.selection() sel = self.actions_tree.selection()
@@ -778,7 +745,7 @@ class App(tk.Tk):
for t in self.scheduler.list_tasks(): for t in self.scheduler.list_tasks():
mode = "Interval" if t.interval_s > 0 else "Once" 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")) 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 --- # --- Poller control / rendering ---
def start_polling(self) -> None: def start_polling(self) -> None:
@@ -796,7 +763,6 @@ class App(tk.Tk):
variables=self.variables_list, variables=self.variables_list,
ui_queue=self.ui_queue, ui_queue=self.ui_queue,
refresh_interval=int(self.refresh_var.get()), refresh_interval=int(self.refresh_var.get()),
max_rps=float(self.rps_var.get()),
stop_event=self.stop_event, stop_event=self.stop_event,
paused_event=self.paused_event, paused_event=self.paused_event,
) )
@@ -852,15 +818,129 @@ class App(tk.Tk):
if self.poller and self.poller.is_alive(): if self.poller and self.poller.is_alive():
self.after(250, self.drain_queue) self.after(250, self.drain_queue)
def stop_polling(self) -> None: # --- Threshold logic & rendering ---
if self.poller and self.poller.is_alive(): def on_tree_right_click(self, event):
self.stop_event.set() try:
for _ in range(20): iid = self.tree.identify_row(event.y)
if not self.poller.is_alive(): if iid:
break self.tree.selection_set(iid)
time.sleep(0.05) finally:
self.start_btn.configure(state="normal") self.tree_menu.tk_popup(event.x_root, event.y_root)
self.pause_btn.configure(state="disabled", text="Pause")
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: def evaluate_thresholds(self, name: str, value: str) -> None:
vi = self.vars.get(name) vi = self.vars.get(name)
@@ -881,11 +961,12 @@ class App(tk.Tk):
elif t.high is not None and x >= t.high: elif t.high is not None and x >= t.high:
new_state = "HIGH" new_state = "HIGH"
if new_state != vi.last_state: 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 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 t = vi.thresholds
# Alarms
alarm_map = { alarm_map = {
"DEAD_LOW": t.alarm_dead_low, "DEAD_LOW": t.alarm_dead_low,
"LOW": t.alarm_low, "LOW": t.alarm_low,
@@ -894,9 +975,31 @@ class App(tk.Tk):
} }
if alarm_map.get(state): if alarm_map.get(state):
try: 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: 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 = { act_map = {
"DEAD_LOW": (t.action_dead_low, t.value_dead_low), "DEAD_LOW": (t.action_dead_low, t.value_dead_low),
"LOW": (t.action_low, t.value_low), "LOW": (t.action_low, t.value_low),
@@ -908,6 +1011,7 @@ class App(tk.Tk):
# one-shot task # one-shot task
self.scheduler.add_task(ActionTask(name=act, value=val, interval_s=0)) self.scheduler.add_task(ActionTask(name=act, value=val, interval_s=0))
# --- Tree rendering ---
def refresh_tree(self) -> None: def refresh_tree(self) -> None:
self.tree.delete(*self.tree.get_children()) self.tree.delete(*self.tree.get_children())
groups: Dict[str, List[VarInfo]] = {} groups: Dict[str, List[VarInfo]] = {}
@@ -959,7 +1063,7 @@ class App(tk.Tk):
pass pass
self.destroy() self.destroy()
# --- Minor parsing utility --- # --- Utils ---
def parse_first_float(value: str) -> Optional[float]: def parse_first_float(value: str) -> Optional[float]:
m = re.search(r"[-+]?\d+(?:\.\d+)?", value.replace(",", ".")) m = re.search(r"[-+]?\d+(?:\.\d+)?", value.replace(",", "."))
if m: if m:
@@ -969,6 +1073,26 @@ def parse_first_float(value: str) -> Optional[float]:
return None return None
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, "<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, "<expr>", "eval")
return eval(code, safe_globals, safe_locals)
def main() -> None: def main() -> None:
app = App() app = App()
app.mainloop() app.mainloop()