Files
time_manager/gui/monitor_panel.py
insulee 3c14e1e401 Initial commit: Dabit Time Manager project
Python-based time management application with UDP discovery,
TCP protocol communication, time sync, and drift monitoring.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 11:10:55 +09:00

242 lines
9.9 KiB
Python

"""탭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()