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:
0
network/__init__.py
Normal file
0
network/__init__.py
Normal file
88
network/packet_parser.py
Normal file
88
network/packet_parser.py
Normal file
@@ -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
|
||||
77
network/tcp_protocol.py
Normal file
77
network/tcp_protocol.py
Normal file
@@ -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()
|
||||
177
network/udp_discovery.py
Normal file
177
network/udp_discovery.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user