From e06e297540ffb50062c00986a4c7b7216f27c920 Mon Sep 17 00:00:00 2001 From: Michal Tuszowski Date: Mon, 20 Oct 2025 18:31:12 +0200 Subject: [PATCH] 1.11 --- nucleares_monitor/control_board_monitor.py | 142 +++++++++++++++++++-- 1 file changed, 129 insertions(+), 13 deletions(-) diff --git a/nucleares_monitor/control_board_monitor.py b/nucleares_monitor/control_board_monitor.py index eea7bd9..896fb81 100644 --- a/nucleares_monitor/control_board_monitor.py +++ b/nucleares_monitor/control_board_monitor.py @@ -282,6 +282,9 @@ class ActionTask: name: str value: str interval_s: float + # dataclass ActionTask (dodaj pole) + tag: Optional[str] = None + enabled: bool = True last_run: float = 0.0 next_run: float = 0.0 @@ -317,6 +320,11 @@ class ActionScheduler(threading.Thread): def remove_task(self, task_id: int) -> None: with self._lock: self.tasks.pop(task_id, None) + def remove_tasks_by_tag(self, tag: str) -> None: + with self._lock: + to_del = [tid for tid, t in self.tasks.items() if getattr(t, "tag", None) == tag] + for tid in to_del: + self.tasks.pop(tid, None) def list_tasks(self) -> List[ActionTask]: with self._lock: @@ -715,6 +723,84 @@ class App(tk.Tk): def open_selector(self) -> None: union = set([vi.display_name for vi in self.vars.values()]) or set(DEFAULT_VARS) self.show_selector_dialog(sorted(union)) + def _reschedule_state_tasks_for(self, vi: VarInfo) -> None: + """ + Usuwa wszystkie 'while-in-state' taski dla tej zmiennej i tworzy na nowo + tylko te, które powinny działać w bieżącym stanie według aktualnych progów. + Interwał 0 => run once; >0 => cykliczne. + """ + if not hasattr(self, "scheduler") or self.scheduler is None: + return + + t = vi.thresholds + # 1) Usuń istniejące zadania powiązane z tą zmienną (po tagu): + for state in ("DEAD_LOW", "LOW", "OPERATING", "HIGH", "EXTREME_HIGH"): + tag = f"state::{vi.key}::{state}" + self.scheduler.remove_tasks_by_tag(tag) + + # 2) Zmapuj ustawienia dla stanów: + mapping = { + "DEAD_LOW": ( + t.action_dead_low, t.value_dead_low, t.action_dead_low_interval, + getattr(t, "expr_dead_low", None), getattr(t, "expr_target_dead_low", None) + ), + "LOW": ( + t.action_low, t.value_low, t.action_low_interval, + getattr(t, "expr_low", None), getattr(t, "expr_target_low", None) + ), + "OPERATING": ( + getattr(t, "action_operating", None), getattr(t, "value_operating", "1"), + getattr(t, "action_operating_interval", 1.0), + getattr(t, "expr_operating", None), getattr(t, "expr_target_operating", None) + ), + "HIGH": ( + t.action_high, t.value_high, t.action_high_interval, + getattr(t, "expr_high", None), getattr(t, "expr_target_high", None) + ), + "EXTREME_HIGH": ( + t.action_extreme_high, t.value_extreme_high, t.action_extreme_high_interval, + getattr(t, "expr_extreme_high", None), getattr(t, "expr_target_extreme_high", None) + ), + } + + curr_state = vi.last_state # aktualny stan (ustawiany w evaluate_thresholds / on_state_change) :contentReference[oaicite:4]{index=4} + if curr_state not in mapping: + self.refresh_actions_tree() + return + + name, value, interval_s, expr, expr_target = mapping[curr_state] + + # Jeśli użytkownik ustawił 0 => 'run once' przy wejściu w stan: + # Samo przełączenie na 0 ma natychmiast skasować ew. cykliczne zadania – zrobiliśmy to wyżej. + # Nie dodajemy nowego zadania, jeśli nie ma co wykonywać. + if not any([name, expr, expr_target]): + self.refresh_actions_tree() + return + + tag = f"state::{vi.key}::{curr_state}" + if expr or expr_target: + # zadanie 'obliczeniowe' – liczymy x/y/z (x=monitorowana) + task = ActionTask( + name=(name or expr_target or ""), # nazwa do tabeli/POST (gdy expr_target) + value=(value if name else "0"), # value nie jest używane gdy expr_target + interval_s=float(interval_s), + expr=expr, + x_src=vi.display_name, + y_src=getattr(t, "y_source", None), + z_src=getattr(t, "z_source", None), + tag=tag, + ) + else: + # zwykły POST do 'name' z 'value' + task = ActionTask( + name=name or "", + value=str(value), + interval_s=float(interval_s), + tag=tag, + ) + + self.scheduler.add_task(task) + self.refresh_actions_tree() def show_selector_dialog(self, discovered: List[str]) -> None: seen=set(); disp=[] @@ -904,11 +990,27 @@ class App(tk.Tk): return parse_first_float(bd) return None def refresh_actions_tree(self): + # zapamiętaj zaznaczenie/fokus + sel_before = self.actions_tree.selection() + focus_before = self.actions_tree.focus() + + # przebuduj self.actions_tree.delete(*self.actions_tree.get_children()) for t in self.scheduler.list_tasks(): mode = ("Interval" if t.interval_s > 0 else "Once") + (" + Expr" if getattr(t, 'expr', None) else "") 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.name, 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") + ) + + # odtwórz zaznaczenie/fokus (o ile elementy wciąż istnieją) + if sel_before: + kept = [iid for iid in sel_before if self.actions_tree.exists(iid)] + if kept: + self.actions_tree.selection_set(kept) + if focus_before and self.actions_tree.exists(focus_before): + self.actions_tree.focus(focus_before) # --- Poller control / rendering --- def start_polling(self) -> None: @@ -946,6 +1048,25 @@ class App(tk.Tk): self.paused_event.set() self.pause_btn.configure(text="Resume") self.status_lbl.configure(text="Paused") + def _bind_mousewheel(self, widget): + # Windows / macOS + def _mw(event): + if event.delta: + widget.yview_scroll(int(-1 * (event.delta / 120)), "units") + return "break" + + # Linux (X11) + def _mw_up(_e): + widget.yview_scroll(-1, "units") + return "break" + + def _mw_down(_e): + widget.yview_scroll(1, "units") + return "break" + + widget.bind("", _mw) + widget.bind("", _mw_up) + widget.bind("", _mw_down) def drain_queue(self) -> None: try: @@ -1022,7 +1143,6 @@ class App(tk.Tk): self.tree.selection_set(iid) finally: self.tree_menu.tk_popup(event.x_root, event.y_root) - def open_plot_window(self): """Open (or focus) a per-variable plot window with last 100 values and last 100 deltas. Top: values line + horizontal mean; Bottom: delta bars + horizontal mean. @@ -1036,13 +1156,14 @@ class App(tk.Tk): if not iid.startswith("var:"): messagebox.showinfo("Select variable", "Select a specific variable row (not a group).") return - key = iid.split("var:",1)[1] + key = iid.split("var:", 1)[1] vi = self.vars.get(key) if not vi: messagebox.showinfo("Missing", "Selected variable is not available yet.") return if not HAS_MPL: - messagebox.showwarning("Plotting not available", "Matplotlib is not installed. Install 'matplotlib' to enable plots.") + messagebox.showwarning("Plotting not available", + "Matplotlib is not installed. Install 'matplotlib' to enable plots.") return if not hasattr(self, "_plot_windows"): @@ -1090,7 +1211,7 @@ class App(tk.Tk): if vals: ax_val.plot(xs_v, vals, linewidth=1.2) - mv = sum(vals)/len(vals) + mv = sum(vals) / len(vals) ax_val.axhline(mv, linestyle="--", linewidth=1.2) vmin, vmax = min(vals), max(vals) span = (vmax - vmin) or 1.0 @@ -1102,7 +1223,7 @@ class App(tk.Tk): if dels: ax_delta.bar(xs_d, dels) - md = sum(dels)/len(dels) + md = sum(dels) / len(dels) ax_delta.axhline(md, linestyle="--", linewidth=1.2) dmin, dmax = min(dels), max(dels) span = (dmax - dmin) or 1.0 @@ -1119,18 +1240,12 @@ class App(tk.Tk): except Exception: pass try: - interval_ms = int(max(250, float(self.refresh_var.get())*1000)) + interval_ms = int(max(250, float(self.refresh_var.get()) * 1000)) except Exception: interval_ms = 1000 win.after(interval_ms, refresh_plot) win.after(200, refresh_plot) - 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): @@ -1305,6 +1420,7 @@ class App(tk.Tk): except Exception: pass dlg.destroy() + self._reschedule_state_tasks_for(vi) ttk.Button(btns, text="Save", command=do_save).pack(side=tk.RIGHT, padx=6) ttk.Button(btns, text="Cancel", command=dlg.destroy).pack(side=tk.RIGHT)