"""EV 충전 시뮬레이터 GUI 어떤 PC에서든 실행 가능한 충전 테스트 도구. tkinter 기반 — 추가 설치 없이 Python만 있으면 실행. 사용법: pip install httpx python ev_simulator.py 요구사항: Python 3.7+ httpx (pip install httpx) """ import json import threading import time import tkinter as tk from tkinter import ttk, scrolledtext, messagebox from datetime import datetime try: import httpx except ImportError: print("httpx 패키지가 필요합니다: pip install httpx") exit(1) # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # 색상 테마 # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ BG = "#0c1018" BG2 = "#121824" BG3 = "#1a2236" BG_INPUT = "#1e2a3a" FG = "#e2e8f0" FG2 = "#94a3b8" FG3 = "#64748b" ACCENT = "#00d4ff" GREEN = "#10b981" AMBER = "#f59e0b" RED = "#ef4444" PURPLE = "#8b5cf6" BORDER = "#2a3448" class EVSimulator: def __init__(self, root): self.root = root self.root.title("EV 충전 시뮬레이터") self.root.configure(bg=BG) self.root.geometry("1100x820") self.root.minsize(900, 700) self.running = False self.session_uid = "" self.id_tag = "" self._build_ui() self._apply_preset("basic") # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # UI 구성 # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ def _build_ui(self): # 스타일 설정 style = ttk.Style() style.theme_use("clam") style.configure(".", background=BG, foreground=FG, fieldbackground=BG_INPUT) style.configure("TFrame", background=BG) style.configure("TLabel", background=BG, foreground=FG, font=("Segoe UI", 10)) style.configure("TLabelframe", background=BG, foreground=ACCENT, font=("Segoe UI", 10, "bold")) style.configure("TLabelframe.Label", background=BG, foreground=ACCENT) style.configure("TEntry", fieldbackground=BG_INPUT, foreground=FG) style.configure("TCombobox", fieldbackground=BG_INPUT, foreground=FG) style.configure("TButton", background=BG3, foreground=FG, font=("Segoe UI", 10)) style.map("TButton", background=[("active", BG2)]) style.configure("Accent.TButton", background="#0e3a4a", foreground=ACCENT, font=("Segoe UI", 11, "bold")) style.map("Accent.TButton", background=[("active", "#15485c")]) style.configure("Green.TButton", background="#0a3028", foreground=GREEN, font=("Segoe UI", 10)) style.configure("Red.TButton", background="#3a1010", foreground=RED, font=("Segoe UI", 10)) style.configure("TNotebook", background=BG) style.configure("TNotebook.Tab", background=BG3, foreground=FG2, padding=[12, 6]) style.map("TNotebook.Tab", background=[("selected", BG2)], foreground=[("selected", ACCENT)]) # 상단 헤더 header = tk.Frame(self.root, bg=BG, pady=8) header.pack(fill="x", padx=16) tk.Label(header, text="⚡ EV Charging Simulator", font=("Segoe UI", 16, "bold"), bg=BG, fg="#fff").pack(side="left") tk.Label(header, text="OCPP TEST CONSOLE", font=("Consolas", 9), bg=BG, fg=ACCENT).pack(side="left", padx=(12, 0), pady=(4, 0)) # 메인 영역 (좌/우 분할) main = tk.Frame(self.root, bg=BG) main.pack(fill="both", expand=True, padx=16, pady=(0, 16)) # 좌측: 파라미터 left = tk.Frame(main, bg=BG, width=380) left.pack(side="left", fill="y", padx=(0, 12)) left.pack_propagate(False) self._build_params(left) # 우측: 결과 right = tk.Frame(main, bg=BG) right.pack(side="left", fill="both", expand=True) self._build_results(right) def _build_params(self, parent): """파라미터 패널""" # 스크롤 가능한 캔버스 canvas = tk.Canvas(parent, bg=BG, highlightthickness=0) scrollbar = ttk.Scrollbar(parent, orient="vertical", command=canvas.yview) scroll_frame = tk.Frame(canvas, bg=BG) scroll_frame.bind("", lambda e: canvas.configure(scrollregion=canvas.bbox("all"))) canvas.create_window((0, 0), window=scroll_frame, anchor="nw", width=360) canvas.configure(yscrollcommand=scrollbar.set) canvas.pack(side="left", fill="both", expand=True) scrollbar.pack(side="right", fill="y") # 마우스 휠 스크롤 def _on_mousewheel(event): canvas.yview_scroll(int(-1 * (event.delta / 120)), "units") canvas.bind_all("", _on_mousewheel) f = scroll_frame # ── 서버 접속 ── self._section(f, "서버 접속") self.var_server = self._entry(f, "서버 주소", "https://s1.byunc.com") # ── 프리셋 ── self._section(f, "프리셋") preset_frame = tk.Frame(f, bg=BG) preset_frame.pack(fill="x", pady=(0, 8)) presets = [ ("기본 7kW", "basic"), ("급속 50kW", "fast"), ("완속 3kW", "slow"), ("단시간", "short"), ("완충", "full"), ("에러", "error"), ] for i, (label, key) in enumerate(presets): btn = tk.Button(preset_frame, text=label, font=("Consolas", 8), bg=BG3, fg=FG2, bd=0, padx=6, pady=3, activebackground=BG2, activeforeground=ACCENT, command=lambda k=key: self._apply_preset(k)) btn.grid(row=i // 3, column=i % 3, padx=2, pady=2, sticky="ew") preset_frame.columnconfigure([0, 1, 2], weight=1) # ── 충전기 설정 ── self._section(f, "충전기") row1 = tk.Frame(f, bg=BG) row1.pack(fill="x", pady=2) self.var_charger = self._entry_in(row1, "충전기 ID", "CHARGER_001", side="left") self.var_connector = self._entry_in(row1, "커넥터", "1", width=5, side="left") row2 = tk.Frame(f, bg=BG) row2.pack(fill="x", pady=2) self.var_name = self._entry_in(row2, "이름", "A동 주차장 1번", side="left") self.var_power = self._entry_in(row2, "출력kW", "7", width=6, side="left") self.var_location = self._entry(f, "설치 위치", "수원시 영통구 테스트 아파트 지하1층") # ── 충전 시뮬레이션 ── self._section(f, "충전 시뮬레이션") row3 = tk.Frame(f, bg=BG) row3.pack(fill="x", pady=2) self.var_meter_start = self._entry_in(row3, "미터시작(Wh)", "100000", side="left") self.var_txn_id = self._entry_in(row3, "TxnID", "", width=8, side="left") # 목표 충전량 슬라이더 slider_frame = tk.Frame(f, bg=BG) slider_frame.pack(fill="x", pady=(8, 0)) tk.Label(slider_frame, text="목표 충전량", font=("Segoe UI", 9), bg=BG, fg=FG3).pack(anchor="w") self.var_target_kwh = tk.IntVar(value=30) self.target_label = tk.Label(slider_frame, text="30 kWh", font=("Consolas", 14, "bold"), bg=BG, fg=ACCENT) self.target_label.pack(anchor="w") slider = tk.Scale(slider_frame, from_=1, to=100, orient="horizontal", variable=self.var_target_kwh, bg=BG, fg=ACCENT, troughcolor=BG3, highlightthickness=0, bd=0, sliderrelief="flat", activebackground=ACCENT, font=("Consolas", 8), command=self._update_slider) slider.pack(fill="x") # 예상 요금 표시 self.estimate_label = tk.Label(slider_frame, text="", font=("Consolas", 9), bg=BG, fg=FG3) self.estimate_label.pack(anchor="w", pady=(2, 0)) self._update_slider(30) row4 = tk.Frame(f, bg=BG) row4.pack(fill="x", pady=6) self.var_meter_steps = self._entry_in(row4, "보고횟수", "4", width=5, side="left") self.var_delay = self._entry_in(row4, "딜레이(ms)", "500", width=7, side="left") # ── 결제 / 종료 ── self._section(f, "결제 / 종료") row5 = tk.Frame(f, bg=BG) row5.pack(fill="x", pady=2) self.var_amount = self._entry_in(row5, "선결제(원)", "10000", side="left") tk.Label(f, text="종료 사유", font=("Segoe UI", 9), bg=BG, fg=FG3).pack(anchor="w", pady=(4, 0)) self.var_stop_reason = ttk.Combobox(f, values=[ "Local", "Remote", "EVDisconnected", "PowerLoss", "EmergencyStop", "Other" ], state="readonly", font=("Consolas", 10)) self.var_stop_reason.set("Local") self.var_stop_reason.pack(fill="x", pady=2) # ── 에러 시뮬레이션 ── self._section(f, "에러 시뮬레이션") tk.Label(f, text="에러 코드", font=("Segoe UI", 9), bg=BG, fg=FG3).pack(anchor="w") self.var_error_code = ttk.Combobox(f, values=[ "NoError", "ConnectorLockFailure", "GroundFailure", "HighTemperature", "OverCurrentFailure", "OverVoltage", "UnderVoltage", "PowerMeterFailure", "PowerSwitchFailure", "InternalError", "OtherError" ], state="readonly", font=("Consolas", 10)) self.var_error_code.set("NoError") self.var_error_code.pack(fill="x", pady=2) # ── 실행 버튼 ── tk.Frame(f, bg=BG, height=12).pack() self.btn_run = tk.Button(f, text="▶ 전체 흐름 실행", font=("Segoe UI", 12, "bold"), bg="#0e3a4a", fg=ACCENT, bd=0, pady=10, activebackground="#15485c", activeforeground=ACCENT, command=self._run_full) self.btn_run.pack(fill="x", pady=2) btn_row = tk.Frame(f, bg=BG) btn_row.pack(fill="x", pady=2) tk.Button(btn_row, text="단계별 실행", font=("Segoe UI", 9), bg=BG3, fg=AMBER, bd=0, pady=6, activebackground=BG2, command=self._run_step).pack(side="left", fill="x", expand=True, padx=(0, 4)) tk.Button(btn_row, text="초기화", font=("Segoe UI", 9), bg=BG3, fg=FG2, bd=0, pady=6, activebackground=BG2, command=self._reset).pack(side="left", fill="x", expand=True, padx=(4, 0)) def _build_results(self, parent): """결과 패널""" # 탭: 실행 로그 / JSON 상세 self.notebook = ttk.Notebook(parent) self.notebook.pack(fill="both", expand=True) # 탭1: 실행 로그 tab1 = tk.Frame(self.notebook, bg=BG) self.notebook.add(tab1, text=" 실행 로그 ") self.log_text = scrolledtext.ScrolledText( tab1, bg="#0a0e17", fg="#8ec8e8", font=("Consolas", 10), insertbackground=ACCENT, wrap="word", bd=0, padx=12, pady=12, selectbackground="#2a3a4a" ) self.log_text.pack(fill="both", expand=True) # 태그 설정 self.log_text.tag_configure("header", foreground="#fff", font=("Consolas", 10, "bold")) self.log_text.tag_configure("step", foreground=ACCENT, font=("Consolas", 10, "bold")) self.log_text.tag_configure("ok", foreground=GREEN) self.log_text.tag_configure("fail", foreground=RED) self.log_text.tag_configure("warn", foreground=AMBER) self.log_text.tag_configure("info", foreground=FG2) self.log_text.tag_configure("dim", foreground=FG3) self.log_text.tag_configure("key", foreground="#f472b6") self.log_text.tag_configure("val", foreground="#a5f3c4") self.log_text.tag_configure("num", foreground="#c4b5fd") self.log_text.tag_configure("summary_head", foreground="#fff", font=("Consolas", 12, "bold")) self.log_text.tag_configure("summary_val", foreground=GREEN, font=("Consolas", 14, "bold")) self.log_text.tag_configure("summary_save", foreground=AMBER, font=("Consolas", 11, "bold")) # 탭2: JSON 상세 tab2 = tk.Frame(self.notebook, bg=BG) self.notebook.add(tab2, text=" JSON 상세 ") self.json_text = scrolledtext.ScrolledText( tab2, bg="#0a0e17", fg="#8ec8e8", font=("Consolas", 10), insertbackground=ACCENT, wrap="word", bd=0, padx=12, pady=12 ) self.json_text.pack(fill="both", expand=True) self.json_text.tag_configure("key", foreground="#f472b6") self.json_text.tag_configure("str", foreground="#a5f3c4") self.json_text.tag_configure("num", foreground="#c4b5fd") # 탭3: 요약 tab3 = tk.Frame(self.notebook, bg=BG) self.notebook.add(tab3, text=" 결과 요약 ") self.summary_frame = tk.Frame(tab3, bg=BG) self.summary_frame.pack(fill="both", expand=True, padx=20, pady=20) # 하단 상태바 status_bar = tk.Frame(parent, bg=BG2, height=28) status_bar.pack(fill="x", side="bottom") status_bar.pack_propagate(False) self.status_label = tk.Label(status_bar, text="준비", font=("Consolas", 9), bg=BG2, fg=FG3, padx=12) self.status_label.pack(side="left") self.progress = ttk.Progressbar(status_bar, length=200, mode="determinate") self.progress.pack(side="right", padx=12, pady=6) # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # UI 유틸 # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ def _section(self, parent, title): sep = tk.Frame(parent, bg=BORDER, height=1) sep.pack(fill="x", pady=(12, 4)) tk.Label(parent, text=title.upper(), font=("Consolas", 8), bg=BG, fg=ACCENT, anchor="w").pack(fill="x") def _entry(self, parent, label, default=""): tk.Label(parent, text=label, font=("Segoe UI", 9), bg=BG, fg=FG3).pack(anchor="w", pady=(4, 0)) var = tk.StringVar(value=default) ent = tk.Entry(parent, textvariable=var, font=("Consolas", 10), bg=BG_INPUT, fg=FG, insertbackground=ACCENT, bd=0, relief="flat", highlightthickness=1, highlightbackground=BORDER, highlightcolor=ACCENT) ent.pack(fill="x", pady=2, ipady=4) return var def _entry_in(self, parent, label, default="", width=15, side="left"): frame = tk.Frame(parent, bg=BG) frame.pack(side=side, fill="x", expand=True, padx=(0, 6)) tk.Label(frame, text=label, font=("Segoe UI", 8), bg=BG, fg=FG3).pack(anchor="w") var = tk.StringVar(value=default) tk.Entry(frame, textvariable=var, font=("Consolas", 10), width=width, bg=BG_INPUT, fg=FG, insertbackground=ACCENT, bd=0, highlightthickness=1, highlightbackground=BORDER, highlightcolor=ACCENT).pack(fill="x", ipady=3) return var def _update_slider(self, val): kwh = int(val) cost = kwh * 170 cpo = kwh * 350 self.target_label.config(text=f"{kwh} kWh") self.estimate_label.config(text=f"예상 {cost:,}원 | CPO {cpo:,}원 | 절감 {cpo - cost:,}원") # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # 프리셋 # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ PRESETS = { "basic": dict(power="7", target=30, steps="4", amount="10000", delay="500", error="NoError", stop="Local"), "fast": dict(power="50", target=60, steps="6", amount="30000", delay="300", error="NoError", stop="Local"), "slow": dict(power="3", target=10, steps="3", amount="5000", delay="800", error="NoError", stop="Local"), "short": dict(power="7", target=5, steps="2", amount="2000", delay="300", error="NoError", stop="Local"), "full": dict(power="11", target=80, steps="8", amount="50000", delay="400", error="NoError", stop="Local"), "error": dict(power="7", target=15, steps="3", amount="10000", delay="500", error="OverCurrentFailure", stop="EmergencyStop"), } def _apply_preset(self, name): p = self.PRESETS[name] self.var_power.set(p["power"]) self.var_target_kwh.set(p["target"]) self.var_meter_steps.set(p["steps"]) self.var_amount.set(p["amount"]) self.var_delay.set(p["delay"]) self.var_error_code.set(p["error"]) self.var_stop_reason.set(p["stop"]) self._update_slider(p["target"]) # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # 로깅 # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ def _log(self, text, tag="info"): self.log_text.insert("end", text + "\n", tag) self.log_text.see("end") def _log_step(self, num, title): ts = datetime.now().strftime("%H:%M:%S") self._log(f"\n{'─' * 50}", "dim") self._log(f"[{ts}] STEP {num} — {title}", "step") def _log_result(self, data, success=True): tag = "ok" if success else "fail" pretty = json.dumps(data, indent=2, ensure_ascii=False, default=str) self._log(pretty, tag) # JSON 탭에도 추가 self.json_text.insert("end", pretty + "\n\n") self.json_text.see("end") def _log_summary(self, billing): self._log(f"\n{'━' * 50}", "header") self._log(f" 충전 완료", "summary_head") self._log(f" {billing['charged_kwh']} kWh 충전", "summary_val") self._log(f" 요금: {billing['total_bill']:,}원 (170원/kWh)", "summary_val") self._log(f" 전기원가 {billing['electricity_cost']:,}원 + 서비스 {billing['service_fee']:,}원", "info") self._log(f" CPO 대비 절감: {billing['saved_vs_cpo']:,}원", "summary_save") self._log(f"{'━' * 50}", "header") # 요약 탭 업데이트 for w in self.summary_frame.winfo_children(): w.destroy() items = [ ("충전량", f"{billing['charged_kwh']} kWh", GREEN), ("요금", f"{billing['total_bill']:,}원", ACCENT), ("전기원가", f"{billing['electricity_cost']:,}원", FG2), ("서비스수익", f"{billing['service_fee']:,}원", PURPLE), ("CPO대비 절감", f"{billing['saved_vs_cpo']:,}원", AMBER), ] for i, (label, value, color) in enumerate(items): frame = tk.Frame(self.summary_frame, bg=BG2, padx=20, pady=16) frame.pack(fill="x", pady=4) tk.Label(frame, text=label, font=("Segoe UI", 11), bg=BG2, fg=FG3).pack(anchor="w") tk.Label(frame, text=value, font=("Consolas", 20, "bold"), bg=BG2, fg=color).pack(anchor="w") def _set_status(self, text, progress=0): self.status_label.config(text=text) self.progress["value"] = progress # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # 파라미터 수집 # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ def _get_params(self): target_kwh = self.var_target_kwh.get() meter_start = int(self.var_meter_start.get()) meter_steps = int(self.var_meter_steps.get()) step_wh = round(target_kwh * 1000 / meter_steps) txn_input = self.var_txn_id.get().strip() return { "server": self.var_server.get().rstrip("/"), "charger": self.var_charger.get(), "connector": int(self.var_connector.get()), "name": self.var_name.get(), "location": self.var_location.get(), "power": float(self.var_power.get()), "meter_start": meter_start, "target_kwh": target_kwh, "meter_steps": meter_steps, "step_wh": step_wh, "meter_stop": meter_start + target_kwh * 1000, "txn_id": int(txn_input) if txn_input else int(time.time()) % 100000, "amount": int(self.var_amount.get()), "stop_reason": self.var_stop_reason.get(), "error_code": self.var_error_code.get(), "delay": int(self.var_delay.get()) / 1000.0, } # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # API 호출 # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ def _api(self, base, method, path, json_data=None): url = base + "/api/v1" + path try: with httpx.Client(timeout=15.0, verify=True) as client: if method == "GET": r = client.get(url) else: r = client.post(url, json=json_data) data = r.json() return {"ok": r.status_code < 400, "status": r.status_code, "data": data} except Exception as e: return {"ok": False, "status": 0, "data": {"error": str(e)}} def _health(self, base): try: with httpx.Client(timeout=10.0, verify=True) as client: r = client.get(base + "/health") return {"ok": r.status_code == 200, "status": r.status_code, "data": r.json()} except Exception as e: return {"ok": False, "status": 0, "data": {"error": str(e)}} # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # 실행 # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ def _run_full(self): if self.running: return self.step_mode = False threading.Thread(target=self._execute, daemon=True).start() def _run_step(self): if self.running: # 이미 실행 중이면 다음 단계로 if hasattr(self, "_step_event"): self._step_event.set() return self.step_mode = True self._step_event = threading.Event() threading.Thread(target=self._execute, daemon=True).start() def _wait_step(self): if self.step_mode: self._step_event.clear() self.root.after(0, lambda: self.status_label.config(text="[단계별] 다음 → '단계별 실행' 클릭")) self._step_event.wait() def _reset(self): self.running = False self.log_text.delete("1.0", "end") self.json_text.delete("1.0", "end") for w in self.summary_frame.winfo_children(): w.destroy() self._set_status("준비", 0) def _execute(self): self.running = True self.root.after(0, lambda: self.btn_run.config(state="disabled", text="실행 중...")) self.root.after(0, lambda: self._reset()) P = self._get_params() base = P["server"] total_steps = 12 current_step = 0 def progress(): nonlocal current_step current_step += 1 pct = int(current_step / total_steps * 100) self.root.after(0, lambda: self._set_status(f"Step {current_step}/{total_steps}", pct)) try: self.root.after(0, lambda: self._log("EV 충전 시뮬레이터 시작", "header")) self.root.after(0, lambda: self._log(f"서버: {base}", "info")) self.root.after(0, lambda: self._log(f"충전기: {P['charger']} | 목표: {P['target_kwh']}kWh | 출력: {P['power']}kW", "info")) # 0. 헬스체크 self._wait_step() self.root.after(0, lambda: self._log_step("0", "헬스체크")) r = self._health(base) self.root.after(0, lambda: self._log_result(r["data"], r["ok"])) if not r["ok"]: self.root.after(0, lambda: self._log("서버 연결 실패! 주소를 확인하세요.", "fail")) return progress() time.sleep(P["delay"]) # 1. 충전기 등록 self._wait_step() self.root.after(0, lambda: self._log_step("1", f"충전기 등록 ({P['charger']})")) r = self._api(base, "POST", "/chargers/", { "charge_box_id": P["charger"], "name": P["name"], "location": P["location"], "connector_count": 1, "power_kw": P["power"], }) if r["status"] == 409: self.root.after(0, lambda: self._log("이미 등록됨 — 건너뜀", "warn")) else: self.root.after(0, lambda: self._log_result(r["data"], r["ok"])) progress() # 1-1. 세션 정리 self._wait_step() self.root.after(0, lambda: self._log_step("1-1", "잔여 세션 정리")) r = self._api(base, "POST", f"/sessions/reset/{P['charger']}") self.root.after(0, lambda: self._log_result(r["data"])) progress() time.sleep(P["delay"]) # 2. 상태 업데이트 self._wait_step() status_val = "Available" if P["error_code"] == "NoError" else "Faulted" self.root.after(0, lambda: self._log_step("2", f"충전기 상태 → {status_val}")) r = self._api(base, "POST", "/ocpp/status", { "charge_box_id": P["charger"], "connector_id": P["connector"], "status": status_val, "error_code": P["error_code"], }) self.root.after(0, lambda: self._log_result(r["data"], r["ok"])) progress() if P["error_code"] != "NoError": self.root.after(0, lambda: self._log(f"\n에러 상태 설정 완료: {P['error_code']}", "warn")) self.root.after(0, lambda: self._log("에러 상태에서는 충전을 진행하지 않습니다.", "warn")) return time.sleep(P["delay"]) # 3. 세션 생성 self._wait_step() self.root.after(0, lambda: self._log_step("3", "세션 생성 (QR 스캔)")) r = self._api(base, "POST", "/sessions/", { "charge_box_id": P["charger"], "connector_id": P["connector"], }) if not r["ok"]: self.root.after(0, lambda: self._log_result(r["data"], False)) return self.session_uid = r["data"]["session_uid"] self.id_tag = r["data"]["id_tag"] self.root.after(0, lambda: self._log_result(r["data"])) progress() time.sleep(P["delay"]) # 4. 결제 준비 self._wait_step() self.root.after(0, lambda: self._log_step("4", f"결제 준비 ({P['amount']:,}원)")) r = self._api(base, "POST", "/payments/prepare", { "session_uid": self.session_uid, "amount": P["amount"], }) self.root.after(0, lambda: self._log_result(r["data"], r["ok"])) progress() time.sleep(P["delay"]) # 5. 결제 우회 self._wait_step() self.root.after(0, lambda: self._log_step("5", "결제 우회 → AUTHORIZED")) r = self._api(base, "POST", f"/sessions/{self.session_uid}/force-authorize") self.root.after(0, lambda: self._log_result(r["data"], r["ok"])) progress() time.sleep(P["delay"]) # 6. StartTransaction self._wait_step() self.root.after(0, lambda: self._log_step("6", f"StartTransaction (meter={P['meter_start']}Wh, txn={P['txn_id']})")) r = self._api(base, "POST", "/ocpp/start-transaction", { "charge_box_id": P["charger"], "connector_id": P["connector"], "id_tag": self.id_tag, "meter_start": P["meter_start"], "transaction_id": P["txn_id"], }) self.root.after(0, lambda: self._log_result(r["data"], r["ok"])) if not r["ok"]: return progress() time.sleep(P["delay"]) # 7. MeterValues self._wait_step() self.root.after(0, lambda: self._log_step("7", f"MeterValues ({P['meter_steps']}회 보고)")) for i in range(1, P["meter_steps"] + 1): wh = P["meter_start"] + round(P["step_wh"] * i) kwh = (wh - P["meter_start"]) / 1000 r = self._api(base, "POST", "/ocpp/meter-values", { "charge_box_id": P["charger"], "connector_id": P["connector"], "transaction_id": P["txn_id"], "value": wh, }) self.root.after(0, lambda w=wh, k=kwh, n=i: self._log( f" [{n}/{P['meter_steps']}] {w:,}Wh ({k:.1f} kWh)", "info")) time.sleep(max(0.1, P["delay"] / 2)) progress() # 8. 세션 조회 self._wait_step() self.root.after(0, lambda: self._log_step("8", "세션 상태 확인")) r = self._api(base, "GET", f"/sessions/{self.session_uid}") self.root.after(0, lambda: self._log_result(r["data"])) progress() time.sleep(P["delay"]) # 9. StopTransaction self._wait_step() self.root.after(0, lambda: self._log_step("9", f"StopTransaction (meter={P['meter_stop']:.0f}Wh, reason={P['stop_reason']})")) r = self._api(base, "POST", "/ocpp/stop-transaction", { "charge_box_id": P["charger"], "transaction_id": P["txn_id"], "meter_stop": int(P["meter_stop"]), "reason": P["stop_reason"], }) self.root.after(0, lambda: self._log_result(r["data"], r["ok"])) if not r["ok"]: return progress() time.sleep(P["delay"]) # 10. 정산 self._wait_step() self.root.after(0, lambda: self._log_step("10", "최종 정산")) r = self._api(base, "GET", f"/sessions/{self.session_uid}/billing") billing = r["data"] self.root.after(0, lambda: self._log_result(billing, r["ok"])) progress() # 11. 대시보드 self._wait_step() self.root.after(0, lambda: self._log_step("11", "대시보드 요약")) r = self._api(base, "GET", "/dashboard/summary") self.root.after(0, lambda: self._log_result(r["data"])) progress() # 요약 if billing and "charged_kwh" in billing: self.root.after(0, lambda: self._log_summary(billing)) self.root.after(0, lambda: self.notebook.select(2)) # 요약 탭으로 except Exception as e: self.root.after(0, lambda: self._log(f"\n예외 발생: {e}", "fail")) finally: self.running = False self.root.after(0, lambda: self.btn_run.config(state="normal", text="▶ 전체 흐름 실행")) self.root.after(0, lambda: self._set_status("완료", 100)) # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # 메인 # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ if __name__ == "__main__": root = tk.Tk() # 아이콘 설정 (없어도 무관) try: root.iconbitmap(default="") except Exception: pass app = EVSimulator(root) root.mainloop()