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>
This commit is contained in:
insulee
2026-02-10 11:10:55 +09:00
commit 3c14e1e401
27 changed files with 2240 additions and 0 deletions

0
gui/__init__.py Normal file
View File

599
gui/app.py Normal file
View File

@@ -0,0 +1,599 @@
"""RTC Time Drift Tester - 단일 화면 GUI"""
import os
import sys
import tkinter as tk
from tkinter import ttk, messagebox, filedialog
import threading
from datetime import datetime
from config import APP_TITLE, APP_WIDTH, APP_HEIGHT, DEFAULT_MONITOR_INTERVAL
from models.controller import Controller
from models.reading import TimeReading
from network.udp_discovery import get_network_interfaces, search_controllers
from services.time_sync_service import sync_all
from services.drift_monitor import DriftMonitor
from services.export_service import (
export_readings_csv, export_per_controller_csv, export_summary_csv,
)
from gui.settings_dialog import SettingsDialog
class App:
def __init__(self):
self.root = tk.Tk()
self.root.title(APP_TITLE)
self.root.geometry(f"{APP_WIDTH}x{APP_HEIGHT}")
self.root.minsize(900, 600)
# 아이콘 설정 (윈도우 좌상단 + 작업표시줄)
if getattr(sys, 'frozen', False):
base_path = sys._MEIPASS
else:
base_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
icon_path = os.path.join(base_path, "icon.ico")
if os.path.exists(icon_path):
self.root.iconbitmap(icon_path)
self._controllers: list[Controller] = []
self._monitor = DriftMonitor()
self._searching = False
self._syncing = False
self._log_queue: list[str] = []
self._search_finished = False
self._search_results: list[Controller] = []
self._build_ui()
self._update_clock()
self.root.protocol("WM_DELETE_WINDOW", self._on_close)
# ── UI 구성 ─────────────────────────────────────────────
def _build_ui(self):
# 상단: NIC + 검색 + PC 시간
self._build_top_bar()
# 컨트롤러 테이블
self._build_ctrl_table()
# 로그 (2줄)
self._build_log()
# 액션 바 (동기화 + 모니터링)
self._build_action_bar()
# 하단: 기록 + 요약 (좌우 분할)
self._build_bottom_pane()
def _build_top_bar(self):
top = ttk.Frame(self.root)
top.pack(fill=tk.X, padx=8, pady=(8, 2))
ttk.Label(top, text="네트워크:").pack(side=tk.LEFT)
self._nic_var = tk.StringVar()
self._nic_combo = ttk.Combobox(
top, textvariable=self._nic_var, state="readonly", width=22)
self._nic_combo.pack(side=tk.LEFT, padx=(4, 8))
self._btn_search = ttk.Button(top, text="검색", command=self._on_search)
self._btn_search.pack(side=tk.LEFT)
self._search_status = ttk.Label(top, text="", foreground="gray")
self._search_status.pack(side=tk.LEFT, padx=8)
# PC 시간 (오른쪽)
self._clock_label = ttk.Label(top, text="", font=("Consolas", 13, "bold"))
self._clock_label.pack(side=tk.RIGHT)
ttk.Label(top, text="PC 시간 ").pack(side=tk.RIGHT)
self._refresh_nics()
def _build_ctrl_table(self):
frame = ttk.LabelFrame(self.root, text="컨트롤러", padding=4)
frame.pack(fill=tk.BOTH, padx=8, pady=2, expand=False)
# Treeview
cols = ("sel", "mac", "ip", "port", "name", "last_drift")
self._ctrl_tree = ttk.Treeview(frame, columns=cols, show="headings", height=5)
self._ctrl_tree.heading("sel", text="")
self._ctrl_tree.heading("mac", text="MAC")
self._ctrl_tree.heading("ip", text="IP")
self._ctrl_tree.heading("port", text="포트")
self._ctrl_tree.heading("name", text="이름")
self._ctrl_tree.heading("last_drift", text="마지막 오차")
self._ctrl_tree.column("sel", width=30, anchor=tk.CENTER, stretch=False)
self._ctrl_tree.column("mac", width=140)
self._ctrl_tree.column("ip", width=120)
self._ctrl_tree.column("port", width=50)
self._ctrl_tree.column("name", width=160)
self._ctrl_tree.column("last_drift", width=90, anchor=tk.CENTER)
scr = ttk.Scrollbar(frame, orient=tk.VERTICAL, command=self._ctrl_tree.yview)
self._ctrl_tree.configure(yscrollcommand=scr.set)
self._ctrl_tree.pack(side=tk.TOP, fill=tk.BOTH, expand=True)
scr.place(relx=1.0, rely=0, relheight=1.0, anchor=tk.NE)
self._ctrl_tree.bind("<ButtonRelease-1>", self._on_ctrl_click)
# 버튼 행
btn_row = ttk.Frame(frame)
btn_row.pack(fill=tk.X, pady=(4, 0))
ttk.Button(btn_row, text="전체 선택", command=self._select_all).pack(side=tk.LEFT, padx=2)
ttk.Button(btn_row, text="전체 해제", command=self._deselect_all).pack(side=tk.LEFT, padx=2)
ttk.Separator(btn_row, orient=tk.VERTICAL).pack(side=tk.LEFT, fill=tk.Y, padx=8)
ttk.Button(btn_row, text="시간 동기화", command=self._on_sync).pack(side=tk.LEFT, padx=2)
ttk.Button(btn_row, text="설정 변경...", command=self._on_settings).pack(side=tk.LEFT, padx=2)
def _build_log(self):
frame = ttk.LabelFrame(self.root, text="로그", padding=2)
frame.pack(fill=tk.X, padx=8, pady=2)
self._log = tk.Text(frame, height=8, state=tk.DISABLED, wrap=tk.WORD,
font=("Consolas", 9))
scr_log = ttk.Scrollbar(frame, orient=tk.VERTICAL, command=self._log.yview)
self._log.configure(yscrollcommand=scr_log.set)
scr_log.pack(side=tk.RIGHT, fill=tk.Y)
self._log.pack(fill=tk.X)
def _build_action_bar(self):
bar = ttk.Frame(self.root)
bar.pack(fill=tk.X, padx=8, pady=2)
ttk.Label(bar, text="모니터링", font=("", 9, "bold")).pack(side=tk.LEFT, padx=(0, 8))
ttk.Label(bar, text="주기:").pack(side=tk.LEFT)
self._interval_var = tk.StringVar(value=str(DEFAULT_MONITOR_INTERVAL))
ttk.Entry(bar, textvariable=self._interval_var, width=6).pack(side=tk.LEFT, padx=2)
ttk.Label(bar, text="").pack(side=tk.LEFT, padx=(0, 6))
self._btn_start = ttk.Button(bar, text="시작", command=self._on_monitor_start)
self._btn_start.pack(side=tk.LEFT, padx=2)
self._btn_stop = ttk.Button(bar, text="중지", command=self._on_monitor_stop, state=tk.DISABLED)
self._btn_stop.pack(side=tk.LEFT, padx=2)
self._btn_read = ttk.Button(bar, text="즉시 읽기", command=self._on_read_now)
self._btn_read.pack(side=tk.LEFT, padx=2)
ttk.Separator(bar, orient=tk.VERTICAL).pack(side=tk.LEFT, fill=tk.Y, padx=6)
self._btn_hourly = ttk.Button(bar, text="매시 정시 읽기", command=self._on_hourly_toggle)
self._btn_hourly.pack(side=tk.LEFT, padx=2)
ttk.Separator(bar, orient=tk.VERTICAL).pack(side=tk.LEFT, fill=tk.Y, padx=6)
ttk.Button(bar, text="기록 초기화", command=self._on_clear).pack(side=tk.LEFT, padx=2)
self._mon_status = ttk.Label(bar, text="대기중", foreground="gray")
self._mon_status.pack(side=tk.RIGHT)
def _build_bottom_pane(self):
pane = ttk.PanedWindow(self.root, orient=tk.HORIZONTAL)
pane.pack(fill=tk.BOTH, expand=True, padx=8, pady=(2, 8))
# ── 왼쪽: 오차 기록 ──
left = ttk.LabelFrame(pane, text="오차 기록", padding=4)
filt = ttk.Frame(left)
filt.pack(fill=tk.X, pady=(0, 4))
ttk.Label(filt, text="컨트롤러:").pack(side=tk.LEFT)
self._filter_var = tk.StringVar(value="전체")
self._filter_combo = ttk.Combobox(
filt, textvariable=self._filter_var, state="readonly", width=28)
self._filter_combo["values"] = ["전체"]
self._filter_combo.pack(side=tk.LEFT, padx=4)
self._filter_combo.bind("<<ComboboxSelected>>", self._on_filter_changed)
cols_r = ("ctrl", "time", "ctrl_time", "drift")
self._read_tree = ttk.Treeview(left, columns=cols_r, show="headings", height=10)
self._read_tree.heading("ctrl", text="컨트롤러")
self._read_tree.heading("time", text="PC 시간")
self._read_tree.heading("ctrl_time", text="컨트롤러 시간")
self._read_tree.heading("drift", text="오차")
self._read_tree.column("ctrl", width=130)
self._read_tree.column("time", width=135)
self._read_tree.column("ctrl_time", width=135)
self._read_tree.column("drift", width=70, anchor=tk.CENTER)
scr_r = ttk.Scrollbar(left, orient=tk.VERTICAL, command=self._read_tree.yview)
self._read_tree.configure(yscrollcommand=scr_r.set)
self._read_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
scr_r.pack(side=tk.RIGHT, fill=tk.Y)
# ── 오른쪽: 요약 + 내보내기 ──
right = ttk.LabelFrame(pane, text="요약", padding=4)
cols_s = ("ctrl", "count", "avg", "max_d")
self._sum_tree = ttk.Treeview(right, columns=cols_s, show="headings", height=10)
self._sum_tree.heading("ctrl", text="컨트롤러")
self._sum_tree.heading("count", text="읽기 횟수")
self._sum_tree.heading("avg", text="평균 오차(초)")
self._sum_tree.heading("max_d", text="최대 오차(초)")
self._sum_tree.column("ctrl", width=150)
self._sum_tree.column("count", width=70, anchor=tk.CENTER)
self._sum_tree.column("avg", width=90, anchor=tk.CENTER)
self._sum_tree.column("max_d", width=90, anchor=tk.CENTER)
scr_s = ttk.Scrollbar(right, orient=tk.VERTICAL, command=self._sum_tree.yview)
self._sum_tree.configure(yscrollcommand=scr_s.set)
self._sum_tree.pack(side=tk.TOP, fill=tk.BOTH, expand=True)
scr_s.place(relx=1.0, rely=0, relheight=0.85, anchor=tk.NE)
# 내보내기 버튼
exp = ttk.Frame(right)
exp.pack(fill=tk.X, pady=(6, 0))
mb = ttk.Menubutton(exp, text="CSV 내보내기 ▾")
menu = tk.Menu(mb, tearoff=0)
menu.add_command(label="전체 내보내기 (단일 파일)", command=self._export_all)
menu.add_command(label="컨트롤러별 내보내기 (폴더)", command=self._export_per_ctrl)
menu.add_separator()
menu.add_command(label="요약 내보내기", command=self._export_summary)
mb["menu"] = menu
mb.pack(side=tk.LEFT)
pane.add(left, weight=3)
pane.add(right, weight=2)
# ── 유틸 ────────────────────────────────────────────────
def _log_msg(self, msg: str):
self._log.config(state=tk.NORMAL)
self._log.insert(tk.END, msg + "\n")
self._log.see(tk.END)
self._log.config(state=tk.DISABLED)
def _update_clock(self):
self._clock_label.config(text=datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
self.root.after(1000, self._update_clock)
def _refresh_nics(self):
nics = get_network_interfaces()
self._nic_list = nics
self._nic_combo["values"] = [f"{ip}" for ip, _ in nics]
if nics:
self._nic_combo.current(0)
def _get_bind_ip(self) -> str:
idx = self._nic_combo.current()
if 0 <= idx < len(self._nic_list):
return self._nic_list[idx][0]
return "0.0.0.0"
def _refresh_ctrl_tree(self):
self._ctrl_tree.delete(*self._ctrl_tree.get_children())
for ctrl in self._controllers:
sel = "" if ctrl.selected else ""
self._ctrl_tree.insert("", tk.END, values=(
sel, ctrl.mac, ctrl.ip, ctrl.port, ctrl.name, "-"
))
def _update_ctrl_drift(self, mac: str, drift_text: str):
"""컨트롤러 테이블의 '마지막 오차' 컬럼 갱신."""
for item in self._ctrl_tree.get_children():
vals = self._ctrl_tree.item(item, "values")
if vals[1] == mac:
new_vals = list(vals)
new_vals[5] = drift_text
self._ctrl_tree.item(item, values=new_vals)
break
def _update_filter_options(self):
labels = ["전체"] + [c.display_label for c in self._controllers]
self._filter_combo["values"] = labels
def _get_selected_ctrl(self) -> Controller | None:
sel = self._ctrl_tree.selection()
if not sel:
return None
vals = self._ctrl_tree.item(sel[0], "values")
mac = vals[1]
for c in self._controllers:
if c.mac == mac:
return c
return None
# ── 컨트롤러 체크박스 ───────────────────────────────────
def _on_ctrl_click(self, event):
col = self._ctrl_tree.identify_column(event.x)
item = self._ctrl_tree.identify_row(event.y)
if col == "#1" and item: # sel 컬럼
idx = self._ctrl_tree.index(item)
if idx < len(self._controllers):
ctrl = self._controllers[idx]
ctrl.selected = not ctrl.selected
vals = list(self._ctrl_tree.item(item, "values"))
vals[0] = "" if ctrl.selected else ""
self._ctrl_tree.item(item, values=vals)
def _select_all(self):
for ctrl in self._controllers:
ctrl.selected = True
self._refresh_ctrl_tree_sel()
def _deselect_all(self):
for ctrl in self._controllers:
ctrl.selected = False
self._refresh_ctrl_tree_sel()
def _refresh_ctrl_tree_sel(self):
for item in self._ctrl_tree.get_children():
idx = self._ctrl_tree.index(item)
if idx < len(self._controllers):
vals = list(self._ctrl_tree.item(item, "values"))
vals[0] = "" if self._controllers[idx].selected else ""
self._ctrl_tree.item(item, values=vals)
# ── 검색 ────────────────────────────────────────────────
def _on_search(self):
if self._searching:
return
self._searching = True
self._btn_search.config(state=tk.DISABLED)
self._search_status.config(text="검색중...")
self._controllers.clear()
self._ctrl_tree.delete(*self._ctrl_tree.get_children())
self._log_queue.clear()
self._search_finished = False
bind_ip = self._get_bind_ip()
self._log_msg(f"검색 시작 (NIC: {bind_ip})")
threading.Thread(target=self._search_thread, args=(bind_ip,), daemon=True).start()
self._poll_search()
def _search_thread(self, bind_ip: str):
results = search_controllers(
bind_ip=bind_ip, timeout=3.0,
on_log=lambda m: self._log_queue.append(m),
)
self._search_results = results
self._search_finished = True
def _poll_search(self):
while self._log_queue:
self._log_msg(self._log_queue.pop(0))
if self._search_finished:
self._search_finished = False
self._searching = False
self._btn_search.config(state=tk.NORMAL)
self._controllers.clear()
self._controllers.extend(self._search_results)
self._refresh_ctrl_tree()
self._update_filter_options()
self._search_status.config(text=f"{len(self._controllers)}개 발견")
return
self.root.after(100, self._poll_search)
# ── 설정 변경 ───────────────────────────────────────────
def _on_settings(self):
ctrl = self._get_selected_ctrl()
if not ctrl:
messagebox.showinfo("알림", "컨트롤러를 선택하세요.")
return
SettingsDialog(self.root, ctrl, self._get_bind_ip())
# 다이얼로그 닫힌 후 테이블 갱신
self._refresh_ctrl_tree()
# ── 동기화 ──────────────────────────────────────────────
def _on_sync(self):
if self._syncing:
return
selected = [c for c in self._controllers if c.selected]
if not selected:
self._log_msg("동기화할 컨트롤러가 없습니다.")
return
self._syncing = True
self._log_msg(f"동기화 시작 ({len(selected)}대)...")
threading.Thread(target=self._sync_thread, daemon=True).start()
def _sync_thread(self):
results = sync_all(self._controllers)
self.root.after(0, self._sync_done, results)
def _sync_done(self, results):
self._syncing = False
ok_count = 0
for ctrl, ok, msg in results:
tag = "OK" if ok else "FAIL"
self._log_msg(f" [{tag}] {ctrl.display_label}: {msg}")
drift_text = "동기화OK" if ok else "동기화실패"
self._update_ctrl_drift(ctrl.mac, drift_text)
if ok:
ok_count += 1
self._log_msg(f"동기화 완료: {ok_count}/{len(results)} 성공")
# ── 모니터링 ────────────────────────────────────────────
def _on_monitor_start(self):
selected = [c for c in self._controllers if c.selected]
if not selected:
messagebox.showwarning("알림", "모니터링할 컨트롤러가 없습니다.")
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 r: self.root.after(0, self._on_new_readings, r),
on_error=lambda m: self.root.after(0, self._log_msg, m),
)
self._monitor.start()
self._btn_start.config(state=tk.DISABLED)
self._btn_stop.config(state=tk.NORMAL)
self._mon_status.config(text=f"모니터링 중 (주기 {interval}초)", foreground="green")
def _on_monitor_stop(self):
self._monitor.stop()
self._btn_start.config(state=tk.NORMAL)
self._btn_stop.config(state=tk.DISABLED)
self._mon_status.config(text="정지됨", foreground="gray")
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 r: self.root.after(0, self._on_new_readings, r),
on_error=lambda m: self.root.after(0, self._log_msg, m),
)
self._monitor.read_once()
self._mon_status.config(text="읽기 중...", foreground="orange")
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())
for ctrl in self._controllers:
self._update_ctrl_drift(ctrl.mac, "-")
def _on_new_readings(self, readings: list[TimeReading]):
for r in readings:
self._read_tree.insert("", tk.END, iid=None, 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,
), tags=(r.controller_mac,))
self._update_ctrl_drift(r.controller_mac, r.drift_display)
# 필터 적용
self._apply_filter()
# 스크롤 끝
children = self._read_tree.get_children()
if children:
self._read_tree.see(children[-1])
# 요약 갱신
self._refresh_summary()
now = datetime.now().strftime("%H:%M:%S")
if self._monitor.is_running:
self._mon_status.config(
text=f"모니터링 중 (마지막: {now})", foreground="green")
else:
self._mon_status.config(text=f"읽기 완료 ({now})", foreground="gray")
# ── 필터 ────────────────────────────────────────────────
def _on_filter_changed(self, event=None):
self._apply_filter()
def _apply_filter(self):
selected = self._filter_var.get()
# 모든 항목을 보이거나 숨기기 (detach/reattach)
# 간단하게: 트리 다시 빌드
self._read_tree.delete(*self._read_tree.get_children())
for r in self._monitor.readings:
if selected == "전체" or r.controller_label == selected:
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,
), tags=(r.controller_mac,))
def _refresh_summary(self):
self._sum_tree.delete(*self._sum_tree.get_children())
summary = self._monitor.get_summary()
selected = self._filter_var.get()
for mac, s in summary.items():
if selected != "전체" and s["label"] != selected:
continue
self._sum_tree.insert("", tk.END, values=(
s["label"],
s["count"],
f"{s['avg_drift']:.2f}",
f"{s['max_drift']:.2f}",
))
# ── CSV 내보내기 ────────────────────────────────────────
def _export_all(self):
readings = self._monitor.readings
if not readings:
messagebox.showinfo("알림", "내보낼 기록이 없습니다.")
return
fp = filedialog.asksaveasfilename(
title="전체 기록 CSV 저장",
defaultextension=".csv",
filetypes=[("CSV", "*.csv")],
initialfile=f"drift_all_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv",
)
if fp:
try:
export_readings_csv(readings, fp)
messagebox.showinfo("성공", f"저장 완료:\n{fp}")
except Exception as e:
messagebox.showerror("오류", str(e))
def _export_per_ctrl(self):
readings = self._monitor.readings
if not readings:
messagebox.showinfo("알림", "내보낼 기록이 없습니다.")
return
folder = filedialog.askdirectory(title="컨트롤러별 CSV 저장 폴더 선택")
if folder:
try:
files = export_per_controller_csv(readings, folder)
messagebox.showinfo("성공", f"{len(files)}개 파일 생성:\n" + "\n".join(files))
except Exception as e:
messagebox.showerror("오류", str(e))
def _export_summary(self):
summary = self._monitor.get_summary()
if not summary:
messagebox.showinfo("알림", "내보낼 요약이 없습니다.")
return
fp = filedialog.asksaveasfilename(
title="요약 CSV 저장",
defaultextension=".csv",
filetypes=[("CSV", "*.csv")],
initialfile=f"drift_summary_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv",
)
if fp:
try:
export_summary_csv(summary, fp)
messagebox.showinfo("성공", f"저장 완료:\n{fp}")
except Exception as e:
messagebox.showerror("오류", str(e))
# ── 매시 정시 읽기 ────────────────────────────────────────
def _on_hourly_toggle(self):
if self._monitor.is_hourly_running:
# 중지
self._monitor.stop_hourly()
self._btn_hourly.config(text="매시 정시 읽기")
self._log_msg("매시 정시 읽기 중지")
else:
# 시작
selected = [c for c in self._controllers if c.selected]
if not selected:
messagebox.showwarning("알림", "컨트롤러를 선택하세요.")
return
self._monitor.configure(
controllers=self._controllers,
interval=self._monitor.interval,
on_readings=lambda r: self.root.after(0, self._on_new_readings, r),
on_error=lambda m: self.root.after(0, self._log_msg, m),
)
self._monitor.start_hourly()
self._btn_hourly.config(text="정시 읽기 중지")
self._log_msg("매시 정시 읽기 시작 (매시 00분 00초에 읽기)")
# ── 앱 생명주기 ─────────────────────────────────────────
def _on_close(self):
self._monitor.stop()
self._monitor.stop_hourly()
self.root.destroy()
def run(self):
self.root.mainloop()

