708 lines
32 KiB
Python
708 lines
32 KiB
Python
"""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("<Configure>", 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("<MouseWheel>", _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()
|