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>
242 lines
9.9 KiB
Python
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()
|