"""탭3: 시간 모니터링/오차 추적 패널""" import tkinter as tk from tkinter import ttk, filedialog, messagebox from datetime import datetime from models.controller import Controller from models.reading import TimeReading from services.drift_monitor import DriftMonitor from services.export_service import export_readings_csv, export_summary_csv from config import DEFAULT_MONITOR_INTERVAL class MonitorPanel(ttk.Frame): """시간 모니터링 및 오차 추적 패널.""" def __init__(self, parent, shared_controllers: list[Controller]): super().__init__(parent) self._controllers = shared_controllers self._monitor = DriftMonitor() self._build_ui() def _build_ui(self): # --- 상단: 설정 및 제어 --- top = ttk.LabelFrame(self, text="모니터링 설정", padding=5) top.pack(fill=tk.X, padx=5, pady=5) ttk.Label(top, text="읽기 주기(초):").pack(side=tk.LEFT, padx=(0, 5)) self._interval_var = tk.StringVar(value=str(DEFAULT_MONITOR_INTERVAL)) ttk.Entry(top, textvariable=self._interval_var, width=8).pack(side=tk.LEFT, padx=(0, 10)) self._btn_start = ttk.Button(top, text="시작", command=self._on_start) self._btn_start.pack(side=tk.LEFT, padx=2) self._btn_stop = ttk.Button(top, text="중지", command=self._on_stop, state=tk.DISABLED) self._btn_stop.pack(side=tk.LEFT, padx=2) self._btn_read_now = ttk.Button(top, text="즉시 읽기", command=self._on_read_now) self._btn_read_now.pack(side=tk.LEFT, padx=2) self._btn_clear = ttk.Button(top, text="기록 초기화", command=self._on_clear) self._btn_clear.pack(side=tk.LEFT, padx=2) self._status_label = ttk.Label(top, text="정지됨") self._status_label.pack(side=tk.RIGHT, padx=5) # --- 중간: 오차 읽기 기록 테이블 --- mid = ttk.LabelFrame(self, text="오차 읽기 기록", padding=5) mid.pack(fill=tk.BOTH, expand=True, padx=5, pady=(5, 2)) cols_read = ("controller", "pc_time", "ctrl_time", "drift") self._read_tree = ttk.Treeview(mid, columns=cols_read, show="headings", height=8) self._read_tree.heading("controller", text="컨트롤러") self._read_tree.heading("pc_time", text="PC 시간") self._read_tree.heading("ctrl_time", text="컨트롤러 시간") self._read_tree.heading("drift", text="오차") self._read_tree.column("controller", width=180) self._read_tree.column("pc_time", width=160) self._read_tree.column("ctrl_time", width=160) self._read_tree.column("drift", width=100) scroll_r = ttk.Scrollbar(mid, orient=tk.VERTICAL, command=self._read_tree.yview) self._read_tree.configure(yscrollcommand=scroll_r.set) self._read_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) scroll_r.pack(side=tk.RIGHT, fill=tk.Y) # --- 하단: 요약 테이블 + CSV 내보내기 --- bot = ttk.LabelFrame(self, text="오차 요약", padding=5) bot.pack(fill=tk.BOTH, expand=True, padx=5, pady=(2, 5)) cols_sum = ("controller", "count", "avg", "max_d", "rate") self._sum_tree = ttk.Treeview(bot, columns=cols_sum, show="headings", height=5) self._sum_tree.heading("controller", text="컨트롤러") self._sum_tree.heading("count", text="읽기 횟수") self._sum_tree.heading("avg", text="평균 오차(초)") self._sum_tree.heading("max_d", text="최대 오차(초)") self._sum_tree.heading("rate", text="시간당 오차율(초/h)") self._sum_tree.column("controller", width=180) self._sum_tree.column("count", width=80) self._sum_tree.column("avg", width=100) self._sum_tree.column("max_d", width=100) self._sum_tree.column("rate", width=120) scroll_s = ttk.Scrollbar(bot, orient=tk.VERTICAL, command=self._sum_tree.yview) self._sum_tree.configure(yscrollcommand=scroll_s.set) self._sum_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) scroll_s.pack(side=tk.RIGHT, fill=tk.Y) # CSV 내보내기 export_frame = ttk.Frame(self) export_frame.pack(fill=tk.X, padx=5, pady=5) ttk.Button(export_frame, text="기록 CSV 내보내기", command=self._export_readings).pack(side=tk.LEFT, padx=5) ttk.Button(export_frame, text="요약 CSV 내보내기", command=self._export_summary).pack(side=tk.LEFT, padx=5) def _on_start(self): selected = [c for c in self._controllers if c.selected] if not selected: messagebox.showwarning("알림", "모니터링할 컨트롤러가 없습니다.\n검색 후 선택하세요.") return try: interval = int(self._interval_var.get().strip()) if interval < 1: raise ValueError except ValueError: messagebox.showerror("오류", "읽기 주기는 1 이상의 정수여야 합니다.") return self._monitor.configure( controllers=self._controllers, interval=interval, on_readings=lambda readings: self.after(0, self._on_new_readings, readings), on_error=lambda msg: self.after(0, self._on_monitor_error, msg), ) self._monitor.start() self._btn_start.config(state=tk.DISABLED) self._btn_stop.config(state=tk.NORMAL) self._status_label.config(text=f"모니터링 중 (주기: {interval}초)") def _on_stop(self): self._monitor.stop() self._btn_start.config(state=tk.NORMAL) self._btn_stop.config(state=tk.DISABLED) self._status_label.config(text="정지됨") def _on_read_now(self): if not self._controllers: messagebox.showwarning("알림", "컨트롤러가 없습니다.") return # 모니터가 구성되지 않은 경우 현재 설정으로 구성 if not self._monitor.is_running: try: interval = int(self._interval_var.get().strip()) except ValueError: interval = DEFAULT_MONITOR_INTERVAL self._monitor.configure( controllers=self._controllers, interval=interval, on_readings=lambda readings: self.after(0, self._on_new_readings, readings), on_error=lambda msg: self.after(0, self._on_monitor_error, msg), ) self._monitor.read_once() self._status_label.config(text="읽기 중...") def _on_clear(self): self._monitor.clear_readings() self._read_tree.delete(*self._read_tree.get_children()) self._sum_tree.delete(*self._sum_tree.get_children()) def _on_new_readings(self, readings: list[TimeReading]): """새 읽기 결과가 도착했을 때 GUI 업데이트.""" for r in readings: self._read_tree.insert("", tk.END, values=( r.controller_label, r.pc_time.strftime("%Y-%m-%d %H:%M:%S"), r.controller_time.strftime("%Y-%m-%d %H:%M:%S"), r.drift_display, )) # 스크롤 끝으로 children = self._read_tree.get_children() if children: self._read_tree.see(children[-1]) # 요약 갱신 self._refresh_summary() if self._monitor.is_running: self._status_label.config( text=f"모니터링 중 (주기: {self._monitor.interval}초) - " f"마지막 읽기: {datetime.now().strftime('%H:%M:%S')}" ) else: self._status_label.config( text=f"읽기 완료: {datetime.now().strftime('%H:%M:%S')}" ) def _on_monitor_error(self, msg: str): # 에러를 읽기 테이블에 표시 self._read_tree.insert("", tk.END, values=(msg, "", "", "오류")) children = self._read_tree.get_children() if children: self._read_tree.see(children[-1]) def _refresh_summary(self): self._sum_tree.delete(*self._sum_tree.get_children()) summary = self._monitor.get_summary() for mac, s in summary.items(): self._sum_tree.insert("", tk.END, values=( s["label"], s["count"], f"{s['avg_drift']:.2f}", f"{s['max_drift']:.2f}", f"{s['drift_per_hour']:.3f}", )) def _export_readings(self): readings = self._monitor.readings if not readings: messagebox.showinfo("알림", "내보낼 기록이 없습니다.") return filepath = filedialog.asksaveasfilename( title="기록 CSV 저장", defaultextension=".csv", filetypes=[("CSV 파일", "*.csv")], initialfile=f"drift_readings_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv", ) if not filepath: return try: saved = export_readings_csv(readings, filepath) messagebox.showinfo("성공", f"저장 완료: {saved}") except Exception as e: messagebox.showerror("오류", f"저장 실패: {e}") def _export_summary(self): summary = self._monitor.get_summary() if not summary: messagebox.showinfo("알림", "내보낼 요약이 없습니다.") return filepath = filedialog.asksaveasfilename( title="요약 CSV 저장", defaultextension=".csv", filetypes=[("CSV 파일", "*.csv")], initialfile=f"drift_summary_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv", ) if not filepath: return try: saved = export_summary_csv(summary, filepath) messagebox.showinfo("성공", f"저장 완료: {saved}") except Exception as e: messagebox.showerror("오류", f"저장 실패: {e}") def stop_monitor(self): """앱 종료 시 호출.""" self._monitor.stop()