This commit is contained in:
2025-10-20 18:25:50 +02:00
parent 61a8cb40a4
commit 311cb768b9
+68 -83
View File
@@ -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:
# 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)
# Apply
self.functions_list = discovered_funcs
self.func_combo["values"] = self.functions_list
self.show_selector_dialog(discovered_vars)
self.status_lbl.configure(text=f"Discovered vars: {len(discovered_vars)} | functions: {len(self.functions_list)}")
# 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__(