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