239
gui/discovery_panel.py Normal file
View File

@@ -0,0 +1,239 @@
"""탭1: 컨트롤러 검색/IP변경 패널"""
import tkinter as tk
from tkinter import ttk, messagebox
import threading
from models.controller import Controller
from network.udp_discovery import get_network_interfaces, search_controllers, send_sett, send_reset
class DiscoveryPanel(ttk.Frame):
"""컨트롤러 검색 및 설정 변경 패널."""
def __init__(self, parent, shared_controllers: list[Controller]):
super().__init__(parent)
self._controllers = shared_controllers
self._searching = False
self._build_ui()
def _build_ui(self):
# --- 상단: NIC 선택 + 검색 ---
top = ttk.LabelFrame(self, text="컨트롤러 검색", padding=5)
top.pack(fill=tk.X, padx=5, pady=5)
ttk.Label(top, text="네트워크:").grid(row=0, column=0, padx=(0, 5))
self._nic_var = tk.StringVar()
self._nic_combo = ttk.Combobox(top, textvariable=self._nic_var, state="readonly", width=20)
self._nic_combo.grid(row=0, column=1, padx=(0, 10))
self._btn_search = ttk.Button(top, text="검색", command=self._on_search)
self._btn_search.grid(row=0, column=2, padx=(0, 5))
self._search_status = ttk.Label(top, text="")
self._search_status.grid(row=0, column=3, padx=5)
self._refresh_nics()
# --- 중간: 컨트롤러 목록 ---
mid = ttk.LabelFrame(self, text="검색된 컨트롤러", padding=5)
mid.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
cols = ("mac", "ip", "port", "name", "subnet", "gateway")
self._tree = ttk.Treeview(mid, columns=cols, show="headings", height=8)
self._tree.heading("mac", text="MAC")
self._tree.heading("ip", text="IP")
self._tree.heading("port", text="포트")
self._tree.heading("name", text="이름")
self._tree.heading("subnet", text="서브넷")
self._tree.heading("gateway", text="게이트웨이")
self._tree.column("mac", width=140)
self._tree.column("ip", width=120)
self._tree.column("port", width=60)
self._tree.column("name", width=120)
self._tree.column("subnet", width=120)
self._tree.column("gateway", width=120)
scroll = ttk.Scrollbar(mid, orient=tk.VERTICAL, command=self._tree.yview)
self._tree.configure(yscrollcommand=scroll.set)
self._tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
scroll.pack(side=tk.RIGHT, fill=tk.Y)
self._tree.bind("<<TreeviewSelect>>", self._on_select)
# --- 로그 영역 ---
log_frame = ttk.LabelFrame(self, text="검색 로그", padding=5)
log_frame.pack(fill=tk.X, padx=5, pady=(0, 5))
self._log = tk.Text(log_frame, height=5, state=tk.DISABLED, wrap=tk.WORD,
font=("Consolas", 9))
log_scroll = ttk.Scrollbar(log_frame, orient=tk.VERTICAL, command=self._log.yview)
self._log.configure(yscrollcommand=log_scroll.set)
self._log.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
log_scroll.pack(side=tk.RIGHT, fill=tk.Y)
# --- 하단: 설정 변경 ---
bot = ttk.LabelFrame(self, text="설정 변경", padding=5)
bot.pack(fill=tk.X, padx=5, pady=5)
fields = [
("IP:", "ip_var", 15),
("포트:", "port_var", 6),
("이름:", "name_var", 15),
("서브넷:", "subnet_var", 15),
("게이트웨이:", "gw_var", 15),
]
self._edit_vars = {}
for i, (label, var_name, width) in enumerate(fields):
ttk.Label(bot, text=label).grid(row=i // 3, column=(i % 3) * 2, padx=2, sticky=tk.E)
var = tk.StringVar()
self._edit_vars[var_name] = var
ttk.Entry(bot, textvariable=var, width=width).grid(
row=i // 3, column=(i % 3) * 2 + 1, padx=(0, 10), pady=2
)
btn_frame = ttk.Frame(bot)
btn_frame.grid(row=2, column=0, columnspan=6, pady=5)
ttk.Button(btn_frame, text="적용", command=self._on_apply).pack(side=tk.LEFT, padx=5)
ttk.Button(btn_frame, text="리셋", command=self._on_reset).pack(side=tk.LEFT, padx=5)
def _log_message(self, msg: str):
self._log.config(state=tk.NORMAL)
self._log.insert(tk.END, msg + "\n")
self._log.see(tk.END)
self._log.config(state=tk.DISABLED)
def _refresh_nics(self):
nics = get_network_interfaces()
self._nic_list = nics
self._nic_combo["values"] = [f"{name} ({ip})" for ip, name in nics]
if nics:
self._nic_combo.current(0)
def _get_bind_ip(self) -> str:
idx = self._nic_combo.current()
if idx >= 0 and idx < len(self._nic_list):
return self._nic_list[idx][0]
return "0.0.0.0"
def _on_search(self):
if self._searching:
return
self._searching = True
self._btn_search.config(state=tk.DISABLED)
self._search_status.config(text="검색중...")
self._tree.delete(*self._tree.get_children())
self._controllers.clear()
# 로그 초기화
self._log.config(state=tk.NORMAL)
self._log.delete("1.0", tk.END)
self._log.config(state=tk.DISABLED)
bind_ip = self._get_bind_ip()
self._log_message(f"검색 시작 (NIC: {bind_ip})")
# 검색 로그를 GUI로 전달하는 큐
self._log_queue: list[str] = []
threading.Thread(target=self._search_thread, args=(bind_ip,), daemon=True).start()
self._poll_logs()
def _search_thread(self, bind_ip: str):
def on_log(msg: str):
self._log_queue.append(msg)
results = search_controllers(bind_ip=bind_ip, timeout=3.0, on_log=on_log)
self._search_results = results
self._search_finished = True
def _poll_logs(self):
"""백그라운드 스레드의 로그를 GUI에 표시."""
while self._log_queue:
msg = self._log_queue.pop(0)
self._log_message(msg)
if hasattr(self, "_search_finished") and self._search_finished:
self._search_finished = False
self._search_done(self._search_results)
return
self.after(100, self._poll_logs)
def _search_done(self, results: list[Controller]):
self._searching = False
self._btn_search.config(state=tk.NORMAL)
self._controllers.clear()
self._controllers.extend(results)
for ctrl in results:
self._tree.insert("", tk.END, values=(
ctrl.mac, ctrl.ip, ctrl.port, ctrl.name, ctrl.subnet, ctrl.gateway
))
self._search_status.config(text=f"{len(results)}개 발견")
def _on_select(self, event):
sel = self._tree.selection()
if not sel:
return
values = self._tree.item(sel[0], "values")
# mac, ip, port, name, subnet, gateway
self._edit_vars["ip_var"].set(values[1])
self._edit_vars["port_var"].set(values[2])
self._edit_vars["name_var"].set(values[3])
self._edit_vars["subnet_var"].set(values[4])
self._edit_vars["gw_var"].set(values[5])
def _get_selected_controller(self) -> Controller | None:
sel = self._tree.selection()
if not sel:
messagebox.showwarning("선택 필요", "컨트롤러를 선택하세요.")
return None
values = self._tree.item(sel[0], "values")
mac = values[0]
for c in self._controllers:
if c.mac == mac:
return c
return None
def _on_apply(self):
ctrl = self._get_selected_controller()
if not ctrl:
return
ctrl.ip = self._edit_vars["ip_var"].get().strip()
try:
ctrl.port = int(self._edit_vars["port_var"].get().strip())
except ValueError:
messagebox.showerror("오류", "포트는 숫자여야 합니다.")
return
ctrl.name = self._edit_vars["name_var"].get().strip()
ctrl.subnet = self._edit_vars["subnet_var"].get().strip()
ctrl.gateway = self._edit_vars["gw_var"].get().strip()
bind_ip = self._get_bind_ip()
ok = send_sett(ctrl, bind_ip)
if ok:
messagebox.showinfo("성공", "설정이 전송되었습니다.\n적용을 위해 리셋하세요.")
sel = self._tree.selection()
if sel:
self._tree.item(sel[0], values=(
ctrl.mac, ctrl.ip, ctrl.port, ctrl.name, ctrl.subnet, ctrl.gateway
))
else:
messagebox.showerror("실패", "설정 전송에 실패했습니다.")
def _on_reset(self):
ctrl = self._get_selected_controller()
if not ctrl:
return
if not messagebox.askyesno("확인", f"{ctrl.mac} 컨트롤러를 리셋하시겠습니까?"):
return
bind_ip = self._get_bind_ip()
ok = send_reset(ctrl.mac, bind_ip)
if ok:
messagebox.showinfo("성공", "리셋 명령이 전송되었습니다.")
else:
messagebox.showerror("실패", "리셋 전송에 실패했습니다.")

241
gui/monitor_panel.py Normal file
View File

@@ -0,0 +1,241 @@
"""탭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()

68
gui/settings_dialog.py Normal file
View File

@@ -0,0 +1,68 @@
"""컨트롤러 설정 변경 다이얼로그"""
import tkinter as tk
from tkinter import ttk, messagebox
from models.controller import Controller
from network.udp_discovery import send_sett
class SettingsDialog(tk.Toplevel):
def __init__(self, parent, ctrl: Controller, bind_ip: str):
super().__init__(parent)
self.title(f"설정 변경 - {ctrl.mac}")
self.resizable(False, False)
self.grab_set()
self._ctrl = ctrl
self._bind_ip = bind_ip
self._build_ui()
self.transient(parent)
# 화면 중앙
self.update_idletasks()
x = parent.winfo_rootx() + (parent.winfo_width() - self.winfo_width()) // 2
y = parent.winfo_rooty() + (parent.winfo_height() - self.winfo_height()) // 2
self.geometry(f"+{x}+{y}")
def _build_ui(self):
f = ttk.Frame(self, padding=15)
f.pack(fill=tk.BOTH, expand=True)
fields = [
("MAC:", self._ctrl.mac, False),
("IP:", self._ctrl.ip, True),
("포트:", str(self._ctrl.port), True),
("이름:", self._ctrl.name, True),
("서브넷:", self._ctrl.subnet, True),
("게이트웨이:", self._ctrl.gateway, True),
]
self._vars = {}
for i, (label, value, editable) in enumerate(fields):
ttk.Label(f, text=label).grid(row=i, column=0, sticky=tk.E, padx=(0, 8), pady=3)
var = tk.StringVar(value=value)
state = "normal" if editable else "readonly"
ttk.Entry(f, textvariable=var, width=22, state=state).grid(row=i, column=1, pady=3)
self._vars[label] = var
btn = ttk.Frame(f)
btn.grid(row=len(fields), column=0, columnspan=2, pady=(12, 0))
ttk.Button(btn, text="설정", command=self._on_apply).pack(side=tk.LEFT, padx=5)
ttk.Button(btn, text="닫기", command=self.destroy).pack(side=tk.LEFT, padx=5)
def _on_apply(self):
self._ctrl.ip = self._vars["IP:"].get().strip()
try:
self._ctrl.port = int(self._vars["포트:"].get().strip())
except ValueError:
messagebox.showerror("오류", "포트는 숫자여야 합니다.", parent=self)
return
self._ctrl.name = self._vars["이름:"].get().strip()
self._ctrl.subnet = self._vars["서브넷:"].get().strip()
self._ctrl.gateway = self._vars["게이트웨이:"].get().strip()
ok = send_sett(self._ctrl, self._bind_ip)
if ok:
messagebox.showinfo("성공", "설정 되었습니다.", parent=self)
else:
messagebox.showerror("실패", "설정 전송에 실패했습니다.", parent=self)

124
gui/sync_panel.py Normal file
View File

@@ -0,0 +1,124 @@
"""탭2: PC시간 동기화 패널"""
import tkinter as tk
from tkinter import ttk
import threading
from datetime import datetime
from models.controller import Controller
from services.time_sync_service import sync_all
class SyncPanel(ttk.Frame):
"""PC 시간 동기화 패널."""
def __init__(self, parent, shared_controllers: list[Controller]):
super().__init__(parent)
self._controllers = shared_controllers
self._check_vars: list[tk.BooleanVar] = []
self._syncing = False
self._build_ui()
self._update_clock()
def _build_ui(self):
# --- 상단: 현재 PC 시간 ---
top = ttk.LabelFrame(self, text="현재 PC 시간", padding=10)
top.pack(fill=tk.X, padx=5, pady=5)
self._clock_label = ttk.Label(top, text="", font=("Consolas", 18))
self._clock_label.pack()
# --- 중간: 컨트롤러 체크리스트 ---
mid = ttk.LabelFrame(self, text="동기화 대상 컨트롤러", padding=5)
mid.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
btn_row = ttk.Frame(mid)
btn_row.pack(fill=tk.X)
ttk.Button(btn_row, text="전체 선택", command=self._select_all).pack(side=tk.LEFT, padx=2)
ttk.Button(btn_row, text="전체 해제", command=self._deselect_all).pack(side=tk.LEFT, padx=2)
ttk.Button(btn_row, text="목록 새로고침", command=self.refresh_list).pack(side=tk.LEFT, padx=2)
self._check_frame = ttk.Frame(mid)
self._check_frame.pack(fill=tk.BOTH, expand=True, pady=5)
# --- 하단: 동기화 실행 + 결과 ---
bot = ttk.LabelFrame(self, text="동기화", padding=5)
bot.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
self._btn_sync = ttk.Button(bot, text="동기화 실행", command=self._on_sync)
self._btn_sync.pack(pady=5)
self._log = tk.Text(bot, height=8, state=tk.DISABLED, wrap=tk.WORD)
self._log.pack(fill=tk.BOTH, expand=True)
def _update_clock(self):
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
self._clock_label.config(text=now)
self.after(1000, self._update_clock)
def refresh_list(self):
"""컨트롤러 체크리스트 갱신."""
for w in self._check_frame.winfo_children():
w.destroy()
self._check_vars.clear()
for ctrl in self._controllers:
var = tk.BooleanVar(value=ctrl.selected)
self._check_vars.append(var)
cb = ttk.Checkbutton(
self._check_frame,
text=ctrl.display_label,
variable=var,
command=lambda c=ctrl, v=var: setattr(c, "selected", v.get()),
)
cb.pack(anchor=tk.W)
def _select_all(self):
for var in self._check_vars:
var.set(True)
for ctrl in self._controllers:
ctrl.selected = True
def _deselect_all(self):
for var in self._check_vars:
var.set(False)
for ctrl in self._controllers:
ctrl.selected = False
def _log_message(self, msg: str):
self._log.config(state=tk.NORMAL)
self._log.insert(tk.END, msg + "\n")
self._log.see(tk.END)
self._log.config(state=tk.DISABLED)
def _on_sync(self):
if self._syncing:
return
selected = [c for c in self._controllers if c.selected]
if not selected:
self._log_message("동기화할 컨트롤러가 없습니다.")
return
self._syncing = True
self._btn_sync.config(state=tk.DISABLED)
self._log_message(f"--- 동기화 시작: {datetime.now().strftime('%H:%M:%S')} ---")
threading.Thread(target=self._sync_thread, daemon=True).start()
def _sync_thread(self):
results = sync_all(self._controllers)
self.after(0, self._sync_done, results)
def _sync_done(self, results):
self._syncing = False
self._btn_sync.config(state=tk.NORMAL)
ok_count = 0
for ctrl, ok, msg in results:
status = "OK" if ok else "FAIL"
self._log_message(f" [{status}] {ctrl.display_label}: {msg}")
if ok:
ok_count += 1
total = len(results)
self._log_message(f"--- 완료: {ok_count}/{total} 성공 ---\n")