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
|
#!/usr/bin/env python3
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""
|
"""
|
||||||
Control Board Monitor - v0.3 (proposal build)
|
Control Board Monitor - v0.4
|
||||||
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.
|
|
||||||
|
|
||||||
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
|
import json
|
||||||
@@ -41,7 +30,6 @@ except Exception as e:
|
|||||||
|
|
||||||
# ======= Windows beep support =======
|
# ======= Windows beep support =======
|
||||||
def play_alarm_tone(level: str = "LOW") -> None:
|
def play_alarm_tone(level: str = "LOW") -> None:
|
||||||
"""Best-effort alarm sound; uses winsound on Windows or tk.bell() otherwise."""
|
|
||||||
try:
|
try:
|
||||||
import winsound # type: ignore
|
import winsound # type: ignore
|
||||||
freq = {"DEAD_LOW": 300, "LOW": 550, "HIGH": 800, "EXTREME_HIGH": 1000}.get(level, 600)
|
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)
|
winsound.Beep(freq, dur)
|
||||||
except Exception:
|
except Exception:
|
||||||
try:
|
try:
|
||||||
# Fallback to Tk bell if available (non-blocking/chip)
|
|
||||||
root = tk._default_root
|
root = tk._default_root
|
||||||
if root is not None:
|
if root is not None:
|
||||||
root.bell()
|
root.bell()
|
||||||
@@ -61,17 +48,20 @@ def play_alarm_tone(level: str = "LOW") -> None:
|
|||||||
# Global configuration
|
# Global configuration
|
||||||
# =====================
|
# =====================
|
||||||
SERVER_HOST: str = "localhost"
|
SERVER_HOST: str = "localhost"
|
||||||
SERVER_PORT: int = 8785
|
SERVER_PORT: int = 8785 # changed as requested
|
||||||
REFRESH_INTERVAL_S: int = 1
|
REFRESH_INTERVAL_S: int = 60
|
||||||
MAX_RPS: float = 5.0
|
MAX_RPS: float = 5.0
|
||||||
REQUEST_TIMEOUT_S: 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
|
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
|
||||||
|
|
||||||
|
# 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
|
# 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]:
|
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
|
import urllib.parse
|
||||||
tokens: List[str] = []
|
tokens: List[str] = []
|
||||||
|
|
||||||
for m in re.finditer(r'href\s*=\s*["\']([^"\']+)["\']', html_text, flags=re.I):
|
for m in re.finditer(r'href\s*=\s*["\']([^"\']+)["\']', html_text, flags=re.I):
|
||||||
href = m.group(1)
|
href = m.group(1)
|
||||||
parsed = urllib.parse.urlparse(href)
|
parsed = urllib.parse.urlparse(href)
|
||||||
qs = urllib.parse.parse_qs(parsed.query)
|
qs = urllib.parse.parse_qs(parsed.query)
|
||||||
for v in qs.get("variable", []):
|
for v in qs.get("variable", []):
|
||||||
tokens.append(v)
|
tokens.append(v)
|
||||||
|
|
||||||
for m in re.finditer(r'variable\s*=\s*["\']?([A-Za-z0-9_.:-]+)', html_text, flags=re.I):
|
for m in re.finditer(r'variable\s*=\s*["\']?([A-Za-z0-9_.:-]+)', html_text, flags=re.I):
|
||||||
tokens.append(m.group(1))
|
tokens.append(m.group(1))
|
||||||
|
|
||||||
seen = set(); uniq: List[str] = []
|
seen = set(); uniq: List[str] = []
|
||||||
for v in tokens:
|
for v in tokens:
|
||||||
vv = v.strip().strip(".,;)]}").strip().upper()
|
vv = v.strip().strip(".,;)]}").strip().upper()
|
||||||
if vv and vv not in seen:
|
if vv and vv not in seen:
|
||||||
seen.add(vv); uniq.append(vv)
|
seen.add(vv); uniq.append(vv)
|
||||||
|
|
||||||
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]
|
||||||
|
|
||||||
|
|
||||||
def parse_function_names_from_html_index(html_text: str) -> List[str]:
|
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 ====")
|
start = html_text.find("==== POST ====")
|
||||||
if start == -1:
|
if start == -1:
|
||||||
return []
|
return []
|
||||||
@@ -206,7 +190,6 @@ class Thresholds:
|
|||||||
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
|
||||||
# Optional function triggers on entering each state
|
|
||||||
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
|
||||||
@@ -225,18 +208,18 @@ class VarInfo:
|
|||||||
last_status: int = 0
|
last_status: int = 0
|
||||||
error: Optional[str] = None
|
error: Optional[str] = None
|
||||||
thresholds: Thresholds = field(default_factory=Thresholds)
|
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
|
@dataclass
|
||||||
class ActionTask:
|
class ActionTask:
|
||||||
name: str # function/variable name to POST
|
name: str
|
||||||
value: str # value to send
|
value: str
|
||||||
interval_s: int # 0 for one-shot
|
interval_s: int
|
||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
last_run: float = 0.0
|
last_run: float = 0.0
|
||||||
next_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():
|
for t in self.tasks.values():
|
||||||
if t.enabled and t.next_run <= now:
|
if t.enabled and t.next_run <= now:
|
||||||
to_run.append(t)
|
to_run.append(t)
|
||||||
# Execute outside lock
|
|
||||||
for t in to_run:
|
for t in to_run:
|
||||||
try:
|
try:
|
||||||
status, body, _ = self.run_once(t.name, t.value)
|
status, body, _ = self.run_once(t.name, t.value)
|
||||||
@@ -298,10 +280,8 @@ class ActionScheduler(threading.Thread):
|
|||||||
if t.interval_s > 0:
|
if t.interval_s > 0:
|
||||||
t.next_run = now + t.interval_s
|
t.next_run = now + t.interval_s
|
||||||
else:
|
else:
|
||||||
# one-shot: remove after run
|
|
||||||
self.remove_task(t.task_id)
|
self.remove_task(t.task_id)
|
||||||
except Exception:
|
except Exception:
|
||||||
# swallow to keep scheduler alive
|
|
||||||
if t.interval_s > 0:
|
if t.interval_s > 0:
|
||||||
t.next_run = now + t.interval_s
|
t.next_run = now + t.interval_s
|
||||||
else:
|
else:
|
||||||
@@ -318,7 +298,7 @@ class ActionScheduler(threading.Thread):
|
|||||||
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.3")
|
self.title("Control Board Monitor — v0.4")
|
||||||
self.geometry("1250x800")
|
self.geometry("1250x800")
|
||||||
try:
|
try:
|
||||||
self.iconbitmap(default="")
|
self.iconbitmap(default="")
|
||||||
@@ -451,8 +431,11 @@ class App(tk.Tk):
|
|||||||
|
|
||||||
self.protocol("WM_DELETE_WINDOW", self.on_close)
|
self.protocol("WM_DELETE_WINDOW", self.on_close)
|
||||||
|
|
||||||
# Initial discovery (does not auto-start polling)
|
# Initial: don't assume server works; just preload defaults into selector
|
||||||
self.after(200, self.reload_discovery)
|
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 -------------
|
# ------------- Discovery & selector -------------
|
||||||
def reload_discovery(self) -> None:
|
def reload_discovery(self) -> None:
|
||||||
@@ -463,7 +446,9 @@ class App(tk.Tk):
|
|||||||
discovered_vars: List[str] = []
|
discovered_vars: List[str] = []
|
||||||
discovered_funcs: List[str] = []
|
discovered_funcs: List[str] = []
|
||||||
|
|
||||||
# JSON for variables
|
msgs = []
|
||||||
|
|
||||||
|
# Try JSON vars
|
||||||
status, body, _ = http_get(base, {"variable": "WEBSERVER_LIST_VARIABLES_JSON"})
|
status, body, _ = http_get(base, {"variable": "WEBSERVER_LIST_VARIABLES_JSON"})
|
||||||
if status == 200:
|
if status == 200:
|
||||||
try:
|
try:
|
||||||
@@ -474,23 +459,26 @@ class App(tk.Tk):
|
|||||||
v = x.strip().upper()
|
v = x.strip().upper()
|
||||||
if v and v not in discovered_vars:
|
if v and v not in discovered_vars:
|
||||||
discovered_vars.append(v)
|
discovered_vars.append(v)
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass
|
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"})
|
status_h, body_h, _h = http_get(base, {"variable": "WEBSERVER_LIST_VARIABLES"})
|
||||||
if status_h == 200 and body_h:
|
if status_h == 200 and body_h:
|
||||||
cand = parse_variable_names_from_html_index(body_h)
|
cand = parse_variable_names_from_html_index(body_h)
|
||||||
for v in cand:
|
for v in cand:
|
||||||
if v not in discovered_vars:
|
if v not in discovered_vars:
|
||||||
discovered_vars.append(v)
|
discovered_vars.append(v)
|
||||||
# Functions from POST section
|
|
||||||
f_cand = parse_function_names_from_html_index(body_h)
|
f_cand = parse_function_names_from_html_index(body_h)
|
||||||
for f in f_cand:
|
for f in f_cand:
|
||||||
if f not in discovered_funcs:
|
if f not in discovered_funcs:
|
||||||
discovered_funcs.append(f)
|
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"})
|
status_f, body_f, _f = http_get(base, {"variable": "WEBSERVER_LIST_FUNCTIONS_JSON"})
|
||||||
if status_f == 200:
|
if status_f == 200:
|
||||||
try:
|
try:
|
||||||
@@ -501,24 +489,40 @@ class App(tk.Tk):
|
|||||||
fx = x.strip().upper()
|
fx = x.strip().upper()
|
||||||
if fx and fx not in discovered_funcs:
|
if fx and fx not in discovered_funcs:
|
||||||
discovered_funcs.append(fx)
|
discovered_funcs.append(fx)
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass
|
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
|
# Fallbacks
|
||||||
if discovered_vars:
|
used_defaults = False
|
||||||
self.show_selector_dialog(discovered_vars)
|
if not discovered_vars:
|
||||||
if discovered_funcs:
|
discovered_vars = list(DEFAULT_VARS)
|
||||||
self.functions_list = discovered_funcs
|
used_defaults = True
|
||||||
self.func_combo["values"] = self.functions_list
|
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:
|
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()))
|
union = sorted(set(self.variables_list) | set(self.vars.keys()))
|
||||||
if not union:
|
if not union:
|
||||||
messagebox.showinfo("No variables", "No discovered variables yet. Use Reload (disc.) first.")
|
# use defaults if nothing yet
|
||||||
return
|
union = list(DEFAULT_VARS)
|
||||||
self.show_selector_dialog(union)
|
self.show_selector_dialog(union)
|
||||||
|
|
||||||
def show_selector_dialog(self, discovered: List[str]) -> None:
|
def show_selector_dialog(self, discovered: List[str]) -> None:
|
||||||
@@ -528,7 +532,6 @@ class App(tk.Tk):
|
|||||||
dlg.transient(self)
|
dlg.transient(self)
|
||||||
dlg.grab_set()
|
dlg.grab_set()
|
||||||
|
|
||||||
# Filter entry
|
|
||||||
top = ttk.Frame(dlg, padding=8)
|
top = ttk.Frame(dlg, padding=8)
|
||||||
top.pack(side=tk.TOP, fill=tk.X)
|
top.pack(side=tk.TOP, fill=tk.X)
|
||||||
ttk.Label(top, text="Filter:").pack(side=tk.LEFT)
|
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_none = ttk.Button(top, text="Clear")
|
||||||
sel_all.pack(side=tk.LEFT, padx=4); sel_none.pack(side=tk.LEFT, padx=4)
|
sel_all.pack(side=tk.LEFT, padx=4); sel_none.pack(side=tk.LEFT, padx=4)
|
||||||
|
|
||||||
# Scrollable checkbox list
|
|
||||||
frame = ttk.Frame(dlg)
|
frame = ttk.Frame(dlg)
|
||||||
frame.pack(side=tk.TOP, fill=tk.BOTH, expand=True, padx=8, pady=8)
|
frame.pack(side=tk.TOP, fill=tk.BOTH, expand=True, padx=8, pady=8)
|
||||||
canvas = tk.Canvas(frame, borderwidth=0)
|
canvas = tk.Canvas(frame, borderwidth=0)
|
||||||
@@ -549,7 +551,6 @@ class App(tk.Tk):
|
|||||||
canvas.configure(yscrollcommand=vsb.set)
|
canvas.configure(yscrollcommand=vsb.set)
|
||||||
canvas.pack(side="left", fill="both", expand=True); vsb.pack(side="right", fill="y")
|
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()))
|
current_set = set(self.variables_list or list(self.vars.keys()))
|
||||||
rows: List[Tuple[str, tk.BooleanVar, ttk.Checkbutton]] = []
|
rows: List[Tuple[str, tk.BooleanVar, ttk.Checkbutton]] = []
|
||||||
def rebuild():
|
def rebuild():
|
||||||
@@ -566,15 +567,12 @@ class App(tk.Tk):
|
|||||||
rows.append((name, var, cb))
|
rows.append((name, var, cb))
|
||||||
rebuild()
|
rebuild()
|
||||||
|
|
||||||
# Wire filter/select-all
|
|
||||||
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])
|
||||||
|
|
||||||
# Buttons
|
|
||||||
bot = ttk.Frame(dlg, padding=8)
|
bot = ttk.Frame(dlg, padding=8)
|
||||||
bot.pack(side=tk.BOTTOM, fill=tk.X)
|
bot.pack(side=tk.BOTTOM, fill=tk.X)
|
||||||
def apply_and_close():
|
def apply_and_close():
|
||||||
@@ -583,7 +581,6 @@ class App(tk.Tk):
|
|||||||
if not messagebox.askyesno("Empty selection", "No variables selected. Continue?"):
|
if not messagebox.askyesno("Empty selection", "No variables selected. Continue?"):
|
||||||
return
|
return
|
||||||
self.variables_list = selected
|
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.vars = {v: self.vars.get(v, VarInfo(name=v)) for v in self.variables_list}
|
||||||
self.refresh_tree()
|
self.refresh_tree()
|
||||||
dlg.destroy()
|
dlg.destroy()
|
||||||
@@ -604,12 +601,15 @@ class App(tk.Tk):
|
|||||||
if not sel:
|
if not sel:
|
||||||
messagebox.showinfo("No selection", "Select a variable first.")
|
messagebox.showinfo("No selection", "Select a variable first.")
|
||||||
return
|
return
|
||||||
# Resolve to a variable item (skip group headers)
|
|
||||||
item = sel[0]
|
item = sel[0]
|
||||||
name = self.tree.item(item, "text")
|
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).")
|
messagebox.showinfo("Select variable", "Select a specific variable row (not a group).")
|
||||||
return
|
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)
|
vi = self.vars.get(name) or VarInfo(name=name)
|
||||||
|
|
||||||
dlg = tk.Toplevel(self)
|
dlg = tk.Toplevel(self)
|
||||||
@@ -617,7 +617,6 @@ class App(tk.Tk):
|
|||||||
dlg.geometry("560x520")
|
dlg.geometry("560x520")
|
||||||
dlg.transient(self); dlg.grab_set()
|
dlg.transient(self); dlg.grab_set()
|
||||||
|
|
||||||
# Entries
|
|
||||||
t = vi.thresholds
|
t = vi.thresholds
|
||||||
grid = ttk.Frame(dlg, padding=10)
|
grid = ttk.Frame(dlg, padding=10)
|
||||||
grid.pack(side=tk.TOP, fill=tk.BOTH, expand=True)
|
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(2, "HIGH", "high", t.high)
|
||||||
add_row(3, "EXTREME HIGH", "extreme_high", t.extreme_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))
|
ttk.Label(grid, text="Alarm on enter:").grid(row=4, column=0, sticky="w", pady=(12,4))
|
||||||
alarm_vars = {}
|
alarm_vars = {}
|
||||||
for i, (lbl, key, val) in enumerate([("Dead Low", "alarm_dead_low", t.alarm_dead_low),
|
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)
|
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
|
alarm_vars[key] = var
|
||||||
|
|
||||||
# Action bindings
|
|
||||||
ttk.Label(grid, text="On enter, run function:").grid(row=7, column=0, sticky="w", pady=(12,4))
|
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"),
|
rows = [("Dead Low", "action_dead_low", "value_dead_low"),
|
||||||
("Low", "action_low", "value_low"),
|
("Low", "action_low", "value_low"),
|
||||||
("High", "action_high", "value_high"),
|
("High", "action_high", "value_high"),
|
||||||
@@ -657,7 +653,7 @@ class App(tk.Tk):
|
|||||||
value_vars = {}
|
value_vars = {}
|
||||||
for i, (label, action_key, value_key) in enumerate(rows):
|
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)
|
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.set(getattr(t, action_key) or "")
|
||||||
s.grid(row=8+i, column=1, sticky="w", padx=6)
|
s.grid(row=8+i, column=1, sticky="w", padx=6)
|
||||||
action_vars[action_key] = s
|
action_vars[action_key] = s
|
||||||
@@ -666,7 +662,6 @@ class App(tk.Tk):
|
|||||||
value_vars[value_key] = sv
|
value_vars[value_key] = sv
|
||||||
|
|
||||||
def save_and_close():
|
def save_and_close():
|
||||||
# Parse floats or None
|
|
||||||
def _f(v):
|
def _f(v):
|
||||||
v = v.strip()
|
v = v.strip()
|
||||||
return float(v) if v else None
|
return float(v) if v else None
|
||||||
@@ -821,7 +816,6 @@ class App(tk.Tk):
|
|||||||
vi.last_status = status
|
vi.last_status = status
|
||||||
vi.error = None
|
vi.error = None
|
||||||
self.vars[name] = vi
|
self.vars[name] = vi
|
||||||
# Evaluate thresholds & trigger events if needed
|
|
||||||
self.evaluate_thresholds(name, value)
|
self.evaluate_thresholds(name, value)
|
||||||
elif kind == "error":
|
elif kind == "error":
|
||||||
_, name, status, msg = item
|
_, name, status, msg = item
|
||||||
@@ -861,11 +855,9 @@ class App(tk.Tk):
|
|||||||
t = vi.thresholds
|
t = vi.thresholds
|
||||||
x = parse_first_float(value)
|
x = parse_first_float(value)
|
||||||
if x is None:
|
if x is None:
|
||||||
# can't evaluate; treat as NORMAL unless error
|
|
||||||
new_state = "NORMAL"
|
new_state = "NORMAL"
|
||||||
else:
|
else:
|
||||||
new_state = "NORMAL"
|
new_state = "NORMAL"
|
||||||
# Order of precedence: dead_low < low < high < extreme_high
|
|
||||||
if t.dead_low is not None and x <= t.dead_low:
|
if t.dead_low is not None and x <= t.dead_low:
|
||||||
new_state = "DEAD_LOW"
|
new_state = "DEAD_LOW"
|
||||||
elif t.low is not None and x <= t.low:
|
elif t.low is not None and x <= t.low:
|
||||||
@@ -874,15 +866,12 @@ class App(tk.Tk):
|
|||||||
new_state = "EXTREME_HIGH"
|
new_state = "EXTREME_HIGH"
|
||||||
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:
|
||||||
# Entering a state: run actions / alarms
|
|
||||||
self.on_enter_state(name, new_state, vi)
|
self.on_enter_state(name, new_state, vi)
|
||||||
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) -> None:
|
||||||
t = vi.thresholds
|
t = vi.thresholds
|
||||||
# Alarm
|
|
||||||
alarm_map = {
|
alarm_map = {
|
||||||
"DEAD_LOW": t.alarm_dead_low,
|
"DEAD_LOW": t.alarm_dead_low,
|
||||||
"LOW": t.alarm_low,
|
"LOW": t.alarm_low,
|
||||||
@@ -891,8 +880,6 @@ class App(tk.Tk):
|
|||||||
}
|
}
|
||||||
if alarm_map.get(state):
|
if alarm_map.get(state):
|
||||||
play_alarm_tone(state)
|
play_alarm_tone(state)
|
||||||
|
|
||||||
# Function trigger
|
|
||||||
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),
|
||||||
@@ -901,7 +888,6 @@ class App(tk.Tk):
|
|||||||
}
|
}
|
||||||
act, val = act_map.get(state, (None, "1"))
|
act, val = act_map.get(state, (None, "1"))
|
||||||
if act:
|
if act:
|
||||||
# fire-and-forget one-shot via scheduler
|
|
||||||
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 -------------
|
# ------------- Tree rendering -------------
|
||||||
@@ -930,7 +916,6 @@ class App(tk.Tk):
|
|||||||
value_preview = coerce_preview(vi.last_value, 120)
|
value_preview = coerce_preview(vi.last_value, 120)
|
||||||
|
|
||||||
tag = infer_status_color(vi.last_value if not vi.error else "error")
|
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}]"
|
suffix = "" if vi.last_state in {"UNKNOWN", "NORMAL"} else f" [{vi.last_state}]"
|
||||||
self.tree.insert(
|
self.tree.insert(
|
||||||
parent,
|
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):
|
class Poller(threading.Thread):
|
||||||
def __init__(
|
def __init__(
|
||||||
|
|||||||
Reference in New Issue
Block a user