From 3c14e1e40150f4946f290351422fbd269d98e021 Mon Sep 17 00:00:00 2001 From: insulee Date: Tue, 10 Feb 2026 11:10:55 +0900 Subject: [PATCH] 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 --- .gitignore | 18 + Dabit Time Manager.spec | 39 +++ RTC_Drift_Tester.spec | 38 +++ config.py | 37 +++ gui/__init__.py | 0 gui/app.py | 599 ++++++++++++++++++++++++++++++++++ gui/discovery_panel.py | 239 ++++++++++++++ gui/monitor_panel.py | 241 ++++++++++++++ gui/settings_dialog.py | 68 ++++ gui/sync_panel.py | 124 +++++++ icon.ico | Bin 0 -> 16582 bytes icon.png | Bin 0 -> 26434 bytes main.py | 18 + models/__init__.py | 0 models/controller.py | 33 ++ models/reading.py | 20 ++ network/__init__.py | 0 network/packet_parser.py | 88 +++++ network/tcp_protocol.py | 77 +++++ network/udp_discovery.py | 177 ++++++++++ services/__init__.py | 0 services/drift_monitor.py | 211 ++++++++++++ services/export_service.py | 103 ++++++ services/time_sync_service.py | 38 +++ utils/__init__.py | 0 utils/ip_format.py | 35 ++ utils/time_format.py | 37 +++ 27 files changed, 2240 insertions(+) create mode 100644 .gitignore create mode 100644 Dabit Time Manager.spec create mode 100644 RTC_Drift_Tester.spec create mode 100644 config.py create mode 100644 gui/__init__.py create mode 100644 gui/app.py create mode 100644 gui/discovery_panel.py create mode 100644 gui/monitor_panel.py create mode 100644 gui/settings_dialog.py create mode 100644 gui/sync_panel.py create mode 100644 icon.ico create mode 100644 icon.png create mode 100644 main.py create mode 100644 models/__init__.py create mode 100644 models/controller.py create mode 100644 models/reading.py create mode 100644 network/__init__.py create mode 100644 network/packet_parser.py create mode 100644 network/tcp_protocol.py create mode 100644 network/udp_discovery.py create mode 100644 services/__init__.py create mode 100644 services/drift_monitor.py create mode 100644 services/export_service.py create mode 100644 services/time_sync_service.py create mode 100644 utils/__init__.py create mode 100644 utils/ip_format.py create mode 100644 utils/time_format.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..05484df --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# Python cache +__pycache__/ +*.py[cod] +*$py.class + +# PyInstaller build artifacts +build/ +dist/ + +# Reference/documentation (large external files) +reference/ + +# IDE +.vscode/ +.idea/ + +# Logs +*.log diff --git a/Dabit Time Manager.spec b/Dabit Time Manager.spec new file mode 100644 index 0000000..c72a005 --- /dev/null +++ b/Dabit Time Manager.spec @@ -0,0 +1,39 @@ +# -*- mode: python ; coding: utf-8 -*- + + +a = Analysis( + ['main.py'], + pathex=[], + binaries=[], + datas=[('icon.ico', '.')], + hiddenimports=[], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name='Dabit Time Manager', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=False, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, + icon=['icon.ico'], +) diff --git a/RTC_Drift_Tester.spec b/RTC_Drift_Tester.spec new file mode 100644 index 0000000..b99ba44 --- /dev/null +++ b/RTC_Drift_Tester.spec @@ -0,0 +1,38 @@ +# -*- mode: python ; coding: utf-8 -*- + + +a = Analysis( + ['main.py'], + pathex=[], + binaries=[], + datas=[], + hiddenimports=[], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name='RTC_Drift_Tester', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=False, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) diff --git a/config.py b/config.py new file mode 100644 index 0000000..4216a4a --- /dev/null +++ b/config.py @@ -0,0 +1,37 @@ +"""상수 및 기본 설정""" + +# UDP 포트 +UDP_PORT_ETHERNET = 5108 +UDP_PORT_WIFI = 5107 +UDP_BROADCAST_IP = "255.255.255.255" + +# UDP 명령 +SEARCH_CMD = "SEARCHING DIBD B\r\n" +SETT_PREFIX = "SETT " +RESET_PREFIX = "RESET " + +# UDP 수신 버퍼 +UDP_RECV_BUFFER = 1024 +UDP_RECV_TIMEOUT = 3.0 # 초 + +# TCP 설정 +TCP_TIMEOUT = 5.0 # 초 +TCP_RECV_BUFFER = 256 + +# ASCII 프로토콜 +CMD_TIME_SYNC = "30" # PC 시간 동기화 +CMD_TIME_READ = "31" # 컨트롤러 시간 읽기 + +# DIBD 응답 패킷 크기 +DIBD_PACKET_SIZE = 222 + +# 모니터링 기본 주기 (초) +DEFAULT_MONITOR_INTERVAL = 3600 # 1시간 + +# 인코딩 +PROTOCOL_ENCODING = "euc-kr" # MS949 호환 + +# GUI +APP_TITLE = "Dabit Time Manager" +APP_WIDTH = 1050 +APP_HEIGHT = 720 diff --git a/gui/__init__.py b/gui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/gui/app.py b/gui/app.py new file mode 100644 index 0000000..a6008e7 --- /dev/null +++ b/gui/app.py @@ -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("", 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("<>", 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() diff --git a/gui/discovery_panel.py b/gui/discovery_panel.py new file mode 100644 index 0000000..bfc0fb7 --- /dev/null +++ b/gui/discovery_panel.py @@ -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("<>", 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("실패", "리셋 전송에 실패했습니다.") diff --git a/gui/monitor_panel.py b/gui/monitor_panel.py new file mode 100644 index 0000000..3e06cc4 --- /dev/null +++ b/gui/monitor_panel.py @@ -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() diff --git a/gui/settings_dialog.py b/gui/settings_dialog.py new file mode 100644 index 0000000..728d7e4 --- /dev/null +++ b/gui/settings_dialog.py @@ -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) diff --git a/gui/sync_panel.py b/gui/sync_panel.py new file mode 100644 index 0000000..f7f25af --- /dev/null +++ b/gui/sync_panel.py @@ -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") diff --git a/icon.ico b/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..ef3dfafade6a0f85b0bce7e1d293f8c62d8f3b32 GIT binary patch literal 16582 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 h?mEhV*p%A$lwcZq;>H!LC`A)PBJC9!np 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 y@F{15REDF=vv+dJQ*yUt=2Zz!X8( zl#mSEE+za7Lg(}b8(>oN-!-_?5voXX@(bcejgs3KR43kDwChiq={6f8(j5Q2RU*hB{-TrXoDNmg+CX?Zd{Rl# z`)EVDi5v-k0s@rZ>7=wa*TFO78s1IT+8~)&1!QMFPsrKsD|*56H9!fGHkWiGGxIM7 ztwoC#lve~m?T(}t7v6$UiQmL{zO`kW+rhoJo(_?Cpe7?yjGlaSgbMgF#uR{yfyusF zK4`uFhvncQJ7tb~F#UV$5doYq@qjLd@4R_Hd@y+L1yBWxcOma0AoGzw{KIbcs7*LC zOpXJLFbMY_avR>#kJyt&QMER#rECZQQ7Gpw-y%-M*>*5IHb>-o-`s7=(w5DJ`HQE2 z#C9DPAjNAdLFHz^cNv46rGc-zTG<^ch&1n;Gf+C{w)+oxmu&d}JnN%Lj^)OHbX1j~ zoJ^a&-tUgqhC4wwV3ni?QS`o;aKRGg61XE7nWT;fhkRBOliho3j~*s!==_<(t9x^! zlyG0o9}OOnBhR%NFb+V#tk2>-Lt=QV5$oulF}DEET&n^iP3gvtQp)JGw_Q@(G0NfS z1l{ROy8D|1-mQ4t^WeDq#szvfj4OlsR8X=$sZl-?|Iuef%Sm*7Uvm<=AXffx%@CaM zTz(`wYvS>^RiYXMnBIOIH_ff8aNxXgNAP`$GhVlY&eO5dTyxm_<-s8@8RUn5?2)GI zR5Y`Q2M{jM0bx7R9jStN9A7IpaCoj7Wz|l>=^dBj|D+<~F)|tz-79{LL*>s7&)Sh5 zcXNku0FYX#?7yYqlQBY9u{+ZLE+OZl)eTq%S2t;!R%cal%T_@Xlm(f ze1oWA?nlnx`oH~IzOJ-XxJ|7w;(mb?0jP4A{+$^E;F4{EMcBtRobht-n8u!MB4_a4 zzqf-@wk@Ox(>x_`nxM@}h+KD9U&!8F<8=$$j`*L70zC9xC9rtrn+rxrX1{Bi@=g4f zm_$4h;#aAOeR+_Rwd-eeTn?_We9ri7GJn@bB%MjB@YQB>pHJ+&zgNh6)(< zLN;{aA>Wq7^YHoN`KjU`KiqhbTQ|;?kf6oRVL(~jg%xKi(DgPdKaK@#!pt(7C}lL> zhIF*MxvfV}2i|(@YZlTX7u=;dOY06%-vtmbs)pq}4sp0YX1|ZGG`;Bfw(z&`(XQK9g52(VR4{*CF0mgCJ*RP3D zTsHB6;uN~o)>e7Xt%Bu0%s3Z=PC5O7%2hwtyMHoH%IdH+sfj(|{30Dfo1+@jDK41~ z_lFGQNYY!ThLBQf2x|b0%;b&oR3d~rR!^b?0iBRK=tqVS6}>Y31+XRKqs?5c# str: + """GUI 표시용 라벨: 이름(IP:포트)""" + if self.name: + return f"{self.name}({self.ip}:{self.port})" + return f"{self.ip}:{self.port}" diff --git a/models/reading.py b/models/reading.py new file mode 100644 index 0000000..d1087b7 --- /dev/null +++ b/models/reading.py @@ -0,0 +1,20 @@ +"""TimeReading 데이터클래스""" + +from dataclasses import dataclass +from datetime import datetime + + +@dataclass +class TimeReading: + """시간 읽기 결과""" + controller_mac: str + controller_label: str + pc_time: datetime + controller_time: datetime + drift_seconds: float + + @property + def drift_display(self) -> str: + """오차 표시용 문자열""" + sign = "+" if self.drift_seconds >= 0 else "" + return f"{sign}{self.drift_seconds:.1f}초" diff --git a/network/__init__.py b/network/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/network/packet_parser.py b/network/packet_parser.py new file mode 100644 index 0000000..3423400 --- /dev/null +++ b/network/packet_parser.py @@ -0,0 +1,88 @@ +"""DIBD 응답 패킷 파싱 (가변 길이 지원)""" + +from models.controller import Controller +from config import PROTOCOL_ENCODING + + +def parse_dibd_packet(data: bytes) -> Controller | None: + """DIBD 응답 패킷을 파싱하여 Controller 객체를 반환. + + 패킷 길이: + - 이더넷 전용 모델: 174바이트 (DNS Name까지) + - WiFi 지원 모델: 222바이트 (AP Mode까지) + + 패킷 구조 (0x88 응답, \r\n으로 필드 구분): + 0-5: 'DIBD\\r\\n' + 6-24: MAC (17자) + \\r\\n + 25-41: Local IP (15자) + \\r\\n + 42-58: Subnet Mask (15자) + \\r\\n + 59-75: Gateway IP (15자) + \\r\\n + 76-82: Port (5자) + \\r\\n + 83-86: DHCP Mode (2자) + \\r\\n + 87-103: Server IP (15자) + \\r\\n + 104-110: Server Port (5자) + \\r\\n + 111-114: Server Mode (2자) + \\r\\n + 115-136: Name (20자) + \\r\\n + 137-141: KeepAlive (3자) + \\r\\n + 142-173: DNS Name (30자) + \\r\\n ← 174바이트 여기까지 + 174-195: AP SSID Name (20자) + \\r\\n ← 선택 + 196-217: AP SSID PW (20자) + \\r\\n ← 선택 + 218-221: AP Mode (2자) + \\r\\n ← 선택 + """ + try: + text = data.decode(PROTOCOL_ENCODING, errors="replace") + except Exception: + return None + + # 최소 길이: KeepAlive 필드까지 = 142바이트 + if len(text) < 142: + return None + + if not text.startswith("DIBD"): + return None + + ctrl = Controller() + + ctrl.mac = text[6:23].strip() + ctrl.ip = _normalize_ip(text[25:40].strip()) + ctrl.subnet = _normalize_ip(text[42:57].strip()) + ctrl.gateway = _normalize_ip(text[59:74].strip()) + + ctrl.port = _safe_int(text[76:81].strip(), 5000) + ctrl.dhcp_mode = _safe_int(text[83:85].strip(), 30) % 30 + ctrl.server_ip = _normalize_ip(text[87:102].strip()) + ctrl.server_port = _safe_int(text[104:109].strip(), 0) + ctrl.server_mode = _safe_int(text[111:113].strip(), 30) % 30 + + ctrl.name = text[115:135].strip() + ctrl.keep_alive = _safe_int(text[137:140].strip(), 0) + + # DNS Name (선택 - 174바이트 이상) + if len(text) >= 172: + ctrl.dns_name = text[142:172].strip() + + # AP SSID (선택 - 222바이트 패킷) + if len(text) >= 194: + ctrl.ap_ssid_name = text[174:194].strip() + if len(text) >= 216: + ctrl.ap_ssid_pw = text[196:216].strip() + if len(text) >= 220: + ctrl.ap_mode = _safe_int(text[218:220].strip(), 30) % 30 + + return ctrl + + +def _normalize_ip(ip_str: str) -> str: + """패딩된 IP(예: '192.168.000.074')를 일반형(예: '192.168.0.74')으로 변환.""" + try: + parts = ip_str.split(".") + return ".".join(str(int(p)) for p in parts) + except (ValueError, IndexError): + return ip_str + + +def _safe_int(s: str, default: int = 0) -> int: + try: + return int(s) + except (ValueError, TypeError): + return default diff --git a/network/tcp_protocol.py b/network/tcp_protocol.py new file mode 100644 index 0000000..485f129 --- /dev/null +++ b/network/tcp_protocol.py @@ -0,0 +1,77 @@ +"""ASCII 프로토콜 (시간 동기화 cmd30, 시간 읽기 cmd31)""" + +import socket +from datetime import datetime + +from config import TCP_TIMEOUT, TCP_RECV_BUFFER, PROTOCOL_ENCODING +from utils.time_format import datetime_to_protocol, protocol_to_datetime + + +def sync_time(ip: str, port: int, dt: datetime | None = None) -> tuple[bool, str]: + """컨트롤러에 PC 시간 동기화 (명령 30). + + 패킷: ![0030YYMMDDdHHMMSS!] + 예시: ![003026020912 30229!] (2026-02-09 월 23:02:29) + 응답: ![00300!] (성공) / ![0030F!] (실패) + + Returns: (성공여부, 메시지) + """ + if dt is None: + dt = datetime.now() + + time_str = datetime_to_protocol(dt) + cmd = f"![0030{time_str}!]" + + try: + resp = _tcp_send_recv(ip, port, cmd) + except Exception as e: + return False, f"연결 실패: {e}" + + if "![00300!]" in resp: + return True, "동기화 성공" + elif "![0030F!]" in resp: + return False, "동기화 실패 (컨트롤러 응답 F)" + else: + return False, f"알 수 없는 응답: {resp}" + + +def read_time(ip: str, port: int) -> tuple[datetime | None, datetime, datetime, str]: + """컨트롤러 시간 읽기 (명령 31). + + 패킷: ![0031!] + 응답: ![0031YYMMDDdHHMMSS!] (13자 시간 데이터) + + Returns: (컨트롤러시간 | None, PC시간_before, PC시간_after, 에러메시지) + """ + cmd = "![0031!]" + + pc_before = datetime.now() + try: + resp = _tcp_send_recv(ip, port, cmd) + except Exception as e: + pc_after = datetime.now() + return None, pc_before, pc_after, f"연결 실패: {e}" + pc_after = datetime.now() + + # 응답 파싱: ![0031YYMMDDdHHMMSS!] + # "![0031" 뒤에 13자의 시간 데이터 + try: + start = resp.index("![0031") + 6 + time_data = resp[start:start + 13] + ctrl_time = protocol_to_datetime(time_data) + return ctrl_time, pc_before, pc_after, "" + except (ValueError, IndexError) as e: + return None, pc_before, pc_after, f"응답 파싱 실패: {resp} ({e})" + + +def _tcp_send_recv(ip: str, port: int, cmd: str) -> str: + """TCP 연결하여 명령 전송 후 응답 수신.""" + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(TCP_TIMEOUT) + try: + sock.connect((ip, port)) + sock.sendall(cmd.encode(PROTOCOL_ENCODING)) + resp = sock.recv(TCP_RECV_BUFFER) + return resp.decode(PROTOCOL_ENCODING, errors="replace") + finally: + sock.close() diff --git a/network/udp_discovery.py b/network/udp_discovery.py new file mode 100644 index 0000000..2afd282 --- /dev/null +++ b/network/udp_discovery.py @@ -0,0 +1,177 @@ +"""UDP 검색, SETT 설정, RESET""" + +import socket +import struct +import time +import threading +from typing import Callable + +from config import ( + UDP_PORT_ETHERNET, UDP_PORT_WIFI, UDP_BROADCAST_IP, + SEARCH_CMD, SETT_PREFIX, RESET_PREFIX, + UDP_RECV_BUFFER, UDP_RECV_TIMEOUT, PROTOCOL_ENCODING, +) +from models.controller import Controller +from network.packet_parser import parse_dibd_packet +from utils.ip_format import pad_ip, sized_string + + +def get_network_interfaces() -> list[tuple[str, str]]: + """사용 가능한 네트워크 인터페이스 목록 반환. + Returns: [(ip, name), ...] + """ + interfaces = [] + try: + hostname = socket.gethostname() + addrs = socket.getaddrinfo(hostname, None, socket.AF_INET) + seen = set() + for addr in addrs: + ip = addr[4][0] + if ip not in seen and not ip.startswith("127."): + seen.add(ip) + interfaces.append((ip, ip)) + except Exception: + pass + if not interfaces: + interfaces.append(("0.0.0.0", "기본")) + return interfaces + + +def search_controllers( + bind_ip: str = "0.0.0.0", + timeout: float = UDP_RECV_TIMEOUT, + on_found: Callable[[Controller], None] | None = None, + on_log: Callable[[str], None] | None = None, +) -> list[Controller]: + """UDP 브로드캐스트로 컨트롤러를 검색. + + Java 참조: + - 소켓을 특정 NIC IP + 고정 포트(5109)에 바인딩 + - 검색 패킷을 5108, 5107 순서로 100ms 간격 전송 + - 별도 리스닝 스레드에서 응답 수신 (블로킹) + """ + def log(msg: str): + if on_log: + on_log(msg) + + results: list[Controller] = [] + mac_set: set[str] = set() + + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + + # Java: 특정 NIC IP + 포트 5109에 바인딩 + bind_port = 5109 + try: + sock.bind((bind_ip, bind_port)) + log(f"소켓 바인딩: {bind_ip}:{bind_port}") + except OSError: + try: + sock.bind((bind_ip, 0)) + local_addr = sock.getsockname() + log(f"포트 5109 사용 불가, 대체 바인딩: {local_addr[0]}:{local_addr[1]}") + except OSError as e: + log(f"소켓 바인딩 실패: {e}") + sock.close() + return results + + cmd = SEARCH_CMD.encode(PROTOCOL_ENCODING) + log(f"검색 패킷: {SEARCH_CMD.strip()!r} ({len(cmd)}바이트)") + + # 이더넷 포트(5108)로 전송 + try: + sock.sendto(cmd, (UDP_BROADCAST_IP, UDP_PORT_ETHERNET)) + log(f"전송 → {UDP_BROADCAST_IP}:{UDP_PORT_ETHERNET}") + except Exception as e: + log(f"전송 실패 (5108): {e}") + + time.sleep(0.1) + + # WiFi 포트(5107)로 전송 + try: + sock.sendto(cmd, (UDP_BROADCAST_IP, UDP_PORT_WIFI)) + log(f"전송 → {UDP_BROADCAST_IP}:{UDP_PORT_WIFI}") + except Exception as e: + log(f"전송 실패 (5107): {e}") + + # 응답 수신: 짧은 per-recv 타임아웃(0.5초) + 긴 전체 대기시간 + sock.settimeout(0.5) + deadline = time.time() + timeout + recv_count = 0 + + while time.time() < deadline: + try: + data, addr = sock.recvfrom(UDP_RECV_BUFFER) + recv_count += 1 + log(f"수신 #{recv_count}: {addr[0]}:{addr[1]}, {len(data)}바이트") + except socket.timeout: + continue # 타임아웃이면 deadline까지 계속 시도 + except OSError as e: + log(f"수신 오류: {e}") + break + + ctrl = parse_dibd_packet(data) + if ctrl and ctrl.mac: + if ctrl.mac not in mac_set: + mac_set.add(ctrl.mac) + results.append(ctrl) + log(f"발견: {ctrl.mac} ({ctrl.ip}:{ctrl.port} {ctrl.name})") + if on_found: + on_found(ctrl) + elif data: + header = data[:4].decode(PROTOCOL_ENCODING, errors="replace") + log(f"무시됨: 헤더={header!r}, 길이={len(data)}") + + sock.close() + log(f"검색 완료: {len(results)}개 발견 (총 {recv_count}개 패킷 수신)") + return results + + +def send_sett(ctrl: Controller, bind_ip: str = "0.0.0.0") -> bool: + """SETT 명령으로 컨트롤러 설정 변경.""" + data = SETT_PREFIX + data += ctrl.mac + " " + data += pad_ip(ctrl.ip) + " " + data += pad_ip(ctrl.subnet) + " " + data += pad_ip(ctrl.gateway) + " " + data += f"{ctrl.port:05d}" + " " + data += f"3{ctrl.dhcp_mode}" + " " + data += pad_ip(ctrl.server_ip) + " " + data += f"{ctrl.server_port:05d}" + " " + data += f"3{ctrl.server_mode}" + " " + data += sized_string(ctrl.name, 20) + " " + data += f"{ctrl.keep_alive:03d}" + " " + data += sized_string(ctrl.dns_name, 30) + " " + data += sized_string(ctrl.ap_ssid_name, 20) + " " + data += sized_string(ctrl.ap_ssid_pw, 20) + " " + data += f"3{ctrl.ap_mode}" + data += "\r\n" + + return _send_udp(data, bind_ip) + + +def send_reset(mac: str, bind_ip: str = "0.0.0.0") -> bool: + """RESET 명령 전송.""" + data = RESET_PREFIX + mac + "\r\n" + return _send_udp(data, bind_ip) + + +def _send_udp(data: str, bind_ip: str) -> bool: + """UDP 패킷을 브로드캐스트로 전송.""" + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) + try: + sock.bind((bind_ip, 0)) + except OSError: + sock.bind(("0.0.0.0", 0)) + + buf = data.encode(PROTOCOL_ENCODING) + sock.sendto(buf, (UDP_BROADCAST_IP, UDP_PORT_ETHERNET)) + time.sleep(0.1) + sock.sendto(buf, (UDP_BROADCAST_IP, UDP_PORT_WIFI)) + sock.close() + return True + except Exception: + return False diff --git a/services/__init__.py b/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/drift_monitor.py b/services/drift_monitor.py new file mode 100644 index 0000000..e75c37b --- /dev/null +++ b/services/drift_monitor.py @@ -0,0 +1,211 @@ +"""주기적 시간 읽기 및 오차 추적 서비스""" + +import threading +from concurrent.futures import ThreadPoolExecutor, as_completed +from datetime import datetime, timedelta +from typing import Callable + +from models.controller import Controller +from models.reading import TimeReading +from network.tcp_protocol import read_time +from config import DEFAULT_MONITOR_INTERVAL + + +class DriftMonitor: + """주기적으로 컨트롤러 시간을 읽어 오차를 추적.""" + + def __init__(self): + self._timer: threading.Timer | None = None + self._hourly_timer: threading.Timer | None = None + self._running = False + self._hourly_running = False + self._interval = DEFAULT_MONITOR_INTERVAL + self._controllers: list[Controller] = [] + self._readings: list[TimeReading] = [] + self._lock = threading.Lock() + self._on_readings: Callable[[list[TimeReading]], None] | None = None + self._on_error: Callable[[str], None] | None = None + + @property + def is_running(self) -> bool: + return self._running + + @property + def is_hourly_running(self) -> bool: + return self._hourly_running + + @property + def readings(self) -> list[TimeReading]: + with self._lock: + return list(self._readings) + + @property + def interval(self) -> int: + return self._interval + + def configure( + self, + controllers: list[Controller], + interval: int = DEFAULT_MONITOR_INTERVAL, + on_readings: Callable[[list[TimeReading]], None] | None = None, + on_error: Callable[[str], None] | None = None, + ): + self._controllers = controllers + self._interval = interval + self._on_readings = on_readings + self._on_error = on_error + + def start(self): + if self._running: + return + self._running = True + self._do_read() + + def stop(self): + self._running = False + if self._timer: + self._timer.cancel() + self._timer = None + + def start_hourly(self): + """매시 정시(XX:00:00)에 시간 읽기 시작.""" + if self._hourly_running: + return + self._hourly_running = True + self._schedule_hourly() + + def stop_hourly(self): + """매시 정시 읽기 중지.""" + self._hourly_running = False + if self._hourly_timer: + self._hourly_timer.cancel() + self._hourly_timer = None + + def _schedule_hourly(self): + """다음 정시까지 대기 후 읽기 예약.""" + if not self._hourly_running: + return + now = datetime.now() + # 다음 정시: 현재 시 + 1, 분/초 = 0 + next_hour = now.replace(minute=0, second=0, microsecond=0) + next_hour = next_hour + timedelta(hours=1) + delay = (next_hour - now).total_seconds() + if self._on_error: + self._on_error(f"정시 읽기 예약: {next_hour.strftime('%H:%M:%S')} ({delay:.0f}초 후)") + self._hourly_timer = threading.Timer(delay, self._hourly_tick) + self._hourly_timer.daemon = True + self._hourly_timer.start() + + def _hourly_tick(self): + """정시 도달 시 읽기 실행 후 다음 정시 예약.""" + if not self._hourly_running: + return + self._do_read(schedule_next=False) + self._schedule_hourly() + + def read_once(self): + """즉시 한 번 읽기 (별도 스레드).""" + threading.Thread(target=self._do_read, args=(False,), daemon=True).start() + + def clear_readings(self): + with self._lock: + self._readings.clear() + + def _do_read(self, schedule_next: bool = True): + """모든 컨트롤러에서 시간 읽기.""" + targets = [c for c in self._controllers if c.selected] + if not targets: + if self._running and schedule_next: + self._schedule_next() + return + + new_readings: list[TimeReading] = [] + + with ThreadPoolExecutor(max_workers=8) as executor: + future_map = { + executor.submit(read_time, c.ip, c.port): c + for c in targets + } + for future in as_completed(future_map): + ctrl = future_map[future] + try: + ctrl_time, pc_before, pc_after, error = future.result() + except Exception as e: + if self._on_error: + self._on_error(f"{ctrl.display_label}: {e}") + continue + + if ctrl_time is None: + if self._on_error: + self._on_error(f"{ctrl.display_label}: {error}") + continue + + # 네트워크 지연 보정: PC 시간 중간값 + pc_mid = pc_before + (pc_after - pc_before) / 2 + drift = (ctrl_time - pc_mid).total_seconds() + + reading = TimeReading( + controller_mac=ctrl.mac, + controller_label=ctrl.display_label, + pc_time=pc_mid, + controller_time=ctrl_time, + drift_seconds=drift, + ) + new_readings.append(reading) + + with self._lock: + self._readings.extend(new_readings) + + if self._on_readings and new_readings: + self._on_readings(new_readings) + + if self._running and schedule_next: + self._schedule_next() + + def _schedule_next(self): + self._timer = threading.Timer(self._interval, self._do_read) + self._timer.daemon = True + self._timer.start() + + def get_summary(self) -> dict[str, dict]: + """컨트롤러별 오차 요약 통계. + + Returns: + {mac: {label, count, avg_drift, max_drift, drift_per_hour, first_time, last_time}} + """ + with self._lock: + readings = list(self._readings) + + by_mac: dict[str, list[TimeReading]] = {} + for r in readings: + by_mac.setdefault(r.controller_mac, []).append(r) + + summary = {} + for mac, rlist in by_mac.items(): + rlist.sort(key=lambda r: r.pc_time) + drifts = [r.drift_seconds for r in rlist] + count = len(drifts) + avg_drift = sum(drifts) / count if count else 0 + max_drift = max(abs(d) for d in drifts) if drifts else 0 + + # 시간당 오차율 + drift_per_hour = 0.0 + if count >= 2: + elapsed_hours = ( + rlist[-1].pc_time - rlist[0].pc_time + ).total_seconds() / 3600 + if elapsed_hours > 0: + drift_change = rlist[-1].drift_seconds - rlist[0].drift_seconds + drift_per_hour = drift_change / elapsed_hours + + summary[mac] = { + "label": rlist[0].controller_label, + "count": count, + "avg_drift": avg_drift, + "max_drift": max_drift, + "drift_per_hour": drift_per_hour, + "first_time": rlist[0].pc_time, + "last_time": rlist[-1].pc_time, + } + + return summary diff --git a/services/export_service.py b/services/export_service.py new file mode 100644 index 0000000..3dd47b4 --- /dev/null +++ b/services/export_service.py @@ -0,0 +1,103 @@ +"""CSV export 서비스""" + +import csv +import os +from pathlib import Path + +from models.reading import TimeReading + + +def export_readings_csv( + readings: list[TimeReading], + filepath: str | Path, +) -> str: + """시간 읽기 기록을 CSV로 내보내기 (컨트롤러별 정렬). + + Returns: 저장된 파일 경로 + """ + filepath = Path(filepath) + # 컨트롤러별로 정렬 + sorted_readings = sorted(readings, key=lambda r: (r.controller_mac, r.pc_time)) + + with open(filepath, "w", newline="", encoding="utf-8-sig") as f: + writer = csv.writer(f) + writer.writerow([ + "컨트롤러", + "MAC", + "PC 시간", + "컨트롤러 시간", + "오차(초)", + ]) + for r in sorted_readings: + writer.writerow([ + r.controller_label, + r.controller_mac, + r.pc_time.strftime("%Y-%m-%d %H:%M:%S"), + r.controller_time.strftime("%Y-%m-%d %H:%M:%S"), + f"{r.drift_seconds:.1f}", + ]) + return str(filepath) + + +def export_per_controller_csv( + readings: list[TimeReading], + folder: str | Path, +) -> list[str]: + """컨트롤러별 개별 CSV 파일 생성. + + Returns: 생성된 파일 경로 목록 + """ + folder = Path(folder) + os.makedirs(folder, exist_ok=True) + + by_mac: dict[str, list[TimeReading]] = {} + for r in readings: + by_mac.setdefault(r.controller_mac, []).append(r) + + created = [] + for mac, rlist in by_mac.items(): + rlist.sort(key=lambda r: r.pc_time) + # 파일명: 컨트롤러 이름 기반 (특수문자 제거) + safe_name = rlist[0].controller_label + for ch in r'<>:"/\\|?*': + safe_name = safe_name.replace(ch, "_") + filepath = folder / f"{safe_name}_drift.csv" + export_readings_csv(rlist, filepath) + created.append(str(filepath)) + + return created + + +def export_summary_csv( + summary: dict[str, dict], + filepath: str | Path, +) -> str: + """오차 요약 통계를 CSV로 내보내기. + + Returns: 저장된 파일 경로 + """ + filepath = Path(filepath) + with open(filepath, "w", newline="", encoding="utf-8-sig") as f: + writer = csv.writer(f) + writer.writerow([ + "컨트롤러", + "MAC", + "읽기 횟수", + "평균 오차(초)", + "최대 오차(초)", + "시간당 오차율(초/시간)", + "첫 읽기", + "마지막 읽기", + ]) + for mac, s in summary.items(): + writer.writerow([ + s["label"], + mac, + s["count"], + f"{s['avg_drift']:.2f}", + f"{s['max_drift']:.2f}", + f"{s['drift_per_hour']:.3f}", + s["first_time"].strftime("%Y-%m-%d %H:%M:%S"), + s["last_time"].strftime("%Y-%m-%d %H:%M:%S"), + ]) + return str(filepath) diff --git a/services/time_sync_service.py b/services/time_sync_service.py new file mode 100644 index 0000000..8137a98 --- /dev/null +++ b/services/time_sync_service.py @@ -0,0 +1,38 @@ +"""전체 컨트롤러 시간 동기화 서비스""" + +from concurrent.futures import ThreadPoolExecutor, as_completed +from datetime import datetime + +from models.controller import Controller +from network.tcp_protocol import sync_time + + +def sync_all( + controllers: list[Controller], + max_workers: int = 8, +) -> list[tuple[Controller, bool, str]]: + """선택된 컨트롤러에 일괄 시간 동기화. + + Returns: [(Controller, 성공여부, 메시지), ...] + """ + targets = [c for c in controllers if c.selected] + if not targets: + return [] + + dt = datetime.now() + results: list[tuple[Controller, bool, str]] = [] + + with ThreadPoolExecutor(max_workers=max_workers) as executor: + future_map = { + executor.submit(sync_time, c.ip, c.port, dt): c + for c in targets + } + for future in as_completed(future_map): + ctrl = future_map[future] + try: + ok, msg = future.result() + except Exception as e: + ok, msg = False, str(e) + results.append((ctrl, ok, msg)) + + return results diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/utils/ip_format.py b/utils/ip_format.py new file mode 100644 index 0000000..b3236c8 --- /dev/null +++ b/utils/ip_format.py @@ -0,0 +1,35 @@ +"""IP 주소 패딩/정규화 유틸리티""" + + +def pad_ip(ip: str) -> str: + """IP 주소 각 옥텟을 3자리로 패딩. + 예: '192.168.0.74' -> '192.168.000.074' + """ + parts = ip.split(".") + padded = [] + for part in parts: + part = part.strip() + padded.append(part.zfill(3)[:3]) + return ".".join(padded) + + +def normalize_ip(ip: str) -> str: + """패딩된 IP를 일반 형태로 변환. + 예: '192.168.000.074' -> '192.168.0.74' + """ + parts = ip.split(".") + return ".".join(str(int(p)) for p in parts) + + +def sized_string(data: str, length: int, encoding: str = "euc-kr") -> str: + """문자열을 지정 바이트 길이로 맞춤 (공백 패딩). MS949/EUC-KR 한글 지원.""" + result = "" + byte_count = 0 + for ch in data: + ch_bytes = len(ch.encode(encoding, errors="replace")) + if byte_count + ch_bytes > length: + break + result += ch + byte_count += ch_bytes + result += " " * (length - byte_count) + return result diff --git a/utils/time_format.py b/utils/time_format.py new file mode 100644 index 0000000..272f7ee --- /dev/null +++ b/utils/time_format.py @@ -0,0 +1,37 @@ +"""날짜시간 <-> 프로토콜 포맷 변환 유틸리티""" + +from datetime import datetime + + +def datetime_to_protocol(dt: datetime) -> str: + """datetime을 프로토콜 포맷으로 변환. + 포맷: YYMMDDdHHMMSS (13자) + d: 요일 (0=일, 1=월, 2=화, 3=수, 4=목, 5=금, 6=토) + + 예: 2026-02-09 23:02:29 (월) → '2602091230229' + """ + yy = dt.strftime("%y") + mm = dt.strftime("%m") + dd = dt.strftime("%d") + dow = dt.isoweekday() % 7 # 7->0(일), 1->1(월), ..., 6->6(토) + hh = dt.strftime("%H") + mi = dt.strftime("%M") + ss = dt.strftime("%S") + return f"{yy}{mm}{dd}{dow}{hh}{mi}{ss}" + + +def protocol_to_datetime(data: str) -> datetime: + """프로토콜 포맷 문자열을 datetime으로 변환. + 입력: YYMMDDdHHMMSS (13자) + + 예: '2602091230229' → 2026-02-09 23:02:29 + """ + yy = int(data[0:2]) + mm = int(data[2:4]) + dd = int(data[4:6]) + # data[6] = 요일 (무시) + hh = int(data[7:9]) + mi = int(data[9:11]) + ss = int(data[11:13]) + year = 2000 + yy + return datetime(year, mm, dd, hh, mi, ss)