EV 충전 플랫폼 초기 백업

This commit is contained in:
root
2026-04-18 05:59:31 +09:00
commit 4558ac10c0
40 changed files with 6246 additions and 0 deletions

707
ev_simulator.py Normal file
View File

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