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

35
.env.example Normal file
View File

@@ -0,0 +1,35 @@
# ── 데이터베이스 ──
POSTGRES_DB=ev_charging
POSTGRES_USER=evuser
POSTGRES_PASSWORD=evpass1234
POSTGRES_HOST=postgres
POSTGRES_PORT=5432
# ── Redis ──
REDIS_HOST=redis
REDIS_PORT=6379
# ── Steve OCPP 서버 ──
STEVE_BASE_URL=https://s1.byunc.com/steve
STEVE_API_USER=admin
STEVE_API_PASSWORD=changeme
# ── 토스페이먼츠 ──
TOSS_CLIENT_KEY=test_ck_Poxy1XQL8RYmzR9JgL5lr7nO5Wml
TOSS_SECRET_KEY=test_sk_ZLKGPx4M3M90lvAvzx1n3BaWypv1
#TOSS_CLIENT_KEY=test_ck_xxxxxxxxxx
#TOSS_SECRET_KEY=test_sk_xxxxxxxxxx
# ── 요금 설정 (원/kWh) ──
ELECTRICITY_RATE=120
SERVICE_MARGIN=50
# ── JWT ──
JWT_SECRET=your-super-secret-key-change-this
JWT_ALGORITHM=HS256
JWT_EXPIRE_MINUTES=1440
# ── 서버 ──
SERVER_HOST=0.0.0.0
SERVER_PORT=8000
DEBUG=true

9
.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
__pycache__/
*.pyc
.env
proxy_users.json
proxy_config.json
ocpp_logs/
pgdata/
*.tar.gz
*.sql

15
Dockerfile Normal file
View File

@@ -0,0 +1,15 @@
FROM python:3.11-slim
WORKDIR /code
# 시스템 패키지
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc libpq-dev && \
rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY ./app /code/app
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]

221
INSTALL_GUIDE.md Executable file
View File

@@ -0,0 +1,221 @@
# EV 충전 플랫폼 — 설치 / 복원 가이드
## 서버 정보
| 항목 | 값 |
|------|------|
| 서버 | debian5825u (192.168.0.126) |
| 도메인 | s1.byunc.com (OCPP/대시보드), ecap.byunc.com (프록시) |
| 프로젝트 경로 | ~/steve/ev-charging-backend |
| Steve 경로 | ~/steve/steve |
## 포트 구성
| 서비스 | 포트 | 설명 |
|--------|------|------|
| FastAPI | 8000 | EV 충전 백엔드 API |
| Steve OCPP | 8180 | OCPP 서버 |
| PostgreSQL | 5432 | 충전 데이터 DB |
| Redis | 6375 | 세션 캐시 |
| MariaDB (Steve) | 3307 | Steve DB |
| OCPP 프록시 | 9002 | 충전기 WebSocket 프록시 |
| 프록시 관리웹 | 9003 | 프록시 관리 패널 |
## 신규 설치 순서
### 1. 코드 가져오기
```bash
cd ~/steve
git clone https://gitea.byunc.com/byun/ev-charging-backend.git
cd ev-charging-backend
```
### 2. 환경변수 설정
```bash
cp .env.example .env
nano .env
```
수정할 항목:
- `POSTGRES_PASSWORD` — DB 비밀번호
- `REDIS_PORT=6375`
- `STEVE_API_PASSWORD=1234`
- `JWT_SECRET` — 랜덤 문자열로 변경
- 토스페이먼츠 키 (있으면)
### 3. Docker 실행
```bash
docker compose build api
docker compose up -d
# 정상 확인
docker compose ps
curl http://localhost:8000/health
```
### 4. OCPP 프록시 설치
```bash
pip3 install websockets aiohttp
# 수동 실행 테스트
python3 ocpp_proxy_server.py
# Ctrl+C로 종료
# systemd 서비스 등록
sudo nano /etc/systemd/system/ocpp-proxy.service
```
```ini
[Unit]
Description=OCPP Proxy Server
After=network.target
[Service]
Type=simple
WorkingDirectory=/root/steve/ev-charging-backend
ExecStart=/usr/bin/python3 ocpp_proxy_server.py
Restart=always
[Install]
WantedBy=multi-user.target
```
```bash
sudo systemctl daemon-reload
sudo systemctl enable ocpp-proxy
sudo systemctl start ocpp-proxy
sudo systemctl status ocpp-proxy
```
### 5. Nginx 설정 (별도 서버인 경우)
**s1.byunc.com**`/api/` → :8000, `/steve/` → :8180, `/dashboard` → :8000
**ecap.byunc.com**`/` → :9003 (관리웹), `/api/` → :9003, `/ws/` → :9003, 나머지 → :9002 (프록시)
### 6. 테스트
```bash
# 전체 충전 흐름 테스트
python3 test_flow.py
# 대시보드 접속
# https://s1.byunc.com/dashboard (admin / admin1234)
# 프록시 관리
# https://ecap.byunc.com (admin / admin1234)
```
## 백업 순서
### 코드 백업 (Gitea)
```bash
cd ~/steve/ev-charging-backend
git add .
git commit -m "백업 메모"
git push
```
### DB 백업
```bash
# PostgreSQL 덤프
docker exec ev-postgres pg_dump -U evuser ev_charging > backup_db_$(date +%Y%m%d).sql
# Steve MariaDB 덤프
docker exec steve-db-1 mariadb-dump -u root -p<비밀번호> stevedb > backup_steve_$(date +%Y%m%d).sql
```
### 설정 파일 백업 (Git에 포함 안 되는 것들)
```bash
# 한번에 묶기
tar czf backup_configs_$(date +%Y%m%d).tar.gz .env proxy_users.json proxy_config.json
```
## 복원 순서
### 1. 코드 복원
```bash
cd ~/steve
git clone https://gitea.byunc.com/byun/ev-charging-backend.git
cd ev-charging-backend
```
### 2. 설정 복원
```bash
# .env 파일 복원 (백업에서 복사 또는 .env.example에서 재작성)
cp .env.example .env
nano .env
# 프록시 설정 복원 (있으면)
# proxy_users.json, proxy_config.json 복사
```
### 3. Docker 실행
```bash
docker compose build api
docker compose up -d
```
### 4. DB 복원 (백업 파일이 있는 경우)
```bash
# PostgreSQL
cat backup_db_20260418.sql | docker exec -i ev-postgres psql -U evuser ev_charging
# Steve MariaDB
cat backup_steve_20260418.sql | docker exec -i steve-db-1 mariadb -u root -p<비밀번호> stevedb
```
### 5. 프록시 서비스 시작
```bash
sudo systemctl start ocpp-proxy
```
## 문제 해결
| 증상 | 확인 | 해결 |
|------|------|------|
| API 502 | `docker compose ps` | `docker compose up -d` |
| 프록시 실패 | `sudo journalctl -u ocpp-proxy -n 20` | 포트 충돌 확인 `ss -tlnp` |
| 대시보드 500 | `docker compose logs api --tail 20` | `docker compose restart api` |
| bcrypt 에러 | requirements.txt 확인 | `bcrypt==4.0.1` 확인 후 `docker compose build api` |
| 포트 충돌 | `ss -tlnp \| grep 포트` | `sudo kill $(lsof -t -i:포트)` |
| DB 연결 실패 | `docker compose logs postgres` | `.env`의 비밀번호 확인 |
## 주요 파일 구조
```
ev-charging-backend/
├── docker-compose.yml ← Docker 서비스 정의
├── Dockerfile ← FastAPI 이미지
├── requirements.txt ← Python 패키지
├── .env ← 환경변수 (Git 제외)
├── .gitignore
├── app/ ← FastAPI 백엔드 코드
│ ├── main.py
│ ├── config.py
│ ├── database.py
│ ├── models/
│ ├── schemas/
│ ├── routers/
│ └── services/
├── dashboard.html ← 관리 대시보드
├── simulator.html ← 충전 시뮬레이터
├── ocpp_proxy_server.py ← OCPP 프록시 (Docker 외부)
├── proxy_control.html ← 프록시 관리 UI
├── test_flow.py ← CLI 테스트 스크립트
├── ev_simulator.py ← GUI 테스트 도구
├── nginx_fastapi.conf ← Nginx 설정 참고
└── README.md
```

139
README.md Normal file
View File

@@ -0,0 +1,139 @@
# EV 충전 플랫폼 — FastAPI 백엔드
CPO 없는 전기차 충전 플랫폼 백엔드 서버.
Steve OCPP 서버와 연동하여 충전 세션 관리, 결제 처리, 정산을 수행.
## 아키텍처
```
사용자 (QR 스캔)
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Nginx :443 │────▶│ FastAPI :8000│────▶│ PostgreSQL │
│ (리버스프록시)│ │ (REST API) │ │ (세션/정산) │
└──────┬───────┘ └──────┬───────┘ └──────────────┘
│ │
│ ▼
│ ┌──────────────┐
│ │ 토스페이먼츠 │
│ │ (결제 PG) │
│ └──────────────┘
┌──────────────┐ ┌──────────────┐
│ Steve :8180 │◀───▶│ 충전기 │
│ (OCPP 서버) │ WS │ (OCPP 1.6J) │
└──────────────┘ └──────────────┘
```
## 충전 흐름
```
① QR 스캔 → POST /api/v1/sessions (세션 생성)
② 결제 → POST /api/v1/payments/prepare (orderId 발급)
→ 토스 결제 UI 호출
→ POST /api/v1/payments/confirm (결제 승인)
③ 충전 → POST /api/v1/sessions/{uid}/start (RemoteStart)
→ OCPP MeterValues 수신 중...
④ 종료 → POST /api/v1/sessions/{uid}/stop (RemoteStop)
→ 자동 정산
```
## 빠른 시작
### 1. 환경 설정
```bash
cd ev-charging-backend
cp .env.example .env
# .env 파일 편집 — DB, Steve, 토스 키 설정
```
### 2. Docker Compose 실행
```bash
docker compose up -d
```
### 3. API 확인
```bash
# 헬스체크
curl http://localhost:8000/health
# Swagger UI
open http://localhost:8000/docs
```
### 4. 테스트 흐름 실행
```bash
pip install httpx
python test_flow.py
```
## API 엔드포인트
| 경로 | 메서드 | 설명 |
|------|--------|------|
| `/api/v1/chargers/` | GET/POST | 충전기 목록/등록 |
| `/api/v1/sessions/` | POST | 세션 생성 (QR 스캔) |
| `/api/v1/sessions/{uid}` | GET | 세션 상태 조회 |
| `/api/v1/sessions/{uid}/start` | POST | 충전 시작 |
| `/api/v1/sessions/{uid}/stop` | POST | 충전 종료 |
| `/api/v1/sessions/{uid}/billing` | GET | 정산 내역 |
| `/api/v1/payments/prepare` | POST | 결제 준비 |
| `/api/v1/payments/confirm` | POST | 결제 승인 |
| `/api/v1/ocpp/status` | POST | 충전기 상태 콜백 |
| `/api/v1/ocpp/start-transaction` | POST | 충전 시작 콜백 |
| `/api/v1/ocpp/stop-transaction` | POST | 충전 종료 콜백 |
| `/api/v1/ocpp/meter-values` | POST | MeterValues 콜백 |
| `/api/v1/dashboard/summary` | GET | 대시보드 요약 |
| `/api/v1/qr/{chargeBoxId}` | GET | QR 코드 이미지 |
## Nginx 설정
기존 Steve Nginx 설정에 `nginx_fastapi.conf` 내용을 추가하면
같은 도메인(`s1.byunc.com`)에서 `/api/` → FastAPI, `/steve/` → Steve로 분기.
## 프로젝트 구조
```
ev-charging-backend/
├── docker-compose.yml
├── Dockerfile
├── requirements.txt
├── .env.example
├── nginx_fastapi.conf ← Nginx 추가 설정
├── test_flow.py ← 전체 흐름 테스트
├── alembic.ini
└── app/
├── main.py ← FastAPI 앱 진입점
├── config.py ← 환경변수 설정
├── database.py ← DB 연결
├── models/
│ └── __init__.py ← SQLAlchemy 모델
├── schemas/
│ └── __init__.py ← Pydantic 스키마
├── routers/
│ ├── chargers.py ← 충전기 관리
│ ├── sessions.py ← 충전 세션 (핵심)
│ ├── payments.py ← 토스 결제
│ ├── ocpp_callbacks.py← OCPP 이벤트 수신
│ ├── dashboard.py ← 대시보드
│ └── qr.py ← QR 코드 생성
└── services/
├── steve_client.py ← Steve REST API 클라이언트
├── billing.py ← 요금 정산 로직
├── payment.py ← 토스페이먼츠 연동
└── scheduler.py ← 백그라운드 태스크
```
## 다음 단계
- [ ] Alembic 마이그레이션 초기화 (`alembic init alembic`)
- [ ] 토스페이먼츠 테스트 키 발급 및 연동
- [ ] 모바일 웹 결제 프론트 (QR → 결제 → 충전 상태)
- [ ] Steve API 인증 설정 확인
- [ ] Grafana 연동 (PostgreSQL 데이터소스)
- [ ] 프로덕션 배포 (SSL, 보안 헤더)

36
alembic.ini Normal file
View File

@@ -0,0 +1,36 @@
[alembic]
script_location = alembic
sqlalchemy.url = postgresql://evuser:evpass1234@postgres:5432/ev_charging
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

0
app/__init__.py Normal file
View File

70
app/config.py Normal file
View File

@@ -0,0 +1,70 @@
"""설정 관리 — 환경변수 기반"""
from pydantic_settings import BaseSettings
from functools import lru_cache
class Settings(BaseSettings):
# DB
POSTGRES_DB: str = "ev_charging"
POSTGRES_USER: str = "evuser"
POSTGRES_PASSWORD: str = "evpass1234"
POSTGRES_HOST: str = "postgres"
POSTGRES_PORT: int = 5432
# Redis
REDIS_HOST: str = "redis"
REDIS_PORT: int = 6379
# Steve
STEVE_BASE_URL: str = "https://s1.byunc.com/steve"
STEVE_API_USER: str = "admin"
STEVE_API_PASSWORD: str = "changeme"
# 토스페이먼츠
TOSS_CLIENT_KEY: str = ""
TOSS_SECRET_KEY: str = ""
# 요금 (원/kWh)
ELECTRICITY_RATE: int = 120
SERVICE_MARGIN: int = 50
# JWT
JWT_SECRET: str = "change-me"
JWT_ALGORITHM: str = "HS256"
JWT_EXPIRE_MINUTES: int = 1440
# 서버
DEBUG: bool = True
@property
def database_url(self) -> str:
return (
f"postgresql+asyncpg://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}"
f"@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}"
)
@property
def database_url_sync(self) -> str:
return (
f"postgresql://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}"
f"@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}"
)
@property
def redis_url(self) -> str:
return f"redis://{self.REDIS_HOST}:{self.REDIS_PORT}/0"
@property
def total_rate(self) -> int:
"""총 충전 단가 (원/kWh)"""
return self.ELECTRICITY_RATE + self.SERVICE_MARGIN
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
@lru_cache
def get_settings() -> Settings:
return Settings()

47
app/database.py Normal file
View File

@@ -0,0 +1,47 @@
"""비동기 DB 엔진 + 세션 팩토리"""
from sqlalchemy.ext.asyncio import (
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from sqlalchemy.orm import DeclarativeBase
from app.config import get_settings
settings = get_settings()
engine = create_async_engine(
settings.database_url,
echo=settings.DEBUG,
pool_size=10,
max_overflow=20,
)
AsyncSessionLocal = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
)
class Base(DeclarativeBase):
pass
async def get_db():
"""FastAPI Depends용 DB 세션 제너레이터"""
async with AsyncSessionLocal() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
async def init_db():
"""테이블 생성 (개발용 — 프로덕션에서는 Alembic 사용)"""
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)

142
app/main.py Normal file
View File

@@ -0,0 +1,142 @@
"""EV 충전 플랫폼 FastAPI 백엔드
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
충전 흐름:
QR 스캔 → 세션 생성 → 결제 → 충전 시작 → 충전 중 → 종료 → 정산
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
"""
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from app.config import get_settings
from app.database import init_db, AsyncSessionLocal
from app.routers import chargers, sessions, payments, ocpp_callbacks, dashboard, qr, auth
settings = get_settings()
# 로깅
logging.basicConfig(
level=logging.DEBUG if settings.DEBUG else logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI):
"""앱 시작/종료 시 실행"""
logger.info("=" * 50)
logger.info("EV 충전 백엔드 시작")
logger.info(f" Steve: {settings.STEVE_BASE_URL}")
logger.info(f" 요금: {settings.total_rate}원/kWh")
logger.info(f" (전기 {settings.ELECTRICITY_RATE} + 서비스 {settings.SERVICE_MARGIN})")
logger.info("=" * 50)
# DB 테이블 생성 (개발용)
await init_db()
logger.info("DB 테이블 초기화 완료")
# 초기 관리자 계정 생성
await _ensure_admin()
yield
logger.info("EV 충전 백엔드 종료")
# ── FastAPI 앱 ──
app = FastAPI(
title="EV 충전 플랫폼 API",
description="CPO 없는 전기차 충전 플랫폼 — OCPP 1.6J + 토스페이먼츠",
version="0.1.0",
lifespan=lifespan,
docs_url="/docs",
redoc_url="/redoc",
)
# CORS (모바일 웹 결제 페이지 허용)
app.add_middleware(
CORSMiddleware,
allow_origins=[
"https://s1.byunc.com",
"http://localhost:3000",
"http://localhost:5173",
],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ── 라우터 등록 ──
app.include_router(auth.router, prefix="/api/v1")
app.include_router(chargers.router, prefix="/api/v1")
app.include_router(sessions.router, prefix="/api/v1")
app.include_router(payments.router, prefix="/api/v1")
app.include_router(ocpp_callbacks.router, prefix="/api/v1")
app.include_router(dashboard.router, prefix="/api/v1")
app.include_router(qr.router, prefix="/api/v1")
# ── 초기 관리자 생성 ──
async def _ensure_admin():
"""admin 계정이 없으면 자동 생성"""
from sqlalchemy import select
from app.models import User, UserRole
from app.services.auth import hash_password
async with AsyncSessionLocal() as db:
result = await db.execute(
select(User).where(User.role == UserRole.ADMIN)
)
if result.scalar_one_or_none():
return
admin = User(
username="admin",
hashed_password=hash_password("admin1234"),
display_name="관리자",
role=UserRole.ADMIN,
)
db.add(admin)
await db.commit()
logger.info("초기 관리자 계정 생성: admin / admin1234")
# ── 헬스체크 ──
@app.get("/health")
async def health():
return {
"status": "ok",
"service": "ev-charging-api",
"rate": f"{settings.total_rate}원/kWh",
}
@app.get("/")
async def root():
return {
"message": "EV 충전 플랫폼 API",
"docs": "/docs",
"health": "/health",
}
@app.get("/dashboard")
async def dashboard_page():
"""관리자 대시보드 HTML 서빙"""
return FileResponse("/code/dashboard.html", media_type="text/html")
@app.get("/simulator")
async def simulator_page():
"""충전 시뮬레이터 GUI 서빙"""
return FileResponse("/code/simulator.html", media_type="text/html")

180
app/models/__init__.py Normal file
View File

@@ -0,0 +1,180 @@
"""DB 모델 정의"""
from datetime import datetime
from enum import Enum as PyEnum
from sqlalchemy import (
Column, Integer, BigInteger, String, Float, DateTime,
Enum, ForeignKey, Text, Boolean, Index,
)
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.database import Base
# ── 상태 Enum ──
class ChargerStatus(str, PyEnum):
AVAILABLE = "Available"
CHARGING = "Charging"
FAULTED = "Faulted"
UNAVAILABLE = "Unavailable"
RESERVED = "Reserved"
class SessionStatus(str, PyEnum):
PENDING = "pending" # 결제 대기
AUTHORIZED = "authorized" # 결제 승인, 충전 시작 대기
CHARGING = "charging" # 충전 중
COMPLETED = "completed" # 정상 종료
FAILED = "failed" # 실패
CANCELLED = "cancelled" # 취소
class PaymentStatus(str, PyEnum):
PENDING = "pending"
CONFIRMED = "confirmed"
FAILED = "failed"
REFUNDED = "refunded"
PARTIAL_REFUND = "partial_refund"
# ── 충전기 ──
class Charger(Base):
__tablename__ = "chargers"
id = Column(Integer, primary_key=True, autoincrement=True)
charge_box_id = Column(String(64), unique=True, nullable=False, index=True,
comment="OCPP chargeBoxId (Steve 등록명)")
name = Column(String(128), comment="표시 이름 (예: A동 주차장 1번)")
location = Column(String(256), comment="설치 위치")
connector_count = Column(Integer, default=1)
power_kw = Column(Float, default=7.0, comment="충전 용량 kW")
status = Column(Enum(ChargerStatus), default=ChargerStatus.UNAVAILABLE)
last_heartbeat = Column(DateTime(timezone=True))
is_active = Column(Boolean, default=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
sessions = relationship("ChargingSession", back_populates="charger")
def __repr__(self):
return f"<Charger {self.charge_box_id} [{self.status}]>"
# ── 충전 세션 ──
class ChargingSession(Base):
__tablename__ = "charging_sessions"
__table_args__ = (
Index("ix_session_charger_status", "charger_id", "status"),
Index("ix_session_created", "created_at"),
)
id = Column(Integer, primary_key=True, autoincrement=True)
session_uid = Column(String(64), unique=True, nullable=False, index=True,
comment="고유 세션 ID (UUID)")
charger_id = Column(Integer, ForeignKey("chargers.id"), nullable=False)
connector_id = Column(Integer, default=1)
# OCPP 트랜잭션 정보
ocpp_transaction_id = Column(Integer, comment="Steve에서 발급한 transactionId")
id_tag = Column(String(64), comment="OCPP 인증 태그")
# 전력량 (Wh 단위)
meter_start = Column(BigInteger, comment="시작 전력량 Wh")
meter_stop = Column(BigInteger, comment="종료 전력량 Wh")
charged_wh = Column(BigInteger, default=0, comment="충전된 전력량 Wh")
last_meter_value = Column(BigInteger, comment="마지막 MeterValues Wh")
# 요금
electricity_cost = Column(Integer, default=0, comment="전기 원가 (원)")
service_fee = Column(Integer, default=0, comment="서비스 수수료 (원)")
total_bill = Column(Integer, default=0, comment="총 요금 (원)")
# 시간
status = Column(Enum(SessionStatus), default=SessionStatus.PENDING)
started_at = Column(DateTime(timezone=True))
stopped_at = Column(DateTime(timezone=True))
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
charger = relationship("Charger", back_populates="sessions")
payment = relationship("Payment", back_populates="session", uselist=False)
def __repr__(self):
return f"<Session {self.session_uid} [{self.status}]>"
# ── 결제 ──
class Payment(Base):
__tablename__ = "payments"
__table_args__ = (
Index("ix_payment_order", "order_id"),
)
id = Column(Integer, primary_key=True, autoincrement=True)
session_id = Column(Integer, ForeignKey("charging_sessions.id"), nullable=False)
# 토스페이먼츠
order_id = Column(String(128), unique=True, nullable=False,
comment="토스 주문 ID")
payment_key = Column(String(256), comment="토스 paymentKey")
amount = Column(Integer, nullable=False, comment="결제 요청 금액 (원)")
actual_amount = Column(Integer, comment="실제 충전 후 정산 금액")
status = Column(Enum(PaymentStatus), default=PaymentStatus.PENDING)
method = Column(String(32), comment="결제 수단 (카드, 간편결제 등)")
card_company = Column(String(32), comment="카드사 이름")
confirmed_at = Column(DateTime(timezone=True))
refunded_at = Column(DateTime(timezone=True))
created_at = Column(DateTime(timezone=True), server_default=func.now())
session = relationship("ChargingSession", back_populates="payment")
def __repr__(self):
return f"<Payment {self.order_id} [{self.status}]>"
# ── 충전기 MeterValue 로그 ──
class MeterValueLog(Base):
"""실시간 MeterValues 로그 (디버깅/분석용)"""
__tablename__ = "meter_value_logs"
id = Column(BigInteger, primary_key=True, autoincrement=True)
charger_id = Column(Integer, ForeignKey("chargers.id"), nullable=False)
transaction_id = Column(Integer)
connector_id = Column(Integer, default=1)
measurand = Column(String(64), default="Energy.Active.Import.Register")
value = Column(Float, nullable=False, comment="측정값")
unit = Column(String(16), default="Wh")
timestamp = Column(DateTime(timezone=True), server_default=func.now())
# ── 사용자 ──
class UserRole(str, PyEnum):
ADMIN = "admin"
OPERATOR = "operator"
VIEWER = "viewer"
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, autoincrement=True)
username = Column(String(64), unique=True, nullable=False, index=True)
hashed_password = Column(String(256), nullable=False)
display_name = Column(String(128), comment="표시 이름")
role = Column(Enum(UserRole), default=UserRole.VIEWER)
is_active = Column(Boolean, default=True)
last_login = Column(DateTime(timezone=True))
created_at = Column(DateTime(timezone=True), server_default=func.now())
def __repr__(self):
return f"<User {self.username} [{self.role}]>"

0
app/routers/__init__.py Normal file
View File

219
app/routers/auth.py Normal file
View File

@@ -0,0 +1,219 @@
"""인증 + 사용자 관리 API
로그인 → JWT 토큰 발급 → 대시보드 접근
관리자만 사용자 생성/수정/삭제 가능
"""
import logging
from datetime import datetime, timezone
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import User, UserRole
from app.services.auth import (
hash_password, verify_password, create_token,
get_current_user, require_admin,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/auth", tags=["인증"])
# ── 스키마 ──
class LoginRequest(BaseModel):
username: str
password: str
class LoginResponse(BaseModel):
access_token: str
token_type: str = "bearer"
user: dict
class UserCreate(BaseModel):
username: str = Field(..., min_length=3, max_length=64)
password: str = Field(..., min_length=4)
display_name: str = ""
role: str = "viewer"
class UserUpdate(BaseModel):
display_name: Optional[str] = None
role: Optional[str] = None
is_active: Optional[bool] = None
password: Optional[str] = None
class UserOut(BaseModel):
id: int
username: str
display_name: Optional[str]
role: str
is_active: bool
last_login: Optional[datetime]
created_at: datetime
class Config:
from_attributes = True
# ── 로그인 ──
@router.post("/login", response_model=LoginResponse)
async def login(
data: LoginRequest,
db: AsyncSession = Depends(get_db),
):
"""로그인 → JWT 토큰 발급"""
result = await db.execute(
select(User).where(User.username == data.username)
)
user = result.scalar_one_or_none()
if not user or not verify_password(data.password, user.hashed_password):
raise HTTPException(401, "아이디 또는 비밀번호가 올바르지 않습니다")
if not user.is_active:
raise HTTPException(403, "비활성화된 계정입니다")
# 마지막 로그인 시간 갱신
user.last_login = datetime.now(timezone.utc)
token = create_token(user.id, user.username, user.role)
logger.info(f"로그인: {user.username} ({user.role})")
return LoginResponse(
access_token=token,
user={
"id": user.id,
"username": user.username,
"display_name": user.display_name or user.username,
"role": user.role,
},
)
# ── 내 정보 ──
@router.get("/me", response_model=UserOut)
async def get_me(user: User = Depends(get_current_user)):
"""현재 로그인 사용자 정보"""
return user
@router.put("/me/password")
async def change_my_password(
old_password: str,
new_password: str,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""비밀번호 변경"""
if not verify_password(old_password, user.hashed_password):
raise HTTPException(400, "현재 비밀번호가 틀립니다")
user.hashed_password = hash_password(new_password)
return {"message": "비밀번호 변경 완료"}
# ── 사용자 관리 (관리자 전용) ──
@router.get("/users", response_model=List[UserOut])
async def list_users(
admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
"""사용자 목록 (관리자 전용)"""
result = await db.execute(
select(User).order_by(User.created_at)
)
return result.scalars().all()
@router.post("/users", response_model=UserOut, status_code=201)
async def create_user(
data: UserCreate,
admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
"""사용자 생성 (관리자 전용)"""
# 중복 확인
existing = await db.execute(
select(User).where(User.username == data.username)
)
if existing.scalar_one_or_none():
raise HTTPException(409, f"이미 존재하는 아이디: {data.username}")
# 역할 검증
valid_roles = [r.value for r in UserRole]
if data.role not in valid_roles:
raise HTTPException(400, f"유효하지 않은 역할: {data.role} (가능: {valid_roles})")
user = User(
username=data.username,
hashed_password=hash_password(data.password),
display_name=data.display_name or data.username,
role=data.role,
)
db.add(user)
await db.flush()
await db.refresh(user)
logger.info(f"사용자 생성: {user.username} ({user.role}) by {admin.username}")
return user
@router.put("/users/{user_id}", response_model=UserOut)
async def update_user(
user_id: int,
data: UserUpdate,
admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
"""사용자 수정 (관리자 전용)"""
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(404, "사용자 없음")
if data.display_name is not None:
user.display_name = data.display_name
if data.role is not None:
user.role = data.role
if data.is_active is not None:
user.is_active = data.is_active
if data.password is not None:
user.hashed_password = hash_password(data.password)
logger.info(f"사용자 수정: {user.username} by {admin.username}")
await db.flush()
await db.refresh(user)
return user
@router.delete("/users/{user_id}")
async def delete_user(
user_id: int,
admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
"""사용자 삭제 (관리자 전용)"""
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(404, "사용자 없음")
if user.id == admin.id:
raise HTTPException(400, "자기 자신은 삭제할 수 없습니다")
await db.delete(user)
logger.info(f"사용자 삭제: {user.username} by {admin.username}")
return {"message": f"{user.username} 삭제 완료"}

92
app/routers/chargers.py Normal file
View File

@@ -0,0 +1,92 @@
"""충전기 관리 API"""
from typing import List
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import Charger
from app.schemas import ChargerCreate, ChargerOut
from app.services.steve_client import steve_client
router = APIRouter(prefix="/chargers", tags=["충전기"])
@router.get("/", response_model=List[ChargerOut])
async def list_chargers(
active_only: bool = True,
db: AsyncSession = Depends(get_db),
):
"""등록된 충전기 목록"""
stmt = select(Charger)
if active_only:
stmt = stmt.where(Charger.is_active == True)
result = await db.execute(stmt)
return result.scalars().all()
@router.post("/", response_model=ChargerOut, status_code=201)
async def register_charger(
data: ChargerCreate,
db: AsyncSession = Depends(get_db),
):
"""새 충전기 등록
FastAPI DB에 등록. Steve에도 이미 chargeBoxId가
등록되어 있어야 OCPP 통신 가능.
"""
# 중복 확인
existing = await db.execute(
select(Charger).where(Charger.charge_box_id == data.charge_box_id)
)
if existing.scalar_one_or_none():
raise HTTPException(409, f"이미 등록된 충전기: {data.charge_box_id}")
charger = Charger(**data.model_dump())
db.add(charger)
await db.flush()
await db.refresh(charger)
return charger
@router.get("/{charge_box_id}", response_model=ChargerOut)
async def get_charger(
charge_box_id: str,
db: AsyncSession = Depends(get_db),
):
"""충전기 상세 정보"""
result = await db.execute(
select(Charger).where(Charger.charge_box_id == charge_box_id)
)
charger = result.scalar_one_or_none()
if not charger:
raise HTTPException(404, "충전기를 찾을 수 없습니다")
return charger
@router.get("/{charge_box_id}/steve-status")
async def get_steve_status(charge_box_id: str):
"""Steve 서버에서 충전기 실시간 상태 조회"""
data = await steve_client.get_charge_point(charge_box_id)
if not data:
raise HTTPException(502, "Steve 서버 응답 없음")
return data
@router.delete("/{charge_box_id}")
async def deactivate_charger(
charge_box_id: str,
db: AsyncSession = Depends(get_db),
):
"""충전기 비활성화 (soft delete)"""
result = await db.execute(
select(Charger).where(Charger.charge_box_id == charge_box_id)
)
charger = result.scalar_one_or_none()
if not charger:
raise HTTPException(404, "충전기를 찾을 수 없습니다")
charger.is_active = False
return {"message": f"{charge_box_id} 비활성화 완료"}

131
app/routers/dashboard.py Normal file
View File

@@ -0,0 +1,131 @@
"""대시보드 API — 관리자 모니터링"""
from datetime import datetime, timezone, timedelta
from fastapi import APIRouter, Depends
from sqlalchemy import select, func, and_, extract
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import Charger, ChargingSession, ChargerStatus, SessionStatus
from app.schemas import DashboardSummary
router = APIRouter(prefix="/dashboard", tags=["대시보드"])
@router.get("/summary", response_model=DashboardSummary)
async def get_summary(db: AsyncSession = Depends(get_db)):
"""대시보드 요약 — 오늘/이번달 충전 현황"""
now = datetime.now(timezone.utc)
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
# 충전기 통계
chargers = await db.execute(select(Charger).where(Charger.is_active == True))
charger_list = chargers.scalars().all()
total = len(charger_list)
active = sum(1 for c in charger_list if c.status != ChargerStatus.UNAVAILABLE)
charging = sum(1 for c in charger_list if c.status == ChargerStatus.CHARGING)
# 오늘 세션
today_q = await db.execute(
select(
func.count(ChargingSession.id),
func.coalesce(func.sum(ChargingSession.total_bill), 0),
func.coalesce(func.sum(ChargingSession.charged_wh), 0),
).where(
and_(
ChargingSession.status == SessionStatus.COMPLETED,
ChargingSession.created_at >= today_start,
)
)
)
today_row = today_q.one()
# 이번달 세션
month_q = await db.execute(
select(
func.coalesce(func.sum(ChargingSession.total_bill), 0),
func.coalesce(func.sum(ChargingSession.charged_wh), 0),
).where(
and_(
ChargingSession.status == SessionStatus.COMPLETED,
ChargingSession.created_at >= month_start,
)
)
)
month_row = month_q.one()
return DashboardSummary(
total_chargers=total,
active_chargers=active,
charging_now=charging,
today_sessions=today_row[0],
today_revenue=today_row[1],
today_kwh=round(today_row[2] / 1000, 2),
month_revenue=month_row[0],
month_kwh=round(month_row[1] / 1000, 2),
)
@router.get("/recent-sessions")
async def recent_sessions(
limit: int = 20,
db: AsyncSession = Depends(get_db),
):
"""최근 충전 세션 목록"""
result = await db.execute(
select(ChargingSession)
.order_by(ChargingSession.created_at.desc())
.limit(limit)
)
sessions = result.scalars().all()
return [
{
"session_uid": s.session_uid,
"charger_id": s.charger_id,
"status": s.status,
"charged_kwh": round(s.charged_wh / 1000, 2) if s.charged_wh else 0,
"total_bill": s.total_bill,
"started_at": s.started_at,
"stopped_at": s.stopped_at,
}
for s in sessions
]
@router.get("/daily-stats")
async def daily_stats(
days: int = 30,
db: AsyncSession = Depends(get_db),
):
"""일별 충전 통계 (최근 N일)"""
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
result = await db.execute(
select(
func.date(ChargingSession.created_at).label("date"),
func.count(ChargingSession.id).label("sessions"),
func.coalesce(func.sum(ChargingSession.total_bill), 0).label("revenue"),
func.coalesce(func.sum(ChargingSession.charged_wh), 0).label("wh"),
)
.where(
and_(
ChargingSession.status == SessionStatus.COMPLETED,
ChargingSession.created_at >= cutoff,
)
)
.group_by(func.date(ChargingSession.created_at))
.order_by(func.date(ChargingSession.created_at))
)
return [
{
"date": str(row.date),
"sessions": row.sessions,
"revenue": row.revenue,
"kwh": round(row.wh / 1000, 2),
}
for row in result.all()
]

View File

@@ -0,0 +1,258 @@
"""OCPP 이벤트 콜백 API
Steve OCPP 서버에서 충전기 이벤트 수신.
Steve 설정에서 webhook URL을 이 엔드포인트로 지정하거나,
주기적으로 Steve API를 폴링하여 이벤트 동기화.
※ Steve 버전에 따라 webhook 지원 여부가 다름.
지원하지 않는 경우 /ocpp/sync 엔드포인트로 수동 동기화.
"""
import logging
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select, and_
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import (
Charger, ChargingSession, MeterValueLog,
ChargerStatus, SessionStatus,
)
from app.schemas import (
OcppStatusNotification,
OcppStartTransaction,
OcppStopTransaction,
OcppMeterValues,
)
from app.services.billing import calculate_bill
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/ocpp", tags=["OCPP 콜백"])
# ── StatusNotification ──
@router.post("/status")
async def handle_status_notification(
data: OcppStatusNotification,
db: AsyncSession = Depends(get_db),
):
"""충전기 상태 변경 수신
Available / Charging / Faulted / Unavailable
"""
charger = await _get_charger(db, data.charge_box_id)
if not charger:
logger.warning(f"미등록 충전기 상태 수신: {data.charge_box_id}")
return {"status": "ignored", "reason": "unregistered"}
# 상태 매핑
status_map = {
"Available": ChargerStatus.AVAILABLE,
"Charging": ChargerStatus.CHARGING,
"Faulted": ChargerStatus.FAULTED,
"Unavailable": ChargerStatus.UNAVAILABLE,
"Reserved": ChargerStatus.RESERVED,
}
new_status = status_map.get(data.status, ChargerStatus.UNAVAILABLE)
charger.status = new_status
charger.last_heartbeat = data.timestamp or datetime.now(timezone.utc)
logger.info(f"충전기 상태: {data.charge_box_id}{new_status}")
return {"status": "ok", "charger_status": new_status}
# ── StartTransaction ──
@router.post("/start-transaction")
async def handle_start_transaction(
data: OcppStartTransaction,
db: AsyncSession = Depends(get_db),
):
"""충전 시작 이벤트 수신
충전기가 StartTransaction 보내면 transactionId 기록.
meterStart 값으로 시작 전력량 저장.
"""
# id_tag로 세션 찾기
result = await db.execute(
select(ChargingSession).where(
and_(
ChargingSession.id_tag == data.id_tag,
ChargingSession.status.in_([
SessionStatus.AUTHORIZED,
SessionStatus.CHARGING,
]),
)
).order_by(ChargingSession.created_at.desc())
)
session = result.scalar_one_or_none()
if not session:
logger.warning(
f"매칭 세션 없음: tag={data.id_tag} "
f"charger={data.charge_box_id}"
)
return {"status": "no_session"}
session.ocpp_transaction_id = data.transaction_id
session.meter_start = data.meter_start
session.status = SessionStatus.CHARGING
session.started_at = data.timestamp or datetime.now(timezone.utc)
logger.info(
f"충전 시작: session={session.session_uid} "
f"txn={data.transaction_id} meter={data.meter_start}Wh"
)
return {
"status": "ok",
"session_uid": session.session_uid,
"transaction_id": data.transaction_id,
}
# ── StopTransaction ──
@router.post("/stop-transaction")
async def handle_stop_transaction(
data: OcppStopTransaction,
db: AsyncSession = Depends(get_db),
):
"""충전 종료 이벤트 수신 + 자동 정산
충전기가 StopTransaction 보내면 meterStop 기록 후 요금 정산.
"""
# transactionId로 충전 중인 세션 찾기
result = await db.execute(
select(ChargingSession).where(
and_(
ChargingSession.ocpp_transaction_id == data.transaction_id,
ChargingSession.status == SessionStatus.CHARGING,
)
).order_by(ChargingSession.created_at.desc())
)
session = result.scalars().first()
if not session:
logger.warning(f"매칭 세션 없음: txn={data.transaction_id}")
return {"status": "no_session"}
# 전력량 기록 및 정산
session.meter_stop = data.meter_stop
session.charged_wh = max(0, data.meter_stop - (session.meter_start or 0))
session.stopped_at = data.timestamp or datetime.now(timezone.utc)
session.status = SessionStatus.COMPLETED
# 요금 계산
bill = calculate_bill(session.meter_start or 0, data.meter_stop)
session.electricity_cost = bill["electricity_cost"]
session.service_fee = bill["service_fee"]
session.total_bill = bill["total_bill"]
logger.info(
f"충전 완료: session={session.session_uid} "
f"charged={bill['charged_kwh']}kWh "
f"bill={bill['total_bill']}"
)
return {
"status": "ok",
"session_uid": session.session_uid,
"billing": bill,
}
# ── MeterValues ──
@router.post("/meter-values")
async def handle_meter_values(
data: OcppMeterValues,
db: AsyncSession = Depends(get_db),
):
"""실시간 전력량 수신
충전 중 주기적으로 수신되는 MeterValues를 기록.
실시간 충전량/요금 계산에 사용.
"""
charger = await _get_charger(db, data.charge_box_id)
# 로그 저장
log = MeterValueLog(
charger_id=charger.id if charger else 0,
transaction_id=data.transaction_id,
connector_id=data.connector_id,
measurand=data.measurand,
value=data.value,
unit="Wh",
timestamp=data.timestamp or datetime.now(timezone.utc),
)
db.add(log)
# 진행 중 세션에 마지막 meter 값 업데이트
if data.transaction_id:
result = await db.execute(
select(ChargingSession).where(
and_(
ChargingSession.ocpp_transaction_id == data.transaction_id,
ChargingSession.status == SessionStatus.CHARGING,
)
)
)
session = result.scalars().first()
if session:
session.last_meter_value = int(data.value)
session.charged_wh = max(
0, int(data.value) - (session.meter_start or 0)
)
return {"status": "ok", "value": data.value}
# ── Steve 데이터 동기화 (폴링 방식) ──
@router.post("/sync")
async def sync_from_steve(
db: AsyncSession = Depends(get_db),
):
"""Steve 서버에서 최신 트랜잭션 데이터 동기화
Steve에 webhook이 없는 경우 이 엔드포인트를
cron 또는 APScheduler로 주기적 호출.
"""
from app.services.steve_client import steve_client
transactions = await steve_client.get_transactions()
if not transactions:
return {"status": "no_data"}
synced = 0
for txn in transactions:
txn_id = txn.get("transactionId") or txn.get("id")
if not txn_id:
continue
# 이미 기록된 트랜잭션인지 확인
result = await db.execute(
select(ChargingSession).where(
ChargingSession.ocpp_transaction_id == txn_id
)
)
session = result.scalar_one_or_none()
if session and session.status == SessionStatus.COMPLETED:
continue
# TODO: 트랜잭션 데이터를 세션에 반영
synced += 1
return {"status": "ok", "synced": synced}
# ── 유틸 ──
async def _get_charger(db: AsyncSession, charge_box_id: str):
result = await db.execute(
select(Charger).where(Charger.charge_box_id == charge_box_id)
)
return result.scalar_one_or_none()

179
app/routers/payments.py Normal file
View File

@@ -0,0 +1,179 @@
"""결제 API — 토스페이먼츠 연동
결제 흐름:
1. POST /payments/prepare → orderId 발급 + Redis에 임시 저장
2. 프론트에서 토스 결제 UI 호출 → 사용자 카드 입력
3. POST /payments/confirm → 백엔드에서 토스 confirm API 호출
4. 승인 성공 → 세션 상태 AUTHORIZED → 충전 시작 가능
"""
import uuid
import json
import logging
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import ChargingSession, Payment, SessionStatus, PaymentStatus
from app.schemas import PaymentRequest, PaymentConfirm, PaymentOut
from app.services.payment import toss_service
from app.config import get_settings
logger = logging.getLogger(__name__)
settings = get_settings()
router = APIRouter(prefix="/payments", tags=["결제"])
@router.post("/prepare")
async def prepare_payment(
data: PaymentRequest,
db: AsyncSession = Depends(get_db),
):
"""결제 준비 — orderId 발급
프론트에서 토스 결제 UI를 호출하기 전에 호출.
orderId와 클라이언트 키를 반환.
"""
# 세션 확인
result = await db.execute(
select(ChargingSession).where(
ChargingSession.session_uid == data.session_uid
)
)
session = result.scalar_one_or_none()
if not session:
raise HTTPException(404, "세션 없음")
if session.status != SessionStatus.PENDING:
raise HTTPException(400, f"결제 불가 상태: {session.status}")
# 주문 ID 생성
order_id = f"EV-{uuid.uuid4().hex[:16].upper()}"
# Payment 레코드 생성
payment = Payment(
session_id=session.id,
order_id=order_id,
amount=data.amount,
status=PaymentStatus.PENDING,
)
db.add(payment)
await db.flush()
return {
"order_id": order_id,
"amount": data.amount,
"session_uid": data.session_uid,
"client_key": settings.TOSS_CLIENT_KEY,
"customer_name": "EV 충전",
"order_name": f"전기차 충전 ({data.amount:,}원)",
}
@router.post("/confirm", response_model=PaymentOut)
async def confirm_payment(
data: PaymentConfirm,
db: AsyncSession = Depends(get_db),
):
"""결제 최종 승인
프론트에서 토스 결제 완료 후 paymentKey, orderId, amount 전달.
백엔드에서 토스 confirm API 호출 → 실제 청구 확정.
성공 시 세션 상태를 AUTHORIZED로 변경.
"""
# Payment 조회
result = await db.execute(
select(Payment).where(Payment.order_id == data.order_id)
)
payment = result.scalar_one_or_none()
if not payment:
raise HTTPException(404, f"주문 없음: {data.order_id}")
# 금액 검증
if payment.amount != data.amount:
raise HTTPException(400, "결제 금액 불일치")
# 토스페이먼츠 최종 승인
toss_result = await toss_service.confirm_payment(
payment_key=data.payment_key,
order_id=data.order_id,
amount=data.amount,
)
if not toss_result["success"]:
payment.status = PaymentStatus.FAILED
raise HTTPException(400, toss_result.get("message", "결제 승인 실패"))
# 결제 성공 처리
toss_data = toss_result["data"]
payment.payment_key = data.payment_key
payment.status = PaymentStatus.CONFIRMED
payment.method = toss_data.get("method", "")
payment.card_company = (
toss_data.get("card", {}).get("company", "") if toss_data.get("card") else ""
)
payment.confirmed_at = datetime.now(timezone.utc)
# 세션 상태 → AUTHORIZED (충전 시작 가능)
session = await db.get(ChargingSession, payment.session_id)
session.status = SessionStatus.AUTHORIZED
logger.info(
f"결제 승인 완료: {data.order_id} / {data.amount}원 → "
f"세션 {session.session_uid} AUTHORIZED"
)
await db.flush()
await db.refresh(payment)
return payment
@router.post("/{order_id}/cancel")
async def cancel_payment(
order_id: str,
cancel_reason: str = "사용자 취소",
db: AsyncSession = Depends(get_db),
):
"""결제 취소"""
result = await db.execute(
select(Payment).where(Payment.order_id == order_id)
)
payment = result.scalar_one_or_none()
if not payment:
raise HTTPException(404, "주문 없음")
if not payment.payment_key:
raise HTTPException(400, "승인된 결제가 아닙니다")
cancel_result = await toss_service.cancel_payment(
payment_key=payment.payment_key,
cancel_reason=cancel_reason,
)
if not cancel_result["success"]:
raise HTTPException(400, cancel_result.get("message", "취소 실패"))
payment.status = PaymentStatus.REFUNDED
payment.refunded_at = datetime.now(timezone.utc)
# 세션 취소
session = await db.get(ChargingSession, payment.session_id)
session.status = SessionStatus.CANCELLED
return {"message": "결제 취소 완료", "order_id": order_id}
@router.get("/{order_id}", response_model=PaymentOut)
async def get_payment(
order_id: str,
db: AsyncSession = Depends(get_db),
):
"""결제 상세 조회"""
result = await db.execute(
select(Payment).where(Payment.order_id == order_id)
)
payment = result.scalar_one_or_none()
if not payment:
raise HTTPException(404, "주문 없음")
return payment

38
app/routers/qr.py Normal file
View File

@@ -0,0 +1,38 @@
"""QR 코드 생성 API"""
import io
import qrcode
from fastapi import APIRouter, Response
from app.config import get_settings
settings = get_settings()
router = APIRouter(prefix="/qr", tags=["QR 코드"])
@router.get("/{charge_box_id}")
async def generate_qr(charge_box_id: str):
"""충전기용 QR 코드 이미지 생성
QR 내용: 충전 시작 페이지 URL (charge_box_id 포함)
사용자가 스캔하면 모바일 웹 결제 페이지로 이동.
"""
# 충전 시작 URL (프론트 배포 후 수정)
charge_url = f"https://s1.byunc.com/charge/{charge_box_id}"
qr = qrcode.QRCode(version=1, box_size=10, border=4)
qr.add_data(charge_url)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
buf = io.BytesIO()
img.save(buf, format="PNG")
buf.seek(0)
return Response(
content=buf.getvalue(),
media_type="image/png",
headers={
"Content-Disposition": f'inline; filename="qr_{charge_box_id}.png"'
},
)

276
app/routers/sessions.py Normal file
View File

@@ -0,0 +1,276 @@
"""충전 세션 API — 핵심 충전 흐름 관리
흐름:
QR 스캔 → POST /sessions (세션 생성)
→ 결제 완료 → POST /sessions/{uid}/start (RemoteStart)
→ 충전 중 MeterValues 수신
→ POST /sessions/{uid}/stop (RemoteStop)
→ 자동 정산
"""
import uuid
from datetime import datetime, timezone
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select, func, and_
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import Charger, ChargingSession, SessionStatus, ChargerStatus
from app.schemas import SessionCreate, SessionOut, SessionBilling
from app.services.steve_client import steve_client
from app.services.billing import calculate_bill
router = APIRouter(prefix="/sessions", tags=["충전 세션"])
# ── 세션 생성 (QR 스캔 시) ──
@router.post("/", response_model=SessionOut, status_code=201)
async def create_session(
data: SessionCreate,
db: AsyncSession = Depends(get_db),
):
"""충전 세션 생성
사용자가 QR 스캔 시 호출. 충전기 상태 확인 후 세션 생성.
이후 결제 → 충전 시작 순서로 진행.
"""
# 충전기 확인
result = await db.execute(
select(Charger).where(Charger.charge_box_id == data.charge_box_id)
)
charger = result.scalar_one_or_none()
if not charger:
raise HTTPException(404, f"충전기 미등록: {data.charge_box_id}")
if not charger.is_active:
raise HTTPException(400, "비활성화된 충전기입니다")
# 해당 충전기에 진행 중인 세션 확인
active = await db.execute(
select(ChargingSession).where(
and_(
ChargingSession.charger_id == charger.id,
ChargingSession.status.in_([
SessionStatus.PENDING,
SessionStatus.AUTHORIZED,
SessionStatus.CHARGING,
]),
)
)
)
if active.scalar_one_or_none():
raise HTTPException(409, "이미 사용 중인 충전기입니다")
# 세션 생성
session_uid = f"SES-{uuid.uuid4().hex[:12].upper()}"
id_tag = f"APP-{uuid.uuid4().hex[:8].upper()}"
session = ChargingSession(
session_uid=session_uid,
charger_id=charger.id,
connector_id=data.connector_id,
id_tag=id_tag,
status=SessionStatus.PENDING,
)
db.add(session)
await db.flush()
await db.refresh(session)
return session
# ── 충전 시작 (결제 승인 후) ──
@router.post("/{session_uid}/start")
async def start_charging(
session_uid: str,
db: AsyncSession = Depends(get_db),
):
"""충전 시작 — 결제 승인 후 호출
Steve 서버에 RemoteStartTransaction 전송.
충전기가 수락하면 StartTransaction 응답이 옴.
"""
session = await _get_session(db, session_uid)
if session.status != SessionStatus.AUTHORIZED:
raise HTTPException(400, f"시작 불가 상태: {session.status}")
charger = await db.get(Charger, session.charger_id)
# Steve에 원격 시작 명령
result = await steve_client.remote_start_transaction(
charge_box_id=charger.charge_box_id,
id_tag=session.id_tag,
connector_id=session.connector_id,
)
if not result:
raise HTTPException(502, "Steve 서버 통신 실패")
session.status = SessionStatus.CHARGING
session.started_at = datetime.now(timezone.utc)
return {
"message": "충전 시작 명령 전송 완료",
"session_uid": session_uid,
"steve_response": result,
}
# ── 충전 종료 ──
@router.post("/{session_uid}/stop")
async def stop_charging(
session_uid: str,
db: AsyncSession = Depends(get_db),
):
"""충전 종료 — 사용자 요청 또는 자동 종료"""
session = await _get_session(db, session_uid)
if session.status != SessionStatus.CHARGING:
raise HTTPException(400, f"종료 불가 상태: {session.status}")
if not session.ocpp_transaction_id:
raise HTTPException(400, "OCPP 트랜잭션 ID 없음 — 아직 시작되지 않음")
charger = await db.get(Charger, session.charger_id)
# Steve에 원격 종료 명령
result = await steve_client.remote_stop_transaction(
charge_box_id=charger.charge_box_id,
transaction_id=session.ocpp_transaction_id,
)
if not result:
raise HTTPException(502, "Steve 서버 통신 실패")
return {
"message": "충전 종료 명령 전송 완료",
"session_uid": session_uid,
}
# ── 세션 상태 조회 ──
@router.get("/{session_uid}", response_model=SessionOut)
async def get_session(
session_uid: str,
db: AsyncSession = Depends(get_db),
):
"""세션 상태 조회 (충전 중 폴링용)"""
return await _get_session(db, session_uid)
@router.get("/{session_uid}/billing", response_model=SessionBilling)
async def get_session_billing(
session_uid: str,
db: AsyncSession = Depends(get_db),
):
"""세션 정산 내역"""
session = await _get_session(db, session_uid)
if session.status not in (SessionStatus.COMPLETED, SessionStatus.CHARGING):
raise HTTPException(400, "정산 정보 없음")
start_wh = session.meter_start or 0
end_wh = session.meter_stop or session.last_meter_value or start_wh
bill = calculate_bill(start_wh, end_wh)
return SessionBilling(
session_uid=session_uid,
charged_kwh=bill["charged_kwh"],
electricity_cost=bill["electricity_cost"],
service_fee=bill["service_fee"],
total_bill=bill["total_bill"],
saved_vs_cpo=bill["saved_vs_cpo"],
)
# ── 세션 목록 ──
@router.get("/", response_model=List[SessionOut])
async def list_sessions(
status: Optional[str] = None,
charge_box_id: Optional[str] = None,
limit: int = Query(50, le=200),
offset: int = 0,
db: AsyncSession = Depends(get_db),
):
"""충전 세션 목록 (관리자 대시보드용)"""
stmt = select(ChargingSession).order_by(
ChargingSession.created_at.desc()
)
if status:
stmt = stmt.where(ChargingSession.status == status)
if charge_box_id:
stmt = stmt.join(Charger).where(
Charger.charge_box_id == charge_box_id
)
stmt = stmt.offset(offset).limit(limit)
result = await db.execute(stmt)
return result.scalars().all()
# ── 테스트용 (프로덕션에서 제거) ──
@router.post("/reset/{charge_box_id}")
async def reset_charger_sessions(
charge_box_id: str,
db: AsyncSession = Depends(get_db),
):
"""[테스트 전용] 충전기의 미완료 세션 전부 취소"""
result = await db.execute(
select(Charger).where(Charger.charge_box_id == charge_box_id)
)
charger = result.scalar_one_or_none()
if not charger:
return {"message": "충전기 없음", "cancelled": 0}
result = await db.execute(
select(ChargingSession).where(
and_(
ChargingSession.charger_id == charger.id,
ChargingSession.status.in_([
SessionStatus.PENDING,
SessionStatus.AUTHORIZED,
SessionStatus.CHARGING,
]),
)
)
)
sessions = result.scalars().all()
for s in sessions:
s.status = SessionStatus.CANCELLED
return {"message": f"{len(sessions)}건 세션 취소", "cancelled": len(sessions)}
@router.post("/{session_uid}/force-authorize")
async def force_authorize(
session_uid: str,
db: AsyncSession = Depends(get_db),
):
"""[테스트 전용] 결제 없이 세션을 AUTHORIZED로 강제 변경"""
session = await _get_session(db, session_uid)
session.status = SessionStatus.AUTHORIZED
return {"message": "세션 AUTHORIZED로 변경", "session_uid": session_uid}
# ── 유틸 ──
async def _get_session(
db: AsyncSession, session_uid: str
) -> ChargingSession:
result = await db.execute(
select(ChargingSession).where(
ChargingSession.session_uid == session_uid
)
)
session = result.scalar_one_or_none()
if not session:
raise HTTPException(404, f"세션 없음: {session_uid}")
return session

156
app/schemas/__init__.py Normal file
View File

@@ -0,0 +1,156 @@
"""Pydantic 스키마 (요청/응답 직렬화)"""
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field
# ━━━━━━━━ 충전기 ━━━━━━━━
class ChargerCreate(BaseModel):
charge_box_id: str = Field(..., example="CHARGER_001")
name: str = Field("", example="A동 주차장 1번")
location: str = Field("", example="수원시 영통구 아파트 지하1층")
connector_count: int = 1
power_kw: float = 7.0
class ChargerOut(BaseModel):
id: int
charge_box_id: str
name: str
location: str
connector_count: int
power_kw: float
status: str
last_heartbeat: Optional[datetime] = None
is_active: bool
class Config:
from_attributes = True
class ChargerStatusUpdate(BaseModel):
status: str
timestamp: Optional[datetime] = None
# ━━━━━━━━ 충전 세션 ━━━━━━━━
class SessionCreate(BaseModel):
"""QR 스캔 → 세션 생성 요청"""
charge_box_id: str = Field(..., example="CHARGER_001")
connector_id: int = 1
class SessionOut(BaseModel):
id: int
session_uid: str
charger_id: int
id_tag: Optional[str] = None
status: str
meter_start: Optional[int] = None
meter_stop: Optional[int] = None
charged_wh: int = 0
total_bill: int = 0
started_at: Optional[datetime] = None
stopped_at: Optional[datetime] = None
created_at: datetime
class Config:
from_attributes = True
class SessionBilling(BaseModel):
"""정산 결과"""
session_uid: str
charged_kwh: float
electricity_cost: int
service_fee: int
total_bill: int
saved_vs_cpo: int
# ━━━━━━━━ 결제 ━━━━━━━━
class PaymentRequest(BaseModel):
"""결제 시작 요청 (모바일 웹에서 호출)"""
session_uid: str
amount: int = Field(..., ge=100, example=10000, description="선결제 금액 (원)")
class PaymentConfirm(BaseModel):
"""토스페이먼츠 결제 승인 요청"""
payment_key: str
order_id: str
amount: int
class PaymentOut(BaseModel):
order_id: str
payment_key: Optional[str] = None
amount: int
actual_amount: Optional[int] = None
status: str
method: Optional[str] = None
confirmed_at: Optional[datetime] = None
class Config:
from_attributes = True
# ━━━━━━━━ Steve OCPP 콜백 ━━━━━━━━
class OcppBootNotification(BaseModel):
charge_box_id: str
charge_point_vendor: Optional[str] = None
charge_point_model: Optional[str] = None
firmware_version: Optional[str] = None
class OcppStatusNotification(BaseModel):
charge_box_id: str
connector_id: int = 1
status: str # Available, Charging, Faulted ...
error_code: str = "NoError"
timestamp: Optional[datetime] = None
class OcppStartTransaction(BaseModel):
charge_box_id: str
connector_id: int = 1
id_tag: str
meter_start: int # Wh
transaction_id: Optional[int] = None
timestamp: Optional[datetime] = None
class OcppStopTransaction(BaseModel):
charge_box_id: str
transaction_id: int
id_tag: Optional[str] = None
meter_stop: int # Wh
reason: str = "Local"
timestamp: Optional[datetime] = None
class OcppMeterValues(BaseModel):
charge_box_id: str
connector_id: int = 1
transaction_id: Optional[int] = None
value: float # Wh
measurand: str = "Energy.Active.Import.Register"
timestamp: Optional[datetime] = None
# ━━━━━━━━ 대시보드 ━━━━━━━━
class DashboardSummary(BaseModel):
total_chargers: int
active_chargers: int
charging_now: int
today_sessions: int
today_revenue: int
today_kwh: float
month_revenue: int
month_kwh: float

0
app/services/__init__.py Normal file
View File

113
app/services/auth.py Normal file
View File

@@ -0,0 +1,113 @@
"""인증 서비스 — JWT 토큰 + 비밀번호 해싱"""
from datetime import datetime, timezone, timedelta
from typing import Optional
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from jose import JWTError, jwt
from passlib.context import CryptContext
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.database import get_db
from app.models import User, UserRole
settings = get_settings()
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
security = HTTPBearer(auto_error=False)
# ── 비밀번호 ──
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
# ── JWT 토큰 ──
def create_token(user_id: int, username: str, role: str) -> str:
expire = datetime.now(timezone.utc) + timedelta(
minutes=settings.JWT_EXPIRE_MINUTES
)
payload = {
"sub": str(user_id),
"username": username,
"role": role,
"exp": expire,
}
return jwt.encode(payload, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM)
def decode_token(token: str) -> Optional[dict]:
try:
payload = jwt.decode(
token, settings.JWT_SECRET, algorithms=[settings.JWT_ALGORITHM]
)
return payload
except JWTError:
return None
# ── FastAPI 인증 의존성 ──
async def get_current_user(
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
db: AsyncSession = Depends(get_db),
) -> User:
"""현재 로그인된 사용자 반환 — 인증 필수 엔드포인트에 사용"""
if not credentials:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="인증이 필요합니다",
headers={"WWW-Authenticate": "Bearer"},
)
payload = decode_token(credentials.credentials)
if not payload:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="유효하지 않은 토큰입니다",
)
user_id = int(payload.get("sub", 0))
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user or not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="비활성화된 계정입니다",
)
return user
async def require_admin(user: User = Depends(get_current_user)) -> User:
"""관리자 권한 필수"""
if user.role != UserRole.ADMIN:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="관리자 권한이 필요합니다",
)
return user
async def optional_auth(
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
db: AsyncSession = Depends(get_db),
) -> Optional[User]:
"""인증 선택 — 토큰 있으면 사용자 반환, 없으면 None"""
if not credentials:
return None
payload = decode_token(credentials.credentials)
if not payload:
return None
user_id = int(payload.get("sub", 0))
result = await db.execute(select(User).where(User.id == user_id))
return result.scalar_one_or_none()

51
app/services/billing.py Normal file
View File

@@ -0,0 +1,51 @@
"""전력량 기반 요금 정산 서비스
OCPP MeterValues의 Energy.Active.Import.Register (Wh 단위)를
세션 시작/종료 시점에 기록하여 실제 충전량 기반 요금 계산.
"""
from app.config import get_settings
settings = get_settings()
def calculate_bill(start_wh: int, end_wh: int) -> dict:
"""충전 요금 계산
Args:
start_wh: 충전 시작 시 meter 값 (Wh)
end_wh: 충전 종료 시 meter 값 (Wh)
Returns:
dict: 정산 내역
"""
charged_wh = max(0, end_wh - start_wh)
charged_kwh = charged_wh / 1000
electricity_cost = round(charged_kwh * settings.ELECTRICITY_RATE)
service_fee = round(charged_kwh * settings.SERVICE_MARGIN)
total_bill = round(charged_kwh * settings.total_rate)
# CPO 방식(350원/kWh) 대비 절감액
cpo_rate = 350
saved_vs_cpo = round(charged_kwh * (cpo_rate - settings.total_rate))
return {
"charged_wh": charged_wh,
"charged_kwh": round(charged_kwh, 3),
"electricity_cost": electricity_cost,
"service_fee": service_fee,
"total_bill": total_bill,
"saved_vs_cpo": saved_vs_cpo,
}
def estimate_charge_cost(kwh: float) -> dict:
"""예상 충전 요금 계산 (QR 결제 화면용)"""
return {
"estimated_kwh": kwh,
"rate_per_kwh": settings.total_rate,
"estimated_cost": round(kwh * settings.total_rate),
"cpo_comparison": round(kwh * 350),
"savings": round(kwh * (350 - settings.total_rate)),
}

115
app/services/payment.py Normal file
View File

@@ -0,0 +1,115 @@
"""토스페이먼츠 결제 서비스
결제 흐름:
1. 프론트에서 토스 결제 UI 호출 (클라이언트 키 사용)
2. 사용자 카드 입력 → paymentKey 발급
3. 백엔드에서 confirm API 호출 → 실제 청구 확정 (시크릿 키 사용)
4. 승인 확인 후 OCPP RemoteStartTransaction 전송
"""
import base64
import logging
from typing import Optional
import httpx
from app.config import get_settings
logger = logging.getLogger(__name__)
settings = get_settings()
TOSS_API_BASE = "https://api.tosspayments.com/v1"
class TossPaymentService:
def __init__(self):
# 시크릿 키를 Base64 인코딩 (Basic Auth)
secret = settings.TOSS_SECRET_KEY
encoded = base64.b64encode(f"{secret}:".encode()).decode()
self.headers = {
"Authorization": f"Basic {encoded}",
"Content-Type": "application/json",
}
async def confirm_payment(
self, payment_key: str, order_id: str, amount: int
) -> dict:
"""결제 최종 승인 (confirm)
프론트에서 결제 완료 후 paymentKey, orderId, amount를 전달받아
토스 서버에 최종 승인 요청.
Returns:
토스 응답 dict (성공 시 status="DONE")
"""
payload = {
"paymentKey": payment_key,
"orderId": order_id,
"amount": amount,
}
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.post(
f"{TOSS_API_BASE}/payments/confirm",
headers=self.headers,
json=payload,
)
data = resp.json()
if resp.status_code == 200:
logger.info(f"결제 승인 성공: {order_id} / {amount}")
return {"success": True, "data": data}
else:
logger.error(f"결제 승인 실패: {data}")
return {
"success": False,
"error_code": data.get("code", "UNKNOWN"),
"message": data.get("message", "결제 승인 실패"),
}
async def get_payment(self, payment_key: str) -> Optional[dict]:
"""결제 상세 조회"""
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(
f"{TOSS_API_BASE}/payments/{payment_key}",
headers=self.headers,
)
if resp.status_code == 200:
return resp.json()
return None
async def cancel_payment(
self,
payment_key: str,
cancel_reason: str = "사용자 취소",
cancel_amount: Optional[int] = None,
) -> dict:
"""결제 취소 / 부분 환불
Args:
payment_key: 토스 paymentKey
cancel_reason: 취소 사유
cancel_amount: 부분 취소 금액 (None이면 전액 취소)
"""
payload = {"cancelReason": cancel_reason}
if cancel_amount is not None:
payload["cancelAmount"] = cancel_amount
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.post(
f"{TOSS_API_BASE}/payments/{payment_key}/cancel",
headers=self.headers,
json=payload,
)
data = resp.json()
if resp.status_code == 200:
logger.info(f"결제 취소 성공: {payment_key}")
return {"success": True, "data": data}
else:
logger.error(f"결제 취소 실패: {data}")
return {"success": False, "message": data.get("message")}
toss_service = TossPaymentService()

94
app/services/scheduler.py Normal file
View File

@@ -0,0 +1,94 @@
"""백그라운드 태스크 — Steve 폴링 + 세션 정리
Steve 서버에 webhook이 없는 경우,
APScheduler로 주기적으로 트랜잭션 데이터 동기화.
"""
import logging
from datetime import datetime, timezone, timedelta
from sqlalchemy import select, and_
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import AsyncSessionLocal
from app.models import Charger, ChargingSession, SessionStatus
from app.services.steve_client import steve_client
logger = logging.getLogger(__name__)
async def poll_steve_heartbeats():
"""Steve에서 충전기 상태 폴링 (60초 간격 권장)"""
try:
charge_points = await steve_client.get_charge_points()
if not charge_points:
return
async with AsyncSessionLocal() as db:
for cp in charge_points:
cp_id = cp.get("chargeBoxId") or cp.get("chargePointId")
if not cp_id:
continue
result = await db.execute(
select(Charger).where(Charger.charge_box_id == cp_id)
)
charger = result.scalar_one_or_none()
if charger:
charger.last_heartbeat = datetime.now(timezone.utc)
await db.commit()
except Exception as e:
logger.error(f"Steve 폴링 실패: {e}")
async def cleanup_stale_sessions():
"""오래된 PENDING 세션 정리 (10분 초과 시 취소)"""
try:
cutoff = datetime.now(timezone.utc) - timedelta(minutes=10)
async with AsyncSessionLocal() as db:
result = await db.execute(
select(ChargingSession).where(
and_(
ChargingSession.status == SessionStatus.PENDING,
ChargingSession.created_at < cutoff,
)
)
)
stale = result.scalars().all()
for session in stale:
session.status = SessionStatus.CANCELLED
logger.info(f"만료 세션 취소: {session.session_uid}")
if stale:
await db.commit()
logger.info(f"만료 세션 {len(stale)}건 정리 완료")
except Exception as e:
logger.error(f"세션 정리 실패: {e}")
async def check_long_charging_sessions():
"""12시간 이상 충전 중인 세션 경고"""
try:
cutoff = datetime.now(timezone.utc) - timedelta(hours=12)
async with AsyncSessionLocal() as db:
result = await db.execute(
select(ChargingSession).where(
and_(
ChargingSession.status == SessionStatus.CHARGING,
ChargingSession.started_at < cutoff,
)
)
)
long_sessions = result.scalars().all()
for session in long_sessions:
logger.warning(
f"장시간 충전: {session.session_uid} "
f"시작: {session.started_at}"
)
except Exception as e:
logger.error(f"장시간 충전 체크 실패: {e}")

View File

@@ -0,0 +1,132 @@
"""Steve OCPP 서버 REST API 클라이언트
Steve 대시보드 API를 통해 충전기 원격 제어 수행.
- RemoteStartTransaction: 결제 승인 후 충전 시작
- RemoteStopTransaction: 충전 원격 종료
"""
import logging
from typing import Optional
import httpx
from app.config import get_settings
logger = logging.getLogger(__name__)
settings = get_settings()
class SteveClient:
"""Steve OCPP 서버와 HTTP 통신"""
def __init__(self):
self.base_url = settings.STEVE_BASE_URL.rstrip("/")
self.auth = (settings.STEVE_API_USER, settings.STEVE_API_PASSWORD)
async def _request(
self, method: str, path: str, **kwargs
) -> Optional[dict]:
url = f"{self.base_url}{path}"
try:
async with httpx.AsyncClient(verify=True, timeout=10.0) as client:
resp = await client.request(
method, url, auth=self.auth, **kwargs
)
resp.raise_for_status()
if resp.headers.get("content-type", "").startswith("application/json"):
return resp.json()
return {"status": "ok", "code": resp.status_code}
except httpx.HTTPStatusError as e:
logger.error(f"Steve API HTTP 에러: {e.response.status_code} {url}")
return None
except Exception as e:
logger.error(f"Steve API 연결 실패: {e}")
return None
# ── 충전기 관리 ──
async def get_charge_points(self) -> Optional[list]:
"""등록된 충전기 목록 조회"""
return await self._request("GET", "/api/v1/chargepoints")
async def get_charge_point(self, charge_box_id: str) -> Optional[dict]:
"""특정 충전기 상세 정보"""
return await self._request("GET", f"/api/v1/chargepoints/{charge_box_id}")
# ── 원격 제어 (핵심) ──
async def remote_start_transaction(
self,
charge_box_id: str,
id_tag: str,
connector_id: int = 1,
) -> Optional[dict]:
"""충전기에 원격 충전 시작 명령 전송
결제 승인 완료 후 호출.
Steve → 충전기 WebSocket으로 RemoteStartTransaction 전송.
"""
payload = {
"chargeBoxId": charge_box_id,
"connectorId": connector_id,
"idTag": id_tag,
}
logger.info(f"RemoteStart 요청: {charge_box_id} tag={id_tag}")
result = await self._request(
"POST",
"/api/v1/operations/RemoteStartTransaction",
json=payload,
)
if result:
logger.info(f"RemoteStart 응답: {result}")
return result
async def remote_stop_transaction(
self,
charge_box_id: str,
transaction_id: int,
) -> Optional[dict]:
"""충전기에 원격 충전 종료 명령 전송"""
payload = {
"chargeBoxId": charge_box_id,
"transactionId": transaction_id,
}
logger.info(f"RemoteStop 요청: {charge_box_id} txn={transaction_id}")
result = await self._request(
"POST",
"/api/v1/operations/RemoteStopTransaction",
json=payload,
)
return result
# ── 트랜잭션 조회 ──
async def get_transactions(
self, charge_box_id: Optional[str] = None
) -> Optional[list]:
"""트랜잭션(충전 기록) 조회"""
params = {}
if charge_box_id:
params["chargeBoxId"] = charge_box_id
return await self._request(
"GET", "/api/v1/transactions", params=params
)
async def get_transaction(self, transaction_id: int) -> Optional[dict]:
"""특정 트랜잭션 상세"""
return await self._request(
"GET", f"/api/v1/transactions/{transaction_id}"
)
# ── ID 태그 관리 ──
async def add_id_tag(self, id_tag: str) -> Optional[dict]:
"""OCPP 인증용 ID 태그 등록"""
payload = {"idTag": id_tag}
return await self._request("POST", "/api/v1/idtags", json=payload)
async def get_id_tags(self) -> Optional[list]:
"""등록된 ID 태그 목록"""
return await self._request("GET", "/api/v1/idtags")
# 싱글턴
steve_client = SteveClient()

0
app/utils/__init__.py Normal file
View File

419
dashboard.html Normal file
View File

@@ -0,0 +1,419 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>EV 충전 관리 대시보드</title>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&family=JetBrains+Mono:wght@400;500;600&family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.7/chart.umd.js"></script>
<style>
:root {
--bg-0:#06090f;--bg-1:#0c1018;--bg-2:#121824;--bg-3:#1a2236;
--bg-card:rgba(18,24,36,0.85);
--accent:#00d4ff;--accent-dim:rgba(0,212,255,0.12);--accent-glow:rgba(0,212,255,0.25);
--green:#10b981;--green-dim:rgba(16,185,129,0.12);
--amber:#f59e0b;--amber-dim:rgba(245,158,11,0.12);
--red:#ef4444;--red-dim:rgba(239,68,68,0.12);
--purple:#8b5cf6;--purple-dim:rgba(139,92,246,0.12);
--text:#e2e8f0;--text-2:#94a3b8;--text-3:#64748b;
--border:rgba(255,255,255,0.06);--border-accent:rgba(0,212,255,0.15);
--radius:12px;--radius-sm:8px;
--font-display:'Outfit',sans-serif;--font-body:'Noto Sans KR',sans-serif;--font-mono:'JetBrains Mono',monospace;
}
*{margin:0;padding:0;box-sizing:border-box}
body{background:var(--bg-0);color:var(--text);font-family:var(--font-body);font-weight:400;line-height:1.6;min-height:100vh}
body::before{content:'';position:fixed;inset:0;background:radial-gradient(ellipse 60% 40% at 15% 5%,rgba(0,212,255,0.04) 0%,transparent 60%),radial-gradient(ellipse 50% 30% at 85% 90%,rgba(139,92,246,0.03) 0%,transparent 60%);pointer-events:none}
/* ── 로그인 ── */
.login-wrap{display:flex;align-items:center;justify-content:center;min-height:100vh;position:relative;z-index:1}
.login-card{background:var(--bg-card);border:1px solid var(--border-accent);border-radius:var(--radius);padding:40px;width:360px;backdrop-filter:blur(16px);position:relative;overflow:hidden}
.login-card::before{content:'';position:absolute;top:0;left:0;right:0;height:2px;background:linear-gradient(90deg,transparent,var(--accent),transparent)}
.login-brand{text-align:center;margin-bottom:32px}
.login-brand h1{font-family:var(--font-display);font-size:24px;font-weight:700;color:#fff;letter-spacing:-0.02em}
.login-brand small{font-family:var(--font-mono);font-size:10px;color:var(--accent);letter-spacing:0.2em;text-transform:uppercase}
.form-group{margin-bottom:18px}
.form-label{display:block;font-size:12px;color:var(--text-3);margin-bottom:6px;font-weight:500}
.form-input{width:100%;padding:10px 14px;background:var(--bg-3);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);font-size:14px;font-family:var(--font-body);outline:none;transition:border-color 0.15s}
.form-input:focus{border-color:var(--accent)}
.login-btn{width:100%;padding:12px;background:linear-gradient(135deg,rgba(0,212,255,0.2),rgba(139,92,246,0.15));border:1px solid var(--accent);border-radius:var(--radius-sm);color:var(--accent);font-family:var(--font-display);font-size:14px;font-weight:600;cursor:pointer;transition:all 0.2s;margin-top:8px}
.login-btn:hover{background:rgba(0,212,255,0.15);box-shadow:0 0 20px rgba(0,212,255,0.1)}
.login-error{color:var(--red);font-size:12px;text-align:center;margin-top:12px;min-height:18px}
/* ── 레이아웃 ── */
.shell{display:grid;grid-template-columns:220px 1fr;min-height:100vh}
.sidebar{background:var(--bg-1);border-right:1px solid var(--border);padding:28px 0;display:flex;flex-direction:column;position:sticky;top:0;height:100vh}
.sidebar-brand{padding:0 24px 28px;border-bottom:1px solid var(--border);margin-bottom:20px}
.sidebar-brand h1{font-family:var(--font-display);font-size:18px;font-weight:700;color:#fff;letter-spacing:-0.02em;line-height:1.2}
.sidebar-brand small{font-family:var(--font-mono);font-size:10px;color:var(--accent);letter-spacing:0.15em;text-transform:uppercase;display:block;margin-top:4px}
.nav-section{padding:0 12px;margin-bottom:8px}
.nav-section-label{font-family:var(--font-mono);font-size:9px;letter-spacing:0.2em;color:var(--text-3);text-transform:uppercase;padding:8px 12px 4px}
.nav-item{display:flex;align-items:center;gap:10px;padding:10px 12px;border-radius:var(--radius-sm);color:var(--text-2);font-size:13px;cursor:pointer;transition:all 0.15s;border:1px solid transparent}
.nav-item:hover{background:var(--bg-2);color:var(--text)}
.nav-item.active{background:var(--accent-dim);color:var(--accent);border-color:var(--border-accent)}
.nav-icon{width:18px;height:18px;opacity:0.7}
.nav-item.active .nav-icon{opacity:1}
.sidebar-footer{margin-top:auto;padding:16px 24px;border-top:1px solid var(--border)}
.sidebar-user{font-size:12px;color:var(--text-2);margin-bottom:8px}
.sidebar-user strong{color:#fff;font-weight:500}
.sidebar-user .role-tag{font-family:var(--font-mono);font-size:9px;padding:2px 6px;border-radius:3px;margin-left:4px;background:var(--accent-dim);color:var(--accent)}
.logout-btn{display:flex;align-items:center;gap:6px;padding:8px 0;color:var(--text-3);font-size:12px;cursor:pointer;transition:color 0.15s;background:none;border:none;font-family:var(--font-body)}
.logout-btn:hover{color:var(--red)}
.live-badge{display:inline-flex;align-items:center;gap:6px;font-family:var(--font-mono);font-size:10px;color:var(--green);letter-spacing:0.05em;margin-top:8px}
.live-dot{width:6px;height:6px;background:var(--green);border-radius:50%;animation:pulse 2s infinite}
@keyframes pulse{0%,100%{opacity:1;box-shadow:0 0 0 0 rgba(16,185,129,0.4)}50%{opacity:0.6;box-shadow:0 0 0 4px rgba(16,185,129,0)}}
/* ── 메인 ── */
.main{padding:32px;max-width:1200px}
.page-header{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:32px}
.page-title{font-family:var(--font-display);font-size:26px;font-weight:600;color:#fff;letter-spacing:-0.02em}
.page-sub{font-size:13px;color:var(--text-3);margin-top:4px}
.header-actions{display:flex;align-items:center;gap:12px}
.refresh-btn{display:flex;align-items:center;gap:6px;padding:8px 14px;background:var(--bg-2);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text-2);font-family:var(--font-mono);font-size:11px;cursor:pointer;transition:all 0.15s}
.refresh-btn:hover{border-color:var(--accent);color:var(--accent)}
.refresh-btn.loading svg{animation:spin 0.8s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}}
.last-update{font-family:var(--font-mono);font-size:11px;color:var(--text-3)}
/* ── 카드 ── */
.summary-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:16px;margin-bottom:28px}
.stat-card{background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:20px;backdrop-filter:blur(12px);position:relative;overflow:hidden;transition:border-color 0.2s}
.stat-card:hover{border-color:var(--border-accent)}
.stat-card::before{content:'';position:absolute;top:0;left:0;right:0;height:1px;background:linear-gradient(90deg,transparent,var(--card-accent,var(--accent)),transparent);opacity:0.4}
.stat-icon{width:36px;height:36px;border-radius:var(--radius-sm);display:flex;align-items:center;justify-content:center;margin-bottom:14px;font-size:16px}
.stat-card[data-color="cyan"] .stat-icon{background:var(--accent-dim)}
.stat-card[data-color="green"] .stat-icon{background:var(--green-dim)}
.stat-card[data-color="amber"] .stat-icon{background:var(--amber-dim)}
.stat-card[data-color="purple"] .stat-icon{background:var(--purple-dim)}
.stat-card[data-color="cyan"]{--card-accent:var(--accent)}
.stat-card[data-color="green"]{--card-accent:var(--green)}
.stat-card[data-color="amber"]{--card-accent:var(--amber)}
.stat-card[data-color="purple"]{--card-accent:var(--purple)}
.stat-label{font-size:12px;color:var(--text-3);margin-bottom:6px;font-weight:500}
.stat-value{font-family:var(--font-display);font-size:28px;font-weight:700;color:#fff;letter-spacing:-0.03em;line-height:1}
.stat-unit{font-family:var(--font-mono);font-size:12px;color:var(--text-3);margin-left:4px;font-weight:400}
.stat-sub{font-family:var(--font-mono);font-size:11px;color:var(--text-3);margin-top:8px}
.stat-sub .hi{color:var(--green)}
/* ── 패널 ── */
.content-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:28px}
.content-grid.full{grid-template-columns:1fr}
.panel{background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);backdrop-filter:blur(12px);overflow:hidden}
.panel-header{display:flex;justify-content:space-between;align-items:center;padding:16px 20px;border-bottom:1px solid var(--border)}
.panel-title{font-family:var(--font-display);font-size:14px;font-weight:600;color:#fff}
.panel-badge{font-family:var(--font-mono);font-size:10px;padding:3px 8px;border-radius:4px;letter-spacing:0.05em}
.panel-body{padding:20px}
.chart-wrap{height:240px;position:relative}
/* ── 충전기 ── */
.charger-list{display:flex;flex-direction:column;gap:10px}
.charger-row{display:flex;align-items:center;gap:14px;padding:14px 16px;background:var(--bg-3);border:1px solid var(--border);border-radius:var(--radius-sm);transition:border-color 0.15s}
.charger-row:hover{border-color:var(--border-accent)}
.charger-status-dot{width:10px;height:10px;border-radius:50%;flex-shrink:0}
.charger-status-dot.available{background:var(--green);box-shadow:0 0 8px rgba(16,185,129,0.4)}
.charger-status-dot.charging{background:var(--accent);box-shadow:0 0 8px rgba(0,212,255,0.4);animation:pulse 1.5s infinite}
.charger-status-dot.faulted{background:var(--red);box-shadow:0 0 8px rgba(239,68,68,0.4)}
.charger-status-dot.unavailable{background:var(--text-3)}
.charger-info{flex:1;min-width:0}
.charger-name{font-size:13px;font-weight:500;color:#fff;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.charger-id{font-family:var(--font-mono);font-size:11px;color:var(--text-3)}
.charger-badge{font-family:var(--font-mono);font-size:10px;padding:3px 10px;border-radius:4px;font-weight:500;white-space:nowrap}
.charger-badge.available{background:var(--green-dim);color:var(--green)}
.charger-badge.charging{background:var(--accent-dim);color:var(--accent)}
.charger-badge.faulted{background:var(--red-dim);color:var(--red)}
.charger-badge.unavailable{background:rgba(100,116,139,0.15);color:var(--text-3)}
.charger-meta{font-family:var(--font-mono);font-size:11px;color:var(--text-3);text-align:right;white-space:nowrap}
/* ── 테이블 ── */
.sessions-table{width:100%;border-collapse:collapse;font-size:12px}
.sessions-table th{text-align:left;padding:10px 14px;font-family:var(--font-mono);font-size:10px;letter-spacing:0.1em;text-transform:uppercase;color:var(--text-3);border-bottom:1px solid var(--border);font-weight:500}
.sessions-table td{padding:12px 14px;color:var(--text-2);border-bottom:1px solid var(--border);vertical-align:middle}
.sessions-table tr:last-child td{border-bottom:none}
.sessions-table tr:hover td{background:rgba(0,212,255,0.02)}
.sessions-table td:first-child{font-family:var(--font-mono);color:var(--text);font-size:11px}
.status-pill{display:inline-block;font-family:var(--font-mono);font-size:10px;padding:2px 8px;border-radius:4px;font-weight:500}
.status-pill.completed{background:var(--green-dim);color:var(--green)}
.status-pill.charging{background:var(--accent-dim);color:var(--accent)}
.status-pill.pending{background:var(--amber-dim);color:var(--amber)}
.status-pill.failed{background:var(--red-dim);color:var(--red)}
.status-pill.cancelled{background:rgba(100,116,139,0.15);color:var(--text-3)}
.status-pill.authorized{background:var(--purple-dim);color:var(--purple)}
.amount-cell{font-family:var(--font-mono);font-weight:500;color:#fff}
/* ── 요금 ── */
.rate-bar{display:flex;height:32px;border-radius:6px;overflow:hidden;margin:12px 0}
.rate-bar-seg{display:flex;align-items:center;justify-content:center;font-family:var(--font-mono);font-size:10px;font-weight:500}
.rate-legend{display:flex;gap:20px;margin-top:10px}
.rate-legend-item{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text-2)}
.rate-legend-dot{width:8px;height:8px;border-radius:2px}
.compare-row{display:flex;gap:16px;margin-top:16px}
.compare-card{flex:1;padding:16px;border-radius:var(--radius-sm);text-align:center}
.compare-card.ours{background:var(--green-dim);border:1px solid rgba(16,185,129,0.2)}
.compare-card.cpo{background:rgba(100,116,139,0.08);border:1px solid var(--border)}
.compare-label{font-size:11px;color:var(--text-3);margin-bottom:4px}
.compare-value{font-family:var(--font-display);font-size:22px;font-weight:700;line-height:1.2}
.compare-card.ours .compare-value{color:var(--green)}
.compare-card.cpo .compare-value{color:var(--text-3);text-decoration:line-through}
.compare-unit{font-family:var(--font-mono);font-size:11px;font-weight:400}
.savings-banner{text-align:center;padding:12px;background:linear-gradient(135deg,var(--green-dim),rgba(0,212,255,0.06));border:1px solid rgba(16,185,129,0.15);border-radius:var(--radius-sm);margin-top:12px;font-family:var(--font-mono);font-size:12px;color:var(--green)}
/* ── 사용자 관리 ── */
.user-row{display:flex;align-items:center;gap:14px;padding:14px 16px;background:var(--bg-3);border:1px solid var(--border);border-radius:var(--radius-sm);margin-bottom:10px}
.user-avatar{width:36px;height:36px;border-radius:50%;background:var(--accent-dim);display:flex;align-items:center;justify-content:center;font-family:var(--font-display);font-size:14px;font-weight:600;color:var(--accent);flex-shrink:0}
.user-info{flex:1}
.user-name{font-size:13px;font-weight:500;color:#fff}
.user-meta{font-family:var(--font-mono);font-size:11px;color:var(--text-3)}
.role-badge{font-family:var(--font-mono);font-size:10px;padding:3px 10px;border-radius:4px;font-weight:500}
.role-badge.admin{background:var(--red-dim);color:var(--red)}
.role-badge.operator{background:var(--amber-dim);color:var(--amber)}
.role-badge.viewer{background:var(--accent-dim);color:var(--accent)}
.user-actions{display:flex;gap:8px}
.btn-sm{padding:6px 12px;border-radius:var(--radius-sm);font-size:11px;font-family:var(--font-mono);cursor:pointer;border:1px solid var(--border);background:var(--bg-2);color:var(--text-2);transition:all 0.15s}
.btn-sm:hover{border-color:var(--accent);color:var(--accent)}
.btn-sm.danger:hover{border-color:var(--red);color:var(--red)}
.btn-primary{padding:10px 20px;background:var(--accent-dim);border:1px solid var(--accent);border-radius:var(--radius-sm);color:var(--accent);font-family:var(--font-display);font-size:13px;font-weight:600;cursor:pointer;transition:all 0.15s}
.btn-primary:hover{background:rgba(0,212,255,0.2)}
/* ── 모달 ── */
.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,0.6);display:flex;align-items:center;justify-content:center;z-index:200;backdrop-filter:blur(4px)}
.modal{background:var(--bg-2);border:1px solid var(--border-accent);border-radius:var(--radius);padding:28px;width:400px;position:relative}
.modal h3{font-family:var(--font-display);font-size:18px;font-weight:600;color:#fff;margin-bottom:20px}
.modal .form-group{margin-bottom:14px}
.modal-actions{display:flex;gap:10px;justify-content:flex-end;margin-top:20px}
.btn-cancel{padding:8px 16px;background:var(--bg-3);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text-2);font-size:12px;cursor:pointer}
.empty-state{text-align:center;padding:40px 20px;color:var(--text-3);font-size:13px}
.empty-icon{font-size:32px;margin-bottom:12px;opacity:0.3}
@keyframes fadeUp{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}
.fade-in{animation:fadeUp 0.4s ease-out forwards;opacity:0}
.fade-in:nth-child(1){animation-delay:0.05s}.fade-in:nth-child(2){animation-delay:0.1s}.fade-in:nth-child(3){animation-delay:0.15s}.fade-in:nth-child(4){animation-delay:0.2s}
.hidden{display:none!important}
@media(max-width:1024px){.summary-grid{grid-template-columns:repeat(2,1fr)}.content-grid{grid-template-columns:1fr}}
@media(max-width:768px){.shell{grid-template-columns:1fr}.sidebar{display:none}.main{padding:20px}.summary-grid{grid-template-columns:1fr 1fr}.stat-value{font-size:22px}}
</style>
</head>
<body>
<!-- 로그인 화면 -->
<div id="login-screen" class="login-wrap">
<div class="login-card">
<div class="login-brand">
<h1>EV Charging</h1>
<small>control panel</small>
</div>
<div class="form-group">
<label class="form-label">아이디</label>
<input class="form-input" id="login-user" type="text" placeholder="admin" autocomplete="username">
</div>
<div class="form-group">
<label class="form-label">비밀번호</label>
<input class="form-input" id="login-pass" type="password" placeholder="비밀번호 입력" autocomplete="current-password">
</div>
<button class="login-btn" onclick="doLogin()">로그인</button>
<div class="login-error" id="login-error"></div>
</div>
</div>
<!-- 대시보드 (로그인 후) -->
<div id="app-shell" class="shell hidden">
<aside class="sidebar">
<div class="sidebar-brand"><h1>EV Charging</h1><small>control panel</small></div>
<div class="nav-section">
<div class="nav-section-label">모니터링</div>
<div class="nav-item active" data-page="dashboard" onclick="navTo(this)">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/></svg>
대시보드</div>
<div class="nav-item" data-page="chargers" onclick="navTo(this)">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg>
충전기 현황</div>
<div class="nav-item" data-page="sessions" onclick="navTo(this)">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M12 8v4l3 3"/><circle cx="12" cy="12" r="9"/></svg>
충전 이력</div>
</div>
<div class="nav-section">
<div class="nav-section-label">관리</div>
<div class="nav-item" data-page="billing" onclick="navTo(this)">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2" y="5" width="20" height="14" rx="2"/><path d="M2 10h20"/></svg>
요금 / 정산</div>
<div class="nav-item" data-page="users" onclick="navTo(this)" id="nav-users">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 00-3-3.87"/><path d="M16 3.13a4 4 0 010 7.75"/></svg>
사용자 관리</div>
</div>
<div class="sidebar-footer">
<div class="sidebar-user" id="sidebar-user"></div>
<button class="logout-btn" onclick="doLogout()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
로그아웃</button>
<div class="live-badge"><span class="live-dot"></span><span id="server-status">연결됨</span></div>
</div>
</aside>
<main class="main" id="main-content"></main>
</div>
<!-- 모달 컨테이너 -->
<div id="modal-root"></div>
<script>
const API='/api/v1';
let token=null,currentUser=null,chartInstance=null,refreshTimer=null;
let state={summary:null,chargers:[],sessions:[],dailyStats:[],users:[],loading:true,page:'dashboard'};
// ── 인증 ──
function saveAuth(t,u){token=t;currentUser=u;try{sessionStorage.setItem('ev_token',t);sessionStorage.setItem('ev_user',JSON.stringify(u))}catch(e){}}
function loadAuth(){try{token=sessionStorage.getItem('ev_token');const u=sessionStorage.getItem('ev_user');if(u)currentUser=JSON.parse(u)}catch(e){}}
function clearAuth(){token=null;currentUser=null;try{sessionStorage.removeItem('ev_token');sessionStorage.removeItem('ev_user')}catch(e){}}
async function doLogin(){
const u=document.getElementById('login-user').value.trim();
const p=document.getElementById('login-pass').value;
const err=document.getElementById('login-error');
if(!u||!p){err.textContent='아이디와 비밀번호를 입력하세요';return}
err.textContent='';
try{
const r=await fetch(API+'/auth/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:u,password:p})});
const d=await r.json();
if(!r.ok){err.textContent=d.detail||'로그인 실패';return}
saveAuth(d.access_token,d.user);
enterApp();
}catch(e){err.textContent='서버 연결 실패'}
}
function doLogout(){clearAuth();document.getElementById('app-shell').classList.add('hidden');document.getElementById('login-screen').classList.remove('hidden');document.getElementById('login-pass').value=''}
function enterApp(){
document.getElementById('login-screen').classList.add('hidden');
document.getElementById('app-shell').classList.remove('hidden');
const su=document.getElementById('sidebar-user');
su.innerHTML=`<strong>${currentUser.display_name||currentUser.username}</strong><span class="role-tag">${currentUser.role}</span>`;
// 관리자만 사용자관리 표시
document.getElementById('nav-users').style.display=currentUser.role==='admin'?'flex':'none';
loadData();
startAutoRefresh();
}
// Enter 키 로그인
document.addEventListener('keydown',e=>{if(e.key==='Enter'&&!document.getElementById('login-screen').classList.contains('hidden'))doLogin()});
// ── API ──
async function api(path,opt={}){
try{
const headers={'Content-Type':'application/json',...(opt.headers||{})};
if(token)headers['Authorization']='Bearer '+token;
const r=await fetch(API+path,{...opt,headers});
if(r.status===401){doLogout();return null}
if(!r.ok){const d=await r.json().catch(()=>({}));throw d}
return await r.json();
}catch(e){if(e.detail)throw e;console.error('API:',path,e);return null}
}
async function loadData(){
const [summary,chargers,sessions,daily]=await Promise.all([
api('/dashboard/summary'),api('/chargers/?active_only=true'),
api('/dashboard/recent-sessions?limit=20'),api('/dashboard/daily-stats?days=30'),
]);
state.summary=summary;state.chargers=chargers||[];state.sessions=sessions||[];state.dailyStats=daily||[];state.loading=false;
render();
}
// ── 포맷 ──
function fN(n){return n==null?'0':n.toLocaleString('ko-KR')}
function fT(ts){if(!ts)return'-';const d=new Date(ts);return`${d.getMonth()+1}/${String(d.getDate()).padStart(2,'0')} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`}
function fDur(a,b){if(!a||!b)return'-';const m=Math.round((new Date(b)-new Date(a))/60000);return m<60?m+'분':Math.floor(m/60)+'시간 '+m%60+'분'}
function sKo(s){return{completed:'완료',charging:'충전중',pending:'대기',failed:'실패',cancelled:'취소',authorized:'인증됨',Available:'사용가능',Charging:'충전중',Faulted:'고장',Unavailable:'오프라인'}[s]||s}
function sC(s){return(s||'').toLowerCase()}
// ── 렌더 ──
function render(){
const m=document.getElementById('main-content');
switch(state.page){
case 'dashboard':m.innerHTML=renderDash();break;
case 'chargers':m.innerHTML=renderChargers();break;
case 'sessions':m.innerHTML=renderSessions();break;
case 'billing':m.innerHTML=renderBilling();break;
case 'users':m.innerHTML=renderUsers();break;
}
if(state.page==='dashboard'&&state.dailyStats.length>0)renderChart();
}
function navTo(el){state.page=el.dataset.page;document.querySelectorAll('.nav-item').forEach(n=>n.classList.remove('active'));el.classList.add('active');render();if(state.page==='users')loadUsers()}
// ── 대시보드 ──
function renderDash(){const s=state.summary||{};const now=new Date().toLocaleTimeString('ko-KR',{hour:'2-digit',minute:'2-digit'});
return `<div class="page-header"><div><div class="page-title">대시보드</div><div class="page-sub">충전 인프라 실시간 모니터링</div></div><div class="header-actions"><div class="last-update">갱신 ${now}</div><button class="refresh-btn" onclick="doRefresh(this)"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 2v6h-6"/><path d="M3 12a9 9 0 0115-6.7L21 8"/><path d="M3 22v-6h6"/><path d="M21 12a9 9 0 01-15 6.7L3 16"/></svg>새로고침</button></div></div>
<div class="summary-grid">
<div class="stat-card fade-in" data-color="cyan"><div class="stat-icon">⚡</div><div class="stat-label">등록 충전기</div><div class="stat-value">${s.total_chargers||0}<span class="stat-unit">대</span></div><div class="stat-sub">활성 <span class="hi">${s.active_chargers||0}</span> / 충전중 ${s.charging_now||0}</div></div>
<div class="stat-card fade-in" data-color="green"><div class="stat-icon">📊</div><div class="stat-label">오늘 충전</div><div class="stat-value">${s.today_sessions||0}<span class="stat-unit">건</span></div><div class="stat-sub">${s.today_kwh||0} kWh</div></div>
<div class="stat-card fade-in" data-color="amber"><div class="stat-icon">💰</div><div class="stat-label">오늘 매출</div><div class="stat-value">${fN(s.today_revenue)}<span class="stat-unit">원</span></div><div class="stat-sub">이번달 ${fN(s.month_revenue)}원</div></div>
<div class="stat-card fade-in" data-color="purple"><div class="stat-icon">🔋</div><div class="stat-label">이번달 전력량</div><div class="stat-value">${s.month_kwh||0}<span class="stat-unit">kWh</span></div><div class="stat-sub">CPO 절감 ${fN(Math.round((s.month_kwh||0)*180))}원</div></div>
</div>
<div class="content-grid">
<div class="panel fade-in"><div class="panel-header"><div class="panel-title">일별 충전 추이</div><div class="panel-badge" style="background:var(--accent-dim);color:var(--accent)">30일</div></div><div class="panel-body"><div class="chart-wrap"><canvas id="dailyChart"></canvas></div></div></div>
<div class="panel fade-in"><div class="panel-header"><div class="panel-title">충전기 상태</div><div class="panel-badge" style="background:var(--green-dim);color:var(--green)">${state.chargers.length}대</div></div><div class="panel-body">${state.chargers.length===0?'<div class="empty-state"><div class="empty-icon">⚡</div>등록된 충전기가 없습니다</div>':'<div class="charger-list">'+state.chargers.map(c=>`<div class="charger-row"><div class="charger-status-dot ${sC(c.status)}"></div><div class="charger-info"><div class="charger-name">${c.name||c.charge_box_id}</div><div class="charger-id">${c.charge_box_id} · ${c.power_kw}kW</div></div><div class="charger-badge ${sC(c.status)}">${sKo(c.status)}</div><div class="charger-meta">${c.last_heartbeat?fT(c.last_heartbeat):'—'}</div></div>`).join('')+'</div>'}</div></div>
</div>
<div class="content-grid full"><div class="panel fade-in"><div class="panel-header"><div class="panel-title">최근 충전 세션</div></div><div class="panel-body" style="padding:0">${state.sessions.length===0?'<div class="empty-state"><div class="empty-icon">📋</div>충전 기록이 없습니다</div>':sessionTable(state.sessions)}</div></div></div>`}
function sessionTable(ss){return `<table class="sessions-table"><thead><tr><th>세션 ID</th><th>충전기</th><th>상태</th><th>충전량</th><th>요금</th><th>시작</th><th>종료</th><th>소요</th></tr></thead><tbody>${ss.map(s=>`<tr><td>${s.session_uid||'-'}</td><td style="color:var(--text-2)">#${s.charger_id}</td><td><span class="status-pill ${sC(s.status)}">${sKo(s.status)}</span></td><td style="color:var(--text)">${s.charged_kwh||0} kWh</td><td class="amount-cell">${fN(s.total_bill)}원</td><td style="color:var(--text-3)">${fT(s.started_at)}</td><td style="color:var(--text-3)">${fT(s.stopped_at)}</td><td style="color:var(--text-3)">${fDur(s.started_at,s.stopped_at)}</td></tr>`).join('')}</tbody></table>`}
function renderChargers(){return `<div class="page-header"><div><div class="page-title">충전기 현황</div><div class="page-sub">등록된 충전기 상세 정보</div></div></div><div class="content-grid full"><div class="panel"><div class="panel-body">${state.chargers.length===0?'<div class="empty-state"><div class="empty-icon">⚡</div>등록된 충전기가 없습니다</div>':'<div class="charger-list">'+state.chargers.map(c=>`<div class="charger-row"><div class="charger-status-dot ${sC(c.status)}"></div><div class="charger-info"><div class="charger-name">${c.name||c.charge_box_id}</div><div class="charger-id">${c.charge_box_id}</div></div><div style="font-size:12px;color:var(--text-2)">${c.location||'-'}</div><div style="font-family:var(--font-mono);font-size:12px;color:var(--text-2)">${c.power_kw}kW · ${c.connector_count}구</div><div class="charger-badge ${sC(c.status)}">${sKo(c.status)}</div></div>`).join('')+'</div>'}</div></div></div>`}
function renderSessions(){return `<div class="page-header"><div><div class="page-title">충전 이력</div></div></div><div class="content-grid full"><div class="panel"><div class="panel-body" style="padding:0">${state.sessions.length===0?'<div class="empty-state"><div class="empty-icon">📋</div>충전 기록이 없습니다</div>':sessionTable(state.sessions)}</div></div></div>`}
function renderBilling(){const s=state.summary||{};return `<div class="page-header"><div><div class="page-title">요금 / 정산</div></div></div><div class="content-grid"><div class="panel fade-in"><div class="panel-header"><div class="panel-title">요금 구조</div><div class="panel-badge" style="background:var(--green-dim);color:var(--green)">170원/kWh</div></div><div class="panel-body"><div class="rate-bar"><div class="rate-bar-seg" style="flex:71;background:var(--accent);color:#fff">전기 120원</div><div class="rate-bar-seg" style="flex:29;background:var(--green);color:#fff">서비스 50원</div></div><div class="rate-legend"><div class="rate-legend-item"><div class="rate-legend-dot" style="background:var(--accent)"></div>전기 원가 120원/kWh</div><div class="rate-legend-item"><div class="rate-legend-dot" style="background:var(--green)"></div>서비스 마진 50원/kWh</div></div><div class="compare-row"><div class="compare-card ours"><div class="compare-label">우리 플랫폼</div><div class="compare-value">170<span class="compare-unit">원/kWh</span></div></div><div class="compare-card cpo"><div class="compare-label">CPO 방식</div><div class="compare-value">350<span class="compare-unit">원/kWh</span></div></div></div><div class="savings-banner">kWh당 180원 절감 · 약 51% 저렴</div></div></div><div class="panel fade-in"><div class="panel-header"><div class="panel-title">이번달 정산</div></div><div class="panel-body"><table class="sessions-table"><tr><td>총 충전량</td><td class="amount-cell" style="text-align:right">${s.month_kwh||0} kWh</td></tr><tr><td>전기 원가</td><td style="text-align:right;color:var(--text-2)">${fN(Math.round((s.month_kwh||0)*120))}원</td></tr><tr><td>서비스 수익</td><td style="text-align:right;color:var(--green)">${fN(Math.round((s.month_kwh||0)*50))}원</td></tr><tr><td style="font-weight:500;color:#fff">총 매출</td><td class="amount-cell" style="text-align:right">${fN(s.month_revenue)}원</td></tr><tr><td>CPO 대비 사용자 절감</td><td style="text-align:right;color:var(--amber)">${fN(Math.round((s.month_kwh||0)*180))}원</td></tr></table></div></div></div>`}
// ── 사용자 관리 ──
async function loadUsers(){state.users=await api('/auth/users')||[];render()}
function renderUsers(){
if(currentUser?.role!=='admin')return '<div class="empty-state">관리자 권한이 필요합니다</div>';
return `<div class="page-header"><div><div class="page-title">사용자 관리</div><div class="page-sub">대시보드 접근 계정 관리</div></div><div class="header-actions"><button class="btn-primary" onclick="showAddUser()">+ 사용자 추가</button></div></div>
<div class="content-grid full"><div class="panel"><div class="panel-body">${state.users.length===0?'<div class="empty-state">등록된 사용자가 없습니다</div>':state.users.map(u=>`<div class="user-row"><div class="user-avatar">${(u.display_name||u.username).charAt(0).toUpperCase()}</div><div class="user-info"><div class="user-name">${u.display_name||u.username}</div><div class="user-meta">@${u.username} · ${u.last_login?fT(u.last_login)+'마지막 로그인':'로그인 기록 없음'}</div></div><span class="role-badge ${u.role}">${u.role}</span><div class="user-actions"><button class="btn-sm" onclick="showEditUser(${u.id},'${u.username}','${u.display_name||''}','${u.role}',${u.is_active})">수정</button>${u.id!==currentUser.id?`<button class="btn-sm danger" onclick="deleteUser(${u.id},'${u.username}')">삭제</button>`:''}</div></div>`).join('')}</div></div></div>`}
function showAddUser(){
document.getElementById('modal-root').innerHTML=`<div class="modal-overlay" onclick="if(event.target===this)closeModal()"><div class="modal"><h3>사용자 추가</h3>
<div class="form-group"><label class="form-label">아이디</label><input class="form-input" id="mu-user" type="text" placeholder="영문, 숫자"></div>
<div class="form-group"><label class="form-label">비밀번호</label><input class="form-input" id="mu-pass" type="password"></div>
<div class="form-group"><label class="form-label">표시 이름</label><input class="form-input" id="mu-name" type="text" placeholder="홍길동"></div>
<div class="form-group"><label class="form-label">역할</label><select class="form-input" id="mu-role"><option value="viewer">viewer (조회만)</option><option value="operator">operator (운영)</option><option value="admin">admin (관리자)</option></select></div>
<div id="mu-error" style="color:var(--red);font-size:12px;min-height:16px;margin-bottom:8px"></div>
<div class="modal-actions"><button class="btn-cancel" onclick="closeModal()">취소</button><button class="btn-primary" onclick="doAddUser()">생성</button></div></div></div>`}
async function doAddUser(){
const u=document.getElementById('mu-user').value.trim(),p=document.getElementById('mu-pass').value,n=document.getElementById('mu-name').value.trim(),r=document.getElementById('mu-role').value;
const err=document.getElementById('mu-error');
if(!u||!p){err.textContent='아이디와 비밀번호를 입력하세요';return}
try{await api('/auth/users',{method:'POST',body:JSON.stringify({username:u,password:p,display_name:n||u,role:r})});closeModal();loadUsers()}catch(e){err.textContent=e.detail||'생성 실패'}}
function showEditUser(id,username,name,role,active){
document.getElementById('modal-root').innerHTML=`<div class="modal-overlay" onclick="if(event.target===this)closeModal()"><div class="modal"><h3>${username} 수정</h3>
<div class="form-group"><label class="form-label">표시 이름</label><input class="form-input" id="eu-name" type="text" value="${name}"></div>
<div class="form-group"><label class="form-label">역할</label><select class="form-input" id="eu-role"><option value="viewer" ${role==='viewer'?'selected':''}>viewer</option><option value="operator" ${role==='operator'?'selected':''}>operator</option><option value="admin" ${role==='admin'?'selected':''}>admin</option></select></div>
<div class="form-group"><label class="form-label">새 비밀번호 (변경 시에만 입력)</label><input class="form-input" id="eu-pass" type="password" placeholder="변경하지 않으려면 비워두세요"></div>
<div class="form-group"><label style="display:flex;align-items:center;gap:8px;cursor:pointer;font-size:13px;color:var(--text-2)"><input type="checkbox" id="eu-active" ${active?'checked':''}>계정 활성화</label></div>
<div id="eu-error" style="color:var(--red);font-size:12px;min-height:16px"></div>
<div class="modal-actions"><button class="btn-cancel" onclick="closeModal()">취소</button><button class="btn-primary" onclick="doEditUser(${id})">저장</button></div></div></div>`}
async function doEditUser(id){
const body={display_name:document.getElementById('eu-name').value,role:document.getElementById('eu-role').value,is_active:document.getElementById('eu-active').checked};
const p=document.getElementById('eu-pass').value;if(p)body.password=p;
try{await api('/auth/users/'+id,{method:'PUT',body:JSON.stringify(body)});closeModal();loadUsers()}catch(e){document.getElementById('eu-error').textContent=e.detail||'수정 실패'}}
async function deleteUser(id,name){if(!confirm(`${name} 계정을 삭제하시겠습니까?`))return;try{await api('/auth/users/'+id,{method:'DELETE'});loadUsers()}catch(e){alert(e.detail||'삭제 실패')}}
function closeModal(){document.getElementById('modal-root').innerHTML=''}
// ── 차트 ──
function renderChart(){const cv=document.getElementById('dailyChart');if(!cv)return;if(chartInstance)chartInstance.destroy();const d=state.dailyStats;const labels=d.map(x=>{const t=new Date(x.date);return(t.getMonth()+1)+'/'+t.getDate()});
chartInstance=new Chart(cv.getContext('2d'),{type:'bar',data:{labels,datasets:[{label:'매출 (원)',data:d.map(x=>x.revenue),backgroundColor:'rgba(0,212,255,0.3)',borderColor:'rgba(0,212,255,0.8)',borderWidth:1,borderRadius:4,yAxisID:'y',order:2},{label:'충전량 (kWh)',data:d.map(x=>x.kwh),type:'line',borderColor:'#10b981',backgroundColor:'rgba(16,185,129,0.1)',borderWidth:2,pointRadius:3,pointBackgroundColor:'#10b981',fill:true,tension:0.3,yAxisID:'y1',order:1}]},options:{responsive:true,maintainAspectRatio:false,interaction:{mode:'index',intersect:false},plugins:{legend:{display:true,position:'top',align:'end',labels:{color:'#94a3b8',font:{family:"'JetBrains Mono'",size:10},boxWidth:12,padding:16}},tooltip:{backgroundColor:'#1a2236',titleColor:'#e2e8f0',bodyColor:'#94a3b8',borderColor:'rgba(0,212,255,0.2)',borderWidth:1,titleFont:{family:"'JetBrains Mono'",size:11},bodyFont:{family:"'JetBrains Mono'",size:11},padding:10,cornerRadius:6,callbacks:{label:c=>c.dataset.yAxisID==='y'?' 매출: '+fN(c.raw)+'원':' 충전: '+c.raw+'kWh'}}},scales:{x:{grid:{color:'rgba(255,255,255,0.03)'},ticks:{color:'#64748b',font:{family:"'JetBrains Mono'",size:10}}},y:{position:'left',grid:{color:'rgba(255,255,255,0.03)'},ticks:{color:'#64748b',font:{family:"'JetBrains Mono'",size:10},callback:v=>fN(v)+'원'}},y1:{position:'right',grid:{drawOnChartArea:false},ticks:{color:'#10b981',font:{family:"'JetBrains Mono'",size:10},callback:v=>v+'kWh'}}}}})}
async function doRefresh(btn){if(btn)btn.classList.add('loading');await loadData();if(btn)setTimeout(()=>btn.classList.remove('loading'),500)}
function startAutoRefresh(){if(refreshTimer)clearInterval(refreshTimer);refreshTimer=setInterval(()=>loadData(),30000)}
// ── 초기화 ──
document.addEventListener('DOMContentLoaded',()=>{
loadAuth();
if(token&&currentUser){enterApp()}
});
</script>
</body>
</html>

66
docker-compose.yml Normal file
View File

@@ -0,0 +1,66 @@
version: "3.8"
services:
# ── FastAPI 백엔드 ──
api:
build: .
container_name: ev-api
restart: unless-stopped
ports:
- "8000:8000"
env_file:
- .env
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
volumes:
- ./app:/code/app
- ./dashboard.html:/code/dashboard.html:ro
- ./simulator.html:/code/simulator.html:ro
networks:
- ev-net
# ── PostgreSQL ──
postgres:
image: postgres:16-alpine
container_name: ev-postgres
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB:-ev_charging}
POSTGRES_USER: ${POSTGRES_USER:-evuser}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-evpass1234}
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-evuser}"]
interval: 5s
timeout: 3s
retries: 5
networks:
- ev-net
# ── Redis ──
redis:
image: redis:7-alpine
container_name: ev-redis
restart: unless-stopped
ports:
- "6375:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
networks:
- ev-net
volumes:
pgdata:
networks:
ev-net:
driver: bridge

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()

63
nginx_fastapi.conf Normal file
View File

@@ -0,0 +1,63 @@
# ─────────────────────────────────────────────
# Nginx 설정 — 기존 Steve + FastAPI 통합
#
# 기존 s1.byunc.com Nginx 설정에 아래 블록 추가.
# Steve와 FastAPI를 같은 도메인에서 path로 분기.
# ─────────────────────────────────────────────
# ── upstream 정의 ──
upstream fastapi_backend {
server 127.0.0.1:8000;
}
# server 블록 내부에 아래 location 추가:
# ── FastAPI API ──
location /api/ {
proxy_pass http://fastapi_backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 60s;
}
# ── FastAPI Docs (Swagger UI) ──
location /docs {
proxy_pass http://fastapi_backend;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /redoc {
proxy_pass http://fastapi_backend;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /openapi.json {
proxy_pass http://fastapi_backend;
}
# ── 헬스체크 ──
location /health {
proxy_pass http://fastapi_backend;
}
# ── 관리자 대시보드 ──
location = /dashboard {
alias /home/byun/ev-charging-backend/dashboard.html;
default_type text/html;
}
# ── 충전 페이지 (QR 스캔 진입점) ──
# 프론트 배포 후 정적 파일 서빙 또는 SPA로 교체
location /charge/ {
proxy_pass http://fastapi_backend;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
# ── 기존 Steve 설정 (유지) ──
# location /steve/ { ... }
# location /steve/websocket/ { ... }

40
nohup.out Normal file
View File

@@ -0,0 +1,40 @@
╔═══════════════════════════════════════════════════╗
║ OCPP 양방향 프록시 / 로깅 서버 v1.0 ║
╠═══════════════════════════════════════════════════╣
║ ║
║ 프록시 포트 : 9000 ║
║ 관리 웹 포트 : 9001 ║
║ 타겟 서버 : ws://cp.e-csp.co.kr/ocppext ║
║ 로그 폴더 : ocpp_logs/ ║
║ ║
╠═══════════════════════════════════════════════════╣
║ 충전기 설정 변경: ║
║ 기존 → ws://ecap.byunc.com/ocppext/{충전기ID} ║
║ ║
║ 관리 페이지: ║
║ http://192.168.0.114:9001 ║
║ http://ecap.byunc.com:9001 ║
╚═══════════════════════════════════════════════════╝
Traceback (most recent call last):
File "/root/steve/ev-charging-backend/ocpp_proxy_server.py", line 607, in <module>
asyncio.run(main())
File "/usr/lib/python3.11/asyncio/runners.py", line 190, in run
return runner.run(main)
^^^^^^^^^^^^^^^^
File "/usr/lib/python3.11/asyncio/runners.py", line 118, in run
return self._loop.run_until_complete(task)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/lib/python3.11/asyncio/base_events.py", line 653, in run_until_complete
return future.result()
^^^^^^^^^^^^^^^
File "/root/steve/ev-charging-backend/ocpp_proxy_server.py", line 580, in main
ws_server = await websockets.serve(
^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/dist-packages/websockets/asyncio/server.py", line 847, in __await_impl__
server = await self.create_server
^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/lib/python3.11/asyncio/base_events.py", line 1525, in create_server
raise OSError(err.errno, 'error while attempting '
OSError: [Errno 98] error while attempting to bind on address ('0.0.0.0', 9000): address already in use

214
ocpp_analyzer.py Normal file
View File

@@ -0,0 +1,214 @@
"""OCPP 캡처 데이터 분석기
ocpp_sniffer.py로 캡처한 메시지를 분석하여
충전기가 실제로 보내는 데이터 구조를 요약.
사용법:
python3 ocpp_analyzer.py
python3 ocpp_analyzer.py ocpp_captures/CHARGER_001_20260405.jsonl
"""
import json
import sys
import os
from collections import defaultdict
from datetime import datetime
G = "\033[92m"
Y = "\033[93m"
C = "\033[96m"
M = "\033[95m"
R = "\033[91m"
DIM = "\033[2m"
E = "\033[0m"
BOLD = "\033[1m"
LOG_DIR = "ocpp_captures"
def analyze_file(filepath):
"""JSONL 파일 분석"""
messages = []
with open(filepath, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if line:
messages.append(json.loads(line))
return messages
def summarize(messages):
"""메시지 요약 분석"""
# 액션별 분류
actions = defaultdict(list)
for msg in messages:
action = msg.get("action") or msg.get("message_type", "unknown")
actions[action].append(msg)
print(f"\n{C}{''*60}")
print(f" OCPP 메시지 분석 결과")
print(f"{len(messages)}개 메시지 / {len(actions)}종 액션")
print(f"{''*60}{E}\n")
for action, msgs in sorted(actions.items()):
charger_msgs = [m for m in msgs if m.get("direction") == "charger"]
steve_msgs = [m for m in msgs if m.get("direction") == "steve"]
print(f"{Y}{''*60}")
print(f" {BOLD}{action}{E}{Y} ({len(msgs)}건 — CP→CS: {len(charger_msgs)}, CS→CP: {len(steve_msgs)})")
print(f"{''*60}{E}")
# 페이로드 구조 분석
all_keys = set()
sample_payload = None
for msg in msgs:
payload = msg.get("payload", {})
if isinstance(payload, dict):
all_keys.update(payload.keys())
if sample_payload is None and payload:
sample_payload = payload
if all_keys:
print(f"\n {C}필드 목록:{E}")
for key in sorted(all_keys):
# 각 필드의 값 샘플 수집
values = set()
for msg in msgs:
p = msg.get("payload", {})
if isinstance(p, dict) and key in p:
v = p[key]
if isinstance(v, (str, int, float, bool)):
values.add(str(v))
elif isinstance(v, list):
values.add(f"[list, len={len(v)}]")
elif isinstance(v, dict):
values.add(f"{{dict, keys={list(v.keys())}}}")
vals_str = ", ".join(list(values)[:5])
if len(values) > 5:
vals_str += f" ... (+{len(values)-5}개)"
print(f" {G}{key}{E}: {DIM}{vals_str}{E}")
# MeterValues 상세 분석
if action == "MeterValues":
print(f"\n {M}MeterValues 상세 — measurand 목록:{E}")
measurands = defaultdict(list)
for msg in msgs:
payload = msg.get("payload", {})
meter_values = payload.get("meterValue", [])
for mv in meter_values:
sampled = mv.get("sampledValue", [])
for sv in sampled:
m_name = sv.get("measurand", "Energy.Active.Import.Register")
measurands[m_name].append({
"value": sv.get("value"),
"unit": sv.get("unit"),
"phase": sv.get("phase"),
"context": sv.get("context"),
"format": sv.get("format"),
"location": sv.get("location"),
})
for m_name, samples in sorted(measurands.items()):
units = set(s["unit"] for s in samples if s["unit"])
phases = set(s["phase"] for s in samples if s["phase"])
values = [s["value"] for s in samples if s["value"]]
unit_str = ", ".join(units) if units else "없음"
phase_str = ", ".join(phases) if phases else "전체"
val_range = ""
if values:
try:
nums = [float(v) for v in values]
val_range = f"범위: {min(nums):.1f} ~ {max(nums):.1f}"
except ValueError:
val_range = f"샘플: {values[0]}"
print(f" {G}{m_name}{E}")
print(f" 단위: {unit_str} | 위상: {phase_str} | {val_range} | {len(samples)}")
# StatusNotification 상세
if action == "StatusNotification":
print(f"\n {M}StatusNotification 상세:{E}")
statuses = defaultdict(int)
errors = defaultdict(int)
vendor_errors = set()
for msg in msgs:
p = msg.get("payload", {})
statuses[p.get("status", "?")] += 1
errors[p.get("errorCode", "?")] += 1
ve = p.get("vendorErrorCode", "")
if ve:
vendor_errors.add(ve)
print(f" 상태: {dict(statuses)}")
print(f" 에러코드: {dict(errors)}")
if vendor_errors:
print(f" {R}벤더 에러코드: {vendor_errors}{E}")
# BootNotification 상세
if action == "BootNotification":
for msg in msgs:
p = msg.get("payload", {})
if msg.get("direction") == "charger":
print(f"\n {M}충전기 정보:{E}")
for k, v in p.items():
print(f" {G}{k}{E}: {v}")
# 샘플 출력
if sample_payload:
print(f"\n {DIM}샘플 페이로드:{E}")
print(f" {DIM}{json.dumps(sample_payload, indent=4, ensure_ascii=False)[:500]}{E}")
print()
# 데이터 구조 요약 출력
print(f"\n{C}{''*60}")
print(f" 대시보드 적용 가능한 데이터 항목")
print(f"{''*60}{E}\n")
print(f" 위 분석 결과를 바탕으로 dashboard에 추가할 수 있는 항목:")
print(f" - MeterValues의 measurand 목록 → 실시간 모니터링 차트")
print(f" - StatusNotification의 에러코드 → 충전기 고장 알림")
print(f" - BootNotification의 펌웨어 정보 → 충전기 상세 정보")
print(f" - vendorErrorCode → 제조사 전용 에러 코드 매핑")
print()
def main():
if len(sys.argv) > 1:
filepath = sys.argv[1]
if not os.path.exists(filepath):
print(f"{R}파일 없음: {filepath}{E}")
return
messages = analyze_file(filepath)
summarize(messages)
else:
# 전체 캡처 파일 분석
if not os.path.exists(LOG_DIR):
print(f"{Y}캡처 폴더 없음: {LOG_DIR}/")
print(f"먼저 ocpp_sniffer.py를 실행하여 메시지를 캡처하세요.{E}")
return
all_messages = []
files = sorted(f for f in os.listdir(LOG_DIR) if f.endswith(".jsonl") and not f.startswith("_"))
if not files:
print(f"{Y}캡처된 메시지가 없습니다.")
print(f"먼저 ocpp_sniffer.py를 실행하여 메시지를 캡처하세요.{E}")
return
print(f"\n{C}캡처 파일 목록:{E}")
for f in files:
size = os.path.getsize(os.path.join(LOG_DIR, f))
print(f" {f} ({size:,} bytes)")
for f in files:
all_messages.extend(analyze_file(os.path.join(LOG_DIR, f)))
summarize(all_messages)
if __name__ == "__main__":
main()

645
ocpp_proxy_server.py Normal file
View File

@@ -0,0 +1,645 @@
"""OCPP 양방향 프록시 / 로깅 서버 (인증 포함)
충전기 ↔ [이 프록시] ↔ CPO 서버
양방향 WebSocket 메시지를 투명하게 중계하면서 모든 통신을 로깅.
실행:
pip3 install websockets aiohttp
python3 ocpp_proxy_server.py
구조:
- 포트 9000: WebSocket 프록시 (충전기 연결, 인증 불필요)
- 포트 9001: 관리 웹서버 (로그인 필수)
"""
import asyncio
import json
import os
import time
import struct
import logging
import hashlib
import hmac
import secrets
from datetime import datetime, timezone, timedelta
from pathlib import Path
from typing import Dict, Optional, Set
from dataclasses import dataclass, asdict
import websockets
from aiohttp import web
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 설정
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
PROXY_PORT = 9002
WEB_PORT = 9003
LOG_DIR = "ocpp_logs"
CONFIG_FILE = "proxy_config.json"
USERS_FILE = "proxy_users.json"
TOKEN_SECRET = secrets.token_hex(32)
TOKEN_EXPIRE_HOURS = 24
DEFAULT_CONFIG = {
"target_url": "ws://cp.e-csp.co.kr/ocppext",
"target_name": "e-CSP CPO",
"log_format": "both",
"log_enabled": True,
"pcap_enabled": False,
"ocpp_subprotocol": "ocpp1.6",
"max_log_size_mb": 100,
"my_ocpp_server": "ws://s1.byunc.com/steve/websocket/CentralSystemService",
"my_dashboard": "http://s1.byunc.com/dashboard",
}
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s", datefmt="%H:%M:%S")
logger = logging.getLogger("ocpp-proxy")
MSG_TYPES = {2: "CALL", 3: "CALLRESULT", 4: "CALLERROR"}
C="\033[96m";G="\033[92m";Y="\033[93m";R="\033[91m";M="\033[95m";DIM="\033[2m";E="\033[0m"
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 사용자 관리
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
def _hash_pw(password: str, salt: str = None) -> tuple:
if not salt:
salt = secrets.token_hex(16)
hashed = hashlib.pbkdf2_hmac("sha256", password.encode(), salt.encode(), 100000).hex()
return hashed, salt
def _verify_pw(password: str, hashed: str, salt: str) -> bool:
check, _ = _hash_pw(password, salt)
return hmac.compare_digest(check, hashed)
def _create_token(user_id: str, username: str, role: str) -> str:
payload = {
"uid": user_id, "user": username, "role": role,
"exp": (datetime.now(timezone.utc) + timedelta(hours=TOKEN_EXPIRE_HOURS)).timestamp(),
"nonce": secrets.token_hex(8),
}
data = json.dumps(payload, separators=(",", ":"))
import base64
b64 = base64.urlsafe_b64encode(data.encode()).decode()
sig = hmac.new(TOKEN_SECRET.encode(), b64.encode(), hashlib.sha256).hexdigest()[:32]
return f"{b64}.{sig}"
def _decode_token(token: str) -> Optional[dict]:
try:
import base64
parts = token.split(".")
if len(parts) != 2:
return None
b64, sig = parts
expected = hmac.new(TOKEN_SECRET.encode(), b64.encode(), hashlib.sha256).hexdigest()[:32]
if not hmac.compare_digest(sig, expected):
return None
payload = json.loads(base64.urlsafe_b64decode(b64))
if payload.get("exp", 0) < datetime.now(timezone.utc).timestamp():
return None
return payload
except Exception:
return None
class UserManager:
def __init__(self):
self.users = {}
self.load()
if not self.users:
self.add_user("admin", "admin1234", "관리자", "admin")
logger.info(f"{G}초기 관리자 계정 생성: admin / admin1234{E}")
def load(self):
try:
if os.path.exists(USERS_FILE):
with open(USERS_FILE, "r") as f:
self.users = json.load(f)
except Exception:
self.users = {}
def save(self):
with open(USERS_FILE, "w") as f:
json.dump(self.users, f, indent=2, ensure_ascii=False)
def add_user(self, username, password, display_name="", role="viewer"):
hashed, salt = _hash_pw(password)
self.users[username] = {
"username": username,
"hashed": hashed,
"salt": salt,
"display_name": display_name or username,
"role": role,
"is_active": True,
"created_at": datetime.now(timezone.utc).isoformat(),
"last_login": None,
}
self.save()
def verify(self, username, password):
user = self.users.get(username)
if not user or not user.get("is_active"):
return None
if _verify_pw(password, user["hashed"], user["salt"]):
user["last_login"] = datetime.now(timezone.utc).isoformat()
self.save()
return user
return None
def update_user(self, username, updates: dict):
user = self.users.get(username)
if not user:
return False
if "password" in updates:
hashed, salt = _hash_pw(updates.pop("password"))
user["hashed"] = hashed
user["salt"] = salt
for k in ("display_name", "role", "is_active"):
if k in updates:
user[k] = updates[k]
self.save()
return True
def delete_user(self, username):
if username in self.users:
del self.users[username]
self.save()
return True
return False
def list_users(self):
return [
{k: v for k, v in u.items() if k not in ("hashed", "salt")}
for u in self.users.values()
]
user_mgr = UserManager()
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 인증 미들웨어
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
PUBLIC_PATHS = {"/", "/api/auth/login"}
def _get_token(request):
auth = request.headers.get("Authorization", "")
if auth.startswith("Bearer "):
return auth[7:]
return request.cookies.get("token")
@web.middleware
async def auth_middleware(request, handler):
if request.path in PUBLIC_PATHS or request.path.startswith("/ws/"):
return await handler(request)
token = _get_token(request)
if not token:
return web.json_response({"error": "인증 필요"}, status=401)
payload = _decode_token(token)
if not payload:
return web.json_response({"error": "만료된 토큰"}, status=401)
user = user_mgr.users.get(payload.get("user"))
if not user or not user.get("is_active"):
return web.json_response({"error": "비활성 계정"}, status=401)
request["user"] = user
return await handler(request)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 설정 관리
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
class Config:
def __init__(self):
self.data = dict(DEFAULT_CONFIG)
self.load()
def load(self):
try:
if os.path.exists(CONFIG_FILE):
with open(CONFIG_FILE, "r") as f:
self.data.update(json.load(f))
except Exception:
pass
def save(self):
with open(CONFIG_FILE, "w") as f:
json.dump(self.data, f, indent=2, ensure_ascii=False)
def get(self, key):
return self.data.get(key, DEFAULT_CONFIG.get(key))
def update(self, updates):
self.data.update(updates)
self.save()
config = Config()
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 연결 추적
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@dataclass
class Connection:
charger_id: str
connected_at: str
target_url: str
messages_in: int = 0
messages_out: int = 0
bytes_in: int = 0
bytes_out: int = 0
last_activity: str = ""
status: str = "connected"
last_action: str = ""
active_connections: Dict[str, Connection] = {}
connection_history: list = []
stats = {"total_connections": 0, "total_messages": 0, "total_bytes": 0,
"start_time": datetime.now(timezone.utc).isoformat()}
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# OCPP 파싱 + 로깅
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
def parse_ocpp(raw):
try:
msg = json.loads(raw)
if not isinstance(msg, list) or len(msg) < 3:
return {"type": "unknown", "raw": raw[:200]}
result = {"type": MSG_TYPES.get(msg[0], f"TYPE_{msg[0]}"), "id": msg[1]}
if msg[0] == 2:
result["action"] = msg[2]
result["payload"] = msg[3] if len(msg) > 3 else {}
elif msg[0] == 3:
result["payload"] = msg[2] if len(msg) > 2 else {}
elif msg[0] == 4:
result["error_code"] = msg[2] if len(msg) > 2 else ""
result["error_desc"] = msg[3] if len(msg) > 3 else ""
return result
except json.JSONDecodeError:
return {"type": "parse_error", "raw": raw[:200]}
os.makedirs(LOG_DIR, exist_ok=True)
def _log_message(charger_id, direction, raw, parsed):
now = datetime.now(timezone.utc)
ts = now.strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
action = parsed.get("action", parsed.get("type", "?"))
msg_type = parsed.get("type", "?")
arrow = f"{G}▶ CP→CS{E}" if direction == "charger_to_server" else f"{C}◀ CS→CP{E}"
print(f"{DIM}{ts}{E} {arrow} {Y}{charger_id}{E} [{msg_type}] {M}{action}{E}")
if not config.get("log_enabled"):
return
date_str = now.strftime("%Y%m%d")
base = f"{LOG_DIR}/{charger_id}_{date_str}"
if config.get("log_format") in ("jsonl", "both"):
entry = {"timestamp": now.isoformat(), "charger_id": charger_id, "direction": direction,
"message_type": msg_type, "action": parsed.get("action"),
"message_id": parsed.get("id"), "payload": parsed.get("payload", {}), "raw": raw}
with open(f"{base}.jsonl", "a", encoding="utf-8") as f:
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
if config.get("log_format") in ("txt", "both"):
dir_label = "CP→CS" if direction == "charger_to_server" else "CS→CP"
with open(f"{base}.txt", "a", encoding="utf-8") as f:
f.write(f"\n{'='*70}\n[{ts}] {dir_label} | {msg_type} | {action}\n{''*70}\n")
try:
f.write(json.dumps(json.loads(raw), indent=2, ensure_ascii=False) + "\n")
except Exception:
f.write(raw + "\n")
if config.get("pcap_enabled"):
_write_pcap(f"{base}.pcap", direction, raw, now)
def _write_pcap(filepath, direction, data, ts):
raw_bytes = data.encode("utf-8")
if not os.path.exists(filepath):
with open(filepath, "wb") as f:
f.write(struct.pack("<IHHIIII", 0xa1b2c3d4, 2, 4, 0, 0, 65535, 147))
direction_byte = b'\x00' if direction == "charger_to_server" else b'\x01'
payload = direction_byte + raw_bytes
sec = int(ts.timestamp())
usec = int((ts.timestamp() - sec) * 1_000_000)
with open(filepath, "ab") as f:
f.write(struct.pack("<IIII", sec, usec, len(payload), len(payload)))
f.write(payload)
# 실시간 브로드캐스트
live_ws_clients: Set = set()
def log_message(charger_id, direction, raw, parsed):
_log_message(charger_id, direction, raw, parsed)
entry = {"timestamp": datetime.now(timezone.utc).isoformat(), "charger_id": charger_id,
"direction": direction, "type": parsed.get("type", "?"),
"action": parsed.get("action", ""),
"payload_preview": json.dumps(parsed.get("payload", {}), ensure_ascii=False)[:300]}
asyncio.ensure_future(broadcast_log(entry))
async def broadcast_log(entry):
if not live_ws_clients:
return
data = json.dumps(entry, ensure_ascii=False, default=str)
closed = set()
for ws in live_ws_clients:
try:
await ws.send_str(data)
except Exception:
closed.add(ws)
live_ws_clients -= closed
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# WebSocket 프록시
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
async def proxy_handler(ws_charger, path):
path_parts = path.strip("/").split("/")
charger_id = path_parts[-1] if path_parts else "unknown"
target_base = config.get("target_url").rstrip("/")
target_url = f"{target_base}{path}"
subprotocol = config.get("ocpp_subprotocol")
now_str = datetime.now(timezone.utc).isoformat()
conn = Connection(charger_id=charger_id, connected_at=now_str,
target_url=target_url, last_activity=now_str)
active_connections[charger_id] = conn
stats["total_connections"] += 1
logger.info(f"{G}충전기 연결: {charger_id}{target_url}{E}")
try:
async with websockets.connect(
target_url, subprotocols=[subprotocol] if subprotocol else None,
ping_interval=30, ping_timeout=20, close_timeout=10, max_size=2**20,
) as ws_server:
conn.status = "active"
async def c2s():
try:
async for msg in ws_charger:
parsed = parse_ocpp(msg)
log_message(charger_id, "charger_to_server", msg, parsed)
conn.messages_in += 1; conn.bytes_in += len(msg)
conn.last_activity = datetime.now(timezone.utc).isoformat()
conn.last_action = parsed.get("action", "")
stats["total_messages"] += 1; stats["total_bytes"] += len(msg)
await ws_server.send(msg)
except websockets.exceptions.ConnectionClosed:
pass
async def s2c():
try:
async for msg in ws_server:
parsed = parse_ocpp(msg)
log_message(charger_id, "server_to_charger", msg, parsed)
conn.messages_out += 1; conn.bytes_out += len(msg)
conn.last_activity = datetime.now(timezone.utc).isoformat()
stats["total_messages"] += 1; stats["total_bytes"] += len(msg)
await ws_charger.send(msg)
except websockets.exceptions.ConnectionClosed:
pass
done, pending = await asyncio.wait(
[asyncio.create_task(c2s()), asyncio.create_task(s2c())],
return_when=asyncio.FIRST_COMPLETED)
for t in pending:
t.cancel()
except Exception as e:
logger.error(f"{R}프록시 에러 [{charger_id}]: {e}{E}")
conn.status = "error"
finally:
conn.status = "disconnected"
connection_history.append(asdict(conn))
if len(connection_history) > 500:
connection_history.pop(0)
active_connections.pop(charger_id, None)
logger.info(f"{Y}연결 해제: {charger_id} (in:{conn.messages_in} out:{conn.messages_out}){E}")
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 관리 웹 API
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
async def h_index(request):
html_path = Path(__file__).parent / "proxy_control.html"
if html_path.exists():
return web.FileResponse(html_path)
return web.Response(text="proxy_control.html not found", status=404)
# ── 인증 ──
async def h_login(request):
data = await request.json()
user = user_mgr.verify(data.get("username", ""), data.get("password", ""))
if not user:
return web.json_response({"error": "아이디 또는 비밀번호가 틀립니다"}, status=401)
token = _create_token(user["username"], user["username"], user["role"])
return web.json_response({
"token": token,
"user": {"username": user["username"], "display_name": user["display_name"], "role": user["role"]},
})
async def h_me(request):
u = request["user"]
return web.json_response({"username": u["username"], "display_name": u["display_name"], "role": u["role"]})
# ── 사용자 관리 ──
async def h_users_list(request):
if request["user"]["role"] != "admin":
return web.json_response({"error": "관리자 권한 필요"}, status=403)
return web.json_response({"users": user_mgr.list_users()})
async def h_users_create(request):
if request["user"]["role"] != "admin":
return web.json_response({"error": "관리자 권한 필요"}, status=403)
data = await request.json()
username = data.get("username", "").strip()
password = data.get("password", "")
if not username or not password:
return web.json_response({"error": "아이디와 비밀번호 필수"}, status=400)
if username in user_mgr.users:
return web.json_response({"error": "이미 존재하는 아이디"}, status=409)
user_mgr.add_user(username, password, data.get("display_name", ""), data.get("role", "viewer"))
logger.info(f"사용자 생성: {username} by {request['user']['username']}")
return web.json_response({"status": "ok", "username": username})
async def h_users_update(request):
if request["user"]["role"] != "admin":
return web.json_response({"error": "관리자 권한 필요"}, status=403)
username = request.match_info["username"]
data = await request.json()
if user_mgr.update_user(username, data):
return web.json_response({"status": "ok"})
return web.json_response({"error": "사용자 없음"}, status=404)
async def h_users_delete(request):
if request["user"]["role"] != "admin":
return web.json_response({"error": "관리자 권한 필요"}, status=403)
username = request.match_info["username"]
if username == request["user"]["username"]:
return web.json_response({"error": "자기 자신 삭제 불가"}, status=400)
if user_mgr.delete_user(username):
return web.json_response({"status": "deleted"})
return web.json_response({"error": "사용자 없음"}, status=404)
# ── 프록시 상태/설정 ──
async def h_status(request):
return web.json_response({
"proxy_port": PROXY_PORT, "target_url": config.get("target_url"),
"target_name": config.get("target_name"),
"log_enabled": config.get("log_enabled"), "log_format": config.get("log_format"),
"pcap_enabled": config.get("pcap_enabled"),
"active_connections": {k: asdict(v) for k, v in active_connections.items()},
"active_count": len(active_connections), "stats": stats,
})
async def h_config_get(request):
return web.json_response(config.data)
async def h_config_set(request):
data = await request.json()
config.update(data)
logger.info(f"설정 변경 by {request['user']['username']}: {list(data.keys())}")
return web.json_response({"status": "ok", "config": config.data})
async def h_connections(request):
return web.json_response({
"active": {k: asdict(v) for k, v in active_connections.items()},
"history": connection_history[-50:],
})
# ── 로그 ──
async def h_logs_list(request):
files = []
if os.path.exists(LOG_DIR):
for f in sorted(os.listdir(LOG_DIR), reverse=True):
fp = os.path.join(LOG_DIR, f)
files.append({"name": f, "size": os.path.getsize(fp),
"modified": datetime.fromtimestamp(os.path.getmtime(fp)).isoformat()})
return web.json_response({"files": files})
async def h_log_content(request):
name = request.match_info["name"]
filepath = os.path.join(LOG_DIR, name)
if not os.path.exists(filepath):
return web.json_response({"error": "파일 없음"}, status=404)
if name.endswith(".jsonl"):
lines = []
with open(filepath, "r", encoding="utf-8") as f:
for line in f:
if line.strip():
try: lines.append(json.loads(line))
except: pass
return web.json_response({"entries": lines[-200:], "total": len(lines)})
if name.endswith(".txt"):
with open(filepath, "r", encoding="utf-8") as f:
content = f.read()
if len(content) > 50000:
content = "...(앞부분 생략)...\n\n" + content[-50000:]
return web.json_response({"content": content})
return web.FileResponse(filepath)
async def h_log_download(request):
name = request.match_info["name"]
filepath = os.path.join(LOG_DIR, name)
if not os.path.exists(filepath):
return web.Response(text="없음", status=404)
return web.FileResponse(filepath, headers={"Content-Disposition": f'attachment; filename="{name}"'})
async def h_log_delete(request):
name = request.match_info["name"]
filepath = os.path.join(LOG_DIR, name)
if os.path.exists(filepath):
os.remove(filepath)
return web.json_response({"status": "deleted"})
return web.json_response({"error": "없음"}, status=404)
# ── 실시간 로그 WebSocket (인증은 쿼리 파라미터) ──
async def h_ws_live(request):
token = request.query.get("token")
if token:
payload = _decode_token(token)
if not payload:
return web.Response(text="인증 실패", status=401)
ws = web.WebSocketResponse()
await ws.prepare(request)
live_ws_clients.add(ws)
try:
async for msg in ws:
pass
finally:
live_ws_clients.discard(ws)
return ws
def create_web_app():
app = web.Application(middlewares=[auth_middleware])
app.router.add_get("/", h_index)
# 인증
app.router.add_post("/api/auth/login", h_login)
app.router.add_get("/api/auth/me", h_me)
# 사용자 관리
app.router.add_get("/api/users", h_users_list)
app.router.add_post("/api/users", h_users_create)
app.router.add_put("/api/users/{username}", h_users_update)
app.router.add_delete("/api/users/{username}", h_users_delete)
# 상태/설정
app.router.add_get("/api/status", h_status)
app.router.add_get("/api/config", h_config_get)
app.router.add_post("/api/config", h_config_set)
app.router.add_get("/api/connections", h_connections)
# 로그
app.router.add_get("/api/logs", h_logs_list)
app.router.add_get("/api/logs/{name}", h_log_content)
app.router.add_get("/api/logs/{name}/download", h_log_download)
app.router.add_delete("/api/logs/{name}", h_log_delete)
# 실시간
app.router.add_get("/ws/live", h_ws_live)
return app
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 메인
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
async def main():
target = config.get("target_url")
print(f"""
{C}╔═══════════════════════════════════════════════════╗
║ OCPP 양방향 프록시 / 로깅 서버 v2.0 ║
║ (인증 + 사용자 관리 포함) ║
╠═══════════════════════════════════════════════════╣
║ 프록시 포트 : {PROXY_PORT}
║ 관리 웹 : {WEB_PORT} (로그인 필요) ║
║ 타겟 서버 : {target:<37s}
║ 초기 계정 : admin / admin1234 ║
╚═══════════════════════════════════════════════════╝{E}
""")
ws_server = await websockets.serve(
proxy_handler, "0.0.0.0", PROXY_PORT,
subprotocols=["ocpp1.6", "ocpp2.0.1"],
ping_interval=30, ping_timeout=20, max_size=2**20)
logger.info(f"프록시 서버: 포트 {PROXY_PORT}")
web_app = create_web_app()
runner = web.AppRunner(web_app)
await runner.setup()
site = web.TCPSite(runner, "0.0.0.0", WEB_PORT)
await site.start()
logger.info(f"관리 웹서버: 포트 {WEB_PORT}")
logger.info(f"{G}서버 대기 중... (Ctrl+C 종료){E}")
await asyncio.Future()
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
logger.info("서버 종료")

197
ocpp_sniffer.py Normal file
View File

@@ -0,0 +1,197 @@
"""OCPP WebSocket 메시지 스니퍼
실제 충전기가 보내는 OCPP 메시지를 캡처하여 JSON으로 저장.
충전기 → 이 프록시 → Steve 서버로 중계하면서 모든 메시지를 로깅.
사용법:
pip3 install websockets
python3 ocpp_sniffer.py
충전기 WebSocket URL을 이 프록시로 변경:
기존: wss://s1.byunc.com/steve/websocket/CentralSystemService/{id}
변경: ws://192.168.0.114:9000/{id}
프록시가 메시지를 캡처한 후 Steve로 전달.
"""
import asyncio
import websockets
import json
import os
from datetime import datetime
# ── 설정 ──
PROXY_PORT = 9000
STEVE_WS_URL = "ws://localhost:8180/steve/websocket/CentralSystemService"
LOG_DIR = "ocpp_captures"
os.makedirs(LOG_DIR, exist_ok=True)
# OCPP 메시지 타입
MSG_TYPES = {2: "CALL", 3: "CALLRESULT", 4: "CALLERROR"}
# 색상
C = "\033[96m"
G = "\033[92m"
Y = "\033[93m"
R = "\033[91m"
M = "\033[95m"
E = "\033[0m"
DIM = "\033[2m"
def parse_ocpp_message(raw):
"""OCPP JSON 메시지 파싱"""
try:
msg = json.loads(raw)
if not isinstance(msg, list) or len(msg) < 3:
return {"type": "unknown", "raw": raw}
msg_type = MSG_TYPES.get(msg[0], f"TYPE_{msg[0]}")
msg_id = msg[1]
if msg[0] == 2: # CALL (요청)
return {
"type": msg_type,
"id": msg_id,
"action": msg[2],
"payload": msg[3] if len(msg) > 3 else {},
}
elif msg[0] == 3: # CALLRESULT (응답)
return {
"type": msg_type,
"id": msg_id,
"payload": msg[2] if len(msg) > 2 else {},
}
elif msg[0] == 4: # CALLERROR (에러)
return {
"type": msg_type,
"id": msg_id,
"error_code": msg[2] if len(msg) > 2 else "",
"error_desc": msg[3] if len(msg) > 3 else "",
"error_detail": msg[4] if len(msg) > 4 else {},
}
except json.JSONDecodeError:
return {"type": "parse_error", "raw": raw}
def log_message(direction, charger_id, parsed, raw):
"""메시지 콘솔 출력 + 파일 저장"""
now = datetime.now()
timestamp = now.strftime("%H:%M:%S.%f")[:-3]
# 콘솔 출력
arrow = f"{G}▶ CP→CS{E}" if direction == "charger" else f"{C}◀ CS→CP{E}"
action = parsed.get("action", parsed.get("type", "?"))
msg_type = parsed.get("type", "?")
print(f"\n{DIM}{timestamp}{E} {arrow} {Y}[{msg_type}]{E} {M}{action}{E}")
print(f" {json.dumps(parsed.get('payload', {}), indent=2, ensure_ascii=False)}")
# 파일 저장
log_entry = {
"timestamp": now.isoformat(),
"direction": direction,
"charger_id": charger_id,
"message_type": msg_type,
"action": parsed.get("action"),
"message_id": parsed.get("id"),
"payload": parsed.get("payload", {}),
"raw": raw,
}
# 날짜별 파일
filename = f"{LOG_DIR}/{charger_id}_{now.strftime('%Y%m%d')}.jsonl"
with open(filename, "a", encoding="utf-8") as f:
f.write(json.dumps(log_entry, ensure_ascii=False) + "\n")
# 액션별 파일 (데이터 구조 분석용)
if parsed.get("action"):
action_file = f"{LOG_DIR}/_actions_{parsed['action']}.jsonl"
with open(action_file, "a", encoding="utf-8") as f:
f.write(json.dumps(log_entry, ensure_ascii=False) + "\n")
async def proxy_handler(ws_client, path):
"""충전기 → 프록시 → Steve 중계"""
# path에서 charger ID 추출: /CHARGER_001 → CHARGER_001
charger_id = path.strip("/")
if not charger_id:
charger_id = "unknown"
steve_url = f"{STEVE_WS_URL}/{charger_id}"
print(f"\n{'='*60}")
print(f"{G}충전기 연결: {charger_id}{E}")
print(f"{DIM}Steve 연결: {steve_url}{E}")
print(f"{'='*60}")
try:
async with websockets.connect(
steve_url,
subprotocols=["ocpp1.6"],
ping_interval=30,
ping_timeout=10,
) as ws_steve:
async def charger_to_steve():
"""충전기 → Steve 방향"""
async for message in ws_client:
parsed = parse_ocpp_message(message)
log_message("charger", charger_id, parsed, message)
await ws_steve.send(message)
async def steve_to_charger():
"""Steve → 충전기 방향"""
async for message in ws_steve:
parsed = parse_ocpp_message(message)
log_message("steve", charger_id, parsed, message)
await ws_client.send(message)
# 양방향 동시 중계
await asyncio.gather(
charger_to_steve(),
steve_to_charger(),
)
except websockets.exceptions.ConnectionClosed as e:
print(f"\n{R}연결 종료: {charger_id}{e}{E}")
except Exception as e:
print(f"\n{R}에러: {charger_id}{e}{E}")
finally:
print(f"{Y}충전기 연결 해제: {charger_id}{E}")
async def main():
print(f"""
{C}╔══════════════════════════════════════════════╗
║ OCPP WebSocket 메시지 스니퍼 v1.0 ║
║ 충전기 ↔ Steve 양방향 캡처 ║
╠══════════════════════════════════════════════╣
║ 프록시 포트 : {PROXY_PORT}
║ Steve 서버 : {STEVE_WS_URL}
║ 로그 폴더 : {LOG_DIR}/ ║
╠══════════════════════════════════════════════╣
║ 충전기 URL을 아래로 변경: ║
║ ws://192.168.0.114:{PROXY_PORT}/CHARGER_ID ║
╚══════════════════════════════════════════════╝{E}
""")
server = await websockets.serve(
proxy_handler,
"0.0.0.0",
PROXY_PORT,
subprotocols=["ocpp1.6"],
ping_interval=30,
ping_timeout=10,
)
print(f"{G}스니퍼 대기 중... (Ctrl+C로 종료){E}\n")
await server.wait_closed()
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
print(f"\n{Y}스니퍼 종료{E}")

5
proxy.log Normal file
View File

@@ -0,0 +1,5 @@
nohup: ignoring input
04:55:11 [INFO] server listening on 0.0.0.0:9002
04:55:11 [INFO] 프록시 서버: 포트 9002
04:55:11 [INFO] 관리 웹서버: 포트 9003
04:55:11 [INFO] 서버 대기 중... (Ctrl+C 종료)

339
proxy_control.html Normal file
View File

@@ -0,0 +1,339 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OCPP 프록시 관리</title>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&family=JetBrains+Mono:wght@400;500;600&family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<style>
:root{--bg-0:#06090f;--bg-1:#0c1018;--bg-2:#121824;--bg-3:#1a2236;--bg-card:rgba(18,24,36,0.85);--accent:#00d4ff;--accent-dim:rgba(0,212,255,0.12);--green:#10b981;--green-dim:rgba(16,185,129,0.12);--amber:#f59e0b;--amber-dim:rgba(245,158,11,0.12);--red:#ef4444;--red-dim:rgba(239,68,68,0.12);--purple:#8b5cf6;--purple-dim:rgba(139,92,246,0.12);--text:#e2e8f0;--text-2:#94a3b8;--text-3:#64748b;--border:rgba(255,255,255,0.06);--border-accent:rgba(0,212,255,0.15);--radius:12px;--radius-sm:8px;--mono:'JetBrains Mono',monospace;--display:'Outfit',sans-serif;--body:'Noto Sans KR',sans-serif}
*{margin:0;padding:0;box-sizing:border-box}body{background:var(--bg-0);color:var(--text);font-family:var(--body);min-height:100vh}
body::before{content:'';position:fixed;inset:0;background:radial-gradient(ellipse 60% 40% at 10% 5%,rgba(0,212,255,0.04) 0%,transparent 60%);pointer-events:none}
.hidden{display:none!important}
/* 로그인 */
.login-wrap{display:flex;align-items:center;justify-content:center;min-height:100vh;position:relative;z-index:1}
.login-card{background:var(--bg-card);border:1px solid var(--border-accent);border-radius:var(--radius);padding:40px;width:360px;backdrop-filter:blur(16px);position:relative;overflow:hidden}
.login-card::before{content:'';position:absolute;top:0;left:0;right:0;height:2px;background:linear-gradient(90deg,transparent,var(--accent),transparent)}
.login-brand{text-align:center;margin-bottom:28px}
.login-brand h1{font-family:var(--display);font-size:22px;font-weight:700;color:#fff}
.login-brand small{font-family:var(--mono);font-size:9px;color:var(--accent);letter-spacing:0.2em}
.form-group{margin-bottom:14px}.form-label{display:block;font-size:11px;color:var(--text-3);margin-bottom:4px;font-weight:500}
.form-input{width:100%;padding:9px 12px;background:var(--bg-3);border:1px solid var(--border);border-radius:6px;color:var(--text);font-family:var(--mono);font-size:12px;outline:none;transition:border-color 0.15s}
.form-input:focus{border-color:var(--accent)}select.form-input{cursor:pointer}
.login-btn{width:100%;padding:12px;background:var(--accent-dim);border:1px solid var(--accent);border-radius:var(--radius-sm);color:var(--accent);font-family:var(--display);font-size:14px;font-weight:600;cursor:pointer;margin-top:8px}
.login-btn:hover{background:rgba(0,212,255,0.2)}
.login-error{color:var(--red);font-size:11px;text-align:center;margin-top:10px;min-height:16px}
/* 레이아웃 */
.container{max-width:1200px;margin:0 auto;padding:24px;position:relative;z-index:1}
.header{display:flex;justify-content:space-between;align-items:center;padding-bottom:20px;border-bottom:1px solid var(--border);margin-bottom:24px}
.header h1{font-family:var(--display);font-size:24px;font-weight:700;color:#fff}
.header small{font-family:var(--mono);font-size:10px;color:var(--accent);letter-spacing:0.2em;display:block;margin-top:2px}
.header-right{display:flex;align-items:center;gap:16px}
.user-info{font-family:var(--mono);font-size:11px;color:var(--text-2)}
.user-info strong{color:#fff}
.user-info .role{padding:2px 6px;border-radius:3px;font-size:9px;margin-left:4px;background:var(--accent-dim);color:var(--accent)}
.logout-btn{padding:6px 12px;background:none;border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text-3);font-size:11px;cursor:pointer;font-family:var(--mono)}
.logout-btn:hover{border-color:var(--red);color:var(--red)}
.status-row{display:flex;align-items:center;gap:6px}
.status-dot{width:8px;height:8px;border-radius:50%;animation:pulse 2s infinite}
.status-dot.on{background:var(--green)}.status-dot.off{background:var(--red)}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:0.4}}
/* 탭 */
.tabs{display:flex;gap:4px;margin-bottom:20px;border-bottom:1px solid var(--border)}
.tab{padding:10px 18px;font-family:var(--mono);font-size:11px;color:var(--text-3);cursor:pointer;border-bottom:2px solid transparent;transition:all 0.15s}
.tab:hover{color:var(--text)}.tab.active{color:var(--accent);border-bottom-color:var(--accent)}
.tab-content{display:none}.tab-content.active{display:block}
/* 카드 */
.card{background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);backdrop-filter:blur(12px);margin-bottom:16px;overflow:hidden}
.card-header{padding:14px 18px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center}
.card-title{font-family:var(--display);font-size:14px;font-weight:600;color:#fff}
.card-body{padding:18px}
.form-row{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-bottom:14px}
.form-row.full{grid-template-columns:1fr}
.form-hint{font-size:10px;color:var(--text-3);margin-top:2px}
/* 버튼 */
.btn{padding:8px 16px;border-radius:var(--radius-sm);font-family:var(--mono);font-size:11px;cursor:pointer;border:1px solid var(--border);background:var(--bg-3);color:var(--text-2);transition:all 0.15s}
.btn:hover{border-color:var(--accent);color:var(--accent)}
.btn-primary{background:var(--accent-dim);border-color:var(--accent);color:var(--accent);font-weight:600}
.btn-primary:hover{background:rgba(0,212,255,0.2)}
.btn-danger:hover{border-color:var(--red);color:var(--red)}
.btn-sm{padding:5px 10px;font-size:10px}
.btn-row{display:flex;gap:8px;margin-top:12px}
/* 통계 */
.stats-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:20px}
.stat-card{background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:16px;text-align:center}
.stat-val{font-family:var(--display);font-size:28px;font-weight:700;color:#fff;line-height:1}
.stat-label{font-size:11px;color:var(--text-3);margin-top:6px}
/* 연결/사용자/파일 행 */
.row-item{display:flex;align-items:center;gap:12px;padding:12px 14px;background:var(--bg-3);border:1px solid var(--border);border-radius:var(--radius-sm);margin-bottom:8px}
.row-dot{width:10px;height:10px;border-radius:50%;flex-shrink:0}
.row-dot.active{background:var(--green);box-shadow:0 0 6px rgba(16,185,129,0.4);animation:pulse 1.5s infinite}
.row-dot.off{background:var(--text-3)}.row-dot.err{background:var(--red)}
.row-info{flex:1;min-width:0}
.row-title{font-family:var(--mono);font-size:13px;font-weight:500;color:#fff}
.row-meta{font-family:var(--mono);font-size:10px;color:var(--text-3);margin-top:2px}
.row-badge{font-family:var(--mono);font-size:10px;padding:3px 8px;border-radius:4px;font-weight:500}
.row-badge.admin{background:var(--red-dim);color:var(--red)}
.row-badge.operator{background:var(--amber-dim);color:var(--amber)}
.row-badge.viewer{background:var(--accent-dim);color:var(--accent)}
.row-actions{display:flex;gap:6px}
.row-stats{font-family:var(--mono);font-size:11px;color:var(--text-2);text-align:right}
/* 실시간 로그 */
.live-log{background:#0a0e17;border:1px solid rgba(255,255,255,0.04);border-radius:var(--radius-sm);height:420px;overflow-y:auto}
.live-log-inner{padding:12px;font-family:var(--mono);font-size:11px}
.log-entry{padding:6px 0;border-bottom:1px solid rgba(255,255,255,0.02);line-height:1.5}
.log-ts{color:var(--text-3)}.log-in{color:var(--green)}.log-out{color:var(--accent)}.log-action{color:var(--amber);font-weight:500}.log-charger{color:var(--purple)}
.log-controls{display:flex;gap:8px;align-items:center;padding:8px 12px;background:var(--bg-2);border-bottom:1px solid var(--border)}
.log-controls label{font-size:11px;color:var(--text-2);display:flex;align-items:center;gap:4px;cursor:pointer}
/* 모달 */
.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,0.6);display:flex;align-items:center;justify-content:center;z-index:200;backdrop-filter:blur(4px)}
.modal{background:var(--bg-2);border:1px solid var(--border-accent);border-radius:var(--radius);padding:28px;width:400px}
.modal h3{font-family:var(--display);font-size:18px;font-weight:600;color:#fff;margin-bottom:20px}
.modal-actions{display:flex;gap:10px;justify-content:flex-end;margin-top:20px}
.empty{text-align:center;padding:40px;color:var(--text-3);font-size:13px}
@media(max-width:768px){.stats-grid{grid-template-columns:1fr 1fr}.form-row{grid-template-columns:1fr}}
</style>
</head>
<body>
<!-- 로그인 -->
<div id="login-screen" class="login-wrap">
<div class="login-card">
<div class="login-brand"><h1>OCPP Proxy</h1><small>CONTROL PANEL</small></div>
<div class="form-group"><div class="form-label">아이디</div><input class="form-input" id="l-user" placeholder="admin" autocomplete="username"></div>
<div class="form-group"><div class="form-label">비밀번호</div><input class="form-input" id="l-pass" type="password" placeholder="비밀번호" autocomplete="current-password"></div>
<button class="login-btn" onclick="doLogin()">로그인</button>
<div class="login-error" id="l-err"></div>
</div>
</div>
<!---->
<div id="app" class="container hidden">
<div class="header">
<div><h1>OCPP Proxy Controller</h1><small>BIDIRECTIONAL LOGGING</small></div>
<div class="header-right">
<div class="status-row"><div class="status-dot on" id="s-dot"></div><span class="user-info" id="s-user"></span></div>
<button class="logout-btn" onclick="doLogout()">로그아웃</button>
</div>
</div>
<div class="tabs">
<div class="tab active" onclick="goTab('overview',this)">개요</div>
<div class="tab" onclick="goTab('config',this)">서버 설정</div>
<div class="tab" onclick="goTab('live',this)">실시간 로그</div>
<div class="tab" onclick="goTab('files',this)">로그 파일</div>
<div class="tab" onclick="goTab('connections',this)">연결 이력</div>
<div class="tab" onclick="goTab('users',this)" id="tab-users-btn">사용자 관리</div>
</div>
<div class="tab-content active" id="t-overview">
<div class="stats-grid" id="stats"></div>
<div class="card"><div class="card-header"><div class="card-title">활성 연결</div><button class="btn btn-sm" onclick="loadStatus()">새로고침</button></div><div class="card-body" id="active-conns"></div></div>
<div class="card"><div class="card-header"><div class="card-title">현재 설정</div></div><div class="card-body" id="ov-config" style="font-family:var(--mono);font-size:12px;color:var(--text-2)"></div></div>
</div>
<div class="tab-content" id="t-config">
<div class="card"><div class="card-header"><div class="card-title">타겟 OCPP 서버 (CPO)</div></div><div class="card-body">
<div class="form-row"><div class="form-group"><div class="form-label">타겟 서버 URL</div><input class="form-input" id="c-url"><div class="form-hint">충전기 메시지를 전달할 CPO 서버</div></div><div class="form-group"><div class="form-label">서버 이름</div><input class="form-input" id="c-name"></div></div>
<div class="form-row"><div class="form-group"><div class="form-label">OCPP 서브프로토콜</div><select class="form-input" id="c-sub"><option value="ocpp1.6">ocpp1.6</option><option value="ocpp2.0.1">ocpp2.0.1</option></select></div><div class="form-group"><div class="form-label">내 OCPP 서버</div><input class="form-input" id="c-my"></div></div>
</div></div>
<div class="card"><div class="card-header"><div class="card-title">로깅 설정</div></div><div class="card-body">
<div class="form-row"><div class="form-group"><div class="form-label">로깅 활성화</div><select class="form-input" id="c-log"><option value="true">활성화</option><option value="false">비활성화</option></select></div><div class="form-group"><div class="form-label">로그 형식</div><select class="form-input" id="c-fmt"><option value="both">TXT + JSONL</option><option value="txt">TXT만</option><option value="jsonl">JSONL만</option></select></div></div>
<div class="form-row"><div class="form-group"><div class="form-label">PCAP 기록</div><select class="form-input" id="c-pcap"><option value="false">비활성화</option><option value="true">활성화</option></select></div><div class="form-group"><div class="form-label">최대 로그 MB</div><input class="form-input" id="c-max" type="number" value="100"></div></div>
<div class="btn-row"><button class="btn btn-primary" onclick="saveConfig()">설정 저장</button></div>
<div id="c-msg" style="font-size:11px;color:var(--green);margin-top:8px;min-height:16px"></div>
</div></div>
<div class="card"><div class="card-header"><div class="card-title">충전기 설정 안내</div></div><div class="card-body" style="font-size:13px;color:var(--text-2);line-height:1.8">
충전기 OCPP 서버 주소를 변경하세요:<br><br>
<span style="color:var(--text-3)">기존:</span> <code style="color:var(--red);font-family:var(--mono);font-size:12px" id="guide-old">ws://...</code><br>
<span style="color:var(--text-3)">변경:</span> <code style="color:var(--green);font-family:var(--mono);font-size:12px">ws://ecap.byunc.com/ocppext/{충전기ID}</code>
</div></div>
</div>
<div class="tab-content" id="t-live">
<div class="card"><div class="card-header"><div class="card-title">실시간 OCPP 메시지</div><div style="display:flex;gap:8px"><button class="btn btn-sm" id="btn-live" onclick="toggleLive()">연결</button><button class="btn btn-sm" onclick="clearLive()">지우기</button></div></div>
<div class="log-controls"><label><input type="checkbox" id="f-call" checked> CALL</label><label><input type="checkbox" id="f-res" checked> RESULT</label><label><input type="checkbox" id="f-err" checked> ERROR</label><label><input type="checkbox" id="f-scroll" checked> 자동스크롤</label></div>
<div class="live-log" id="live-log"><div class="live-log-inner" id="live-inner"></div></div></div>
</div>
<div class="tab-content" id="t-files">
<div class="card"><div class="card-header"><div class="card-title">로그 파일</div><button class="btn btn-sm" onclick="loadFiles()">새로고침</button></div><div class="card-body" id="files-list"></div></div>
<div class="card hidden" id="viewer-card"><div class="card-header"><div class="card-title" id="viewer-title">파일</div><button class="btn btn-sm" onclick="closeViewer()">닫기</button></div><div class="card-body"><div class="live-log" style="height:500px"><div class="live-log-inner" id="viewer-inner"></div></div></div></div>
</div>
<div class="tab-content" id="t-connections">
<div class="card"><div class="card-header"><div class="card-title">연결 이력</div><button class="btn btn-sm" onclick="loadConns()">새로고침</button></div><div class="card-body" id="conns-list"></div></div>
</div>
<div class="tab-content" id="t-users">
<div class="card"><div class="card-header"><div class="card-title">사용자 관리</div><button class="btn btn-primary btn-sm" onclick="showAddUser()">+ 사용자 추가</button></div><div class="card-body" id="users-list"></div></div>
</div>
</div>
<div id="modal-root"></div>
<script>
let token=null,curUser=null,liveWs=null,liveOn=false;
// ── 인증 ──
function save(t,u){token=t;curUser=u;try{sessionStorage.setItem('pt',t);sessionStorage.setItem('pu',JSON.stringify(u))}catch(e){}}
function load(){try{token=sessionStorage.getItem('pt');const u=sessionStorage.getItem('pu');if(u)curUser=JSON.parse(u)}catch(e){}}
function clear(){token=null;curUser=null;try{sessionStorage.removeItem('pt');sessionStorage.removeItem('pu')}catch(e){}}
async function doLogin(){
const u=document.getElementById('l-user').value.trim(),p=document.getElementById('l-pass').value,err=document.getElementById('l-err');
if(!u||!p){err.textContent='아이디와 비밀번호를 입력하세요';return}
err.textContent='';
try{const r=await fetch('/api/auth/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:u,password:p})});
const d=await r.json();if(!r.ok){err.textContent=d.error||'로그인 실패';return}
save(d.token,d.user);enterApp()}catch(e){err.textContent='서버 연결 실패'}
}
function doLogout(){clear();if(liveWs)liveWs.close();document.getElementById('app').classList.add('hidden');document.getElementById('login-screen').classList.remove('hidden');document.getElementById('l-pass').value=''}
function enterApp(){
document.getElementById('login-screen').classList.add('hidden');document.getElementById('app').classList.remove('hidden');
document.getElementById('s-user').innerHTML=`<strong>${curUser.display_name||curUser.username}</strong><span class="role">${curUser.role}</span>`;
document.getElementById('tab-users-btn').style.display=curUser.role==='admin'?'':'none';
loadStatus()
}
document.addEventListener('keydown',e=>{if(e.key==='Enter'&&!document.getElementById('login-screen').classList.contains('hidden'))doLogin()});
// ── API ──
async function api(path,opt={}){
const headers={'Content-Type':'application/json',...(opt.headers||{})};
if(token)headers['Authorization']='Bearer '+token;
const r=await fetch(path,{...opt,headers});
if(r.status===401){doLogout();return null}
const d=await r.json().catch(()=>({}));
if(!r.ok)throw d;
return d
}
// ── 탭 ──
function goTab(name,el){
document.querySelectorAll('.tab').forEach(t=>t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(t=>t.classList.remove('active'));
el.classList.add('active');document.getElementById('t-'+name).classList.add('active');
if(name==='live'&&!liveOn)toggleLive();
if(name==='files')loadFiles();if(name==='connections')loadConns();if(name==='config')loadConfig();if(name==='users')loadUsers()
}
// ── 개요 ──
async function loadStatus(){
try{const d=await api('/api/status');if(!d)return;
document.getElementById('s-dot').className='status-dot on';
document.getElementById('stats').innerHTML=`
<div class="stat-card"><div class="stat-val">${d.active_count}</div><div class="stat-label">활성 연결</div></div>
<div class="stat-card"><div class="stat-val">${d.stats.total_connections}</div><div class="stat-label">총 연결</div></div>
<div class="stat-card"><div class="stat-val">${d.stats.total_messages.toLocaleString()}</div><div class="stat-label">총 메시지</div></div>
<div class="stat-card"><div class="stat-val">${fB(d.stats.total_bytes)}</div><div class="stat-label">총 데이터</div></div>`;
const conns=Object.values(d.active_connections);
document.getElementById('active-conns').innerHTML=conns.length===0?'<div class="empty">연결된 충전기 없음</div>':conns.map(c=>`<div class="row-item"><div class="row-dot active"></div><div class="row-info"><div class="row-title">${c.charger_id}</div><div class="row-meta">→ ${c.target_url}</div><div class="row-meta">연결 ${fT(c.connected_at)} | ${c.last_action||'-'}</div></div><div class="row-stats">↑${c.messages_in}${c.messages_out}<br>${fB(c.bytes_in+c.bytes_out)}</div></div>`).join('');
document.getElementById('ov-config').innerHTML=`타겟: ${d.target_url} (${d.target_name})<br>로깅: ${d.log_enabled?'ON':'OFF'} | ${d.log_format} | PCAP: ${d.pcap_enabled?'ON':'OFF'}`
}catch(e){document.getElementById('s-dot').className='status-dot off'}
}
// ── 설정 ──
async function loadConfig(){
const d=await api('/api/config');if(!d)return;
document.getElementById('c-url').value=d.target_url||'';document.getElementById('c-name').value=d.target_name||'';
document.getElementById('c-sub').value=d.ocpp_subprotocol||'ocpp1.6';document.getElementById('c-my').value=d.my_ocpp_server||'';
document.getElementById('c-log').value=String(d.log_enabled);document.getElementById('c-fmt').value=d.log_format||'both';
document.getElementById('c-pcap').value=String(d.pcap_enabled);document.getElementById('c-max').value=d.max_log_size_mb||100;
document.getElementById('guide-old').textContent=`${d.target_url}/{충전기ID}`
}
async function saveConfig(){
await api('/api/config',{method:'POST',body:JSON.stringify({
target_url:document.getElementById('c-url').value,target_name:document.getElementById('c-name').value,
ocpp_subprotocol:document.getElementById('c-sub').value,my_ocpp_server:document.getElementById('c-my').value,
log_enabled:document.getElementById('c-log').value==='true',log_format:document.getElementById('c-fmt').value,
pcap_enabled:document.getElementById('c-pcap').value==='true',max_log_size_mb:parseInt(document.getElementById('c-max').value)})});
document.getElementById('c-msg').textContent='설정 저장 완료';setTimeout(()=>document.getElementById('c-msg').textContent='',3000);loadStatus()
}
// ── 실시간 로그 ──
function toggleLive(){
if(liveOn){if(liveWs)liveWs.close();liveOn=false;document.getElementById('btn-live').textContent='연결';return}
const proto=location.protocol==='https:'?'wss':'ws';
liveWs=new WebSocket(`${proto}://${location.host}/ws/live?token=${token}`);
liveWs.onopen=()=>{liveOn=true;document.getElementById('btn-live').textContent='중지';addLog({type:'system',message:'실시간 로그 연결'})};
liveWs.onmessage=e=>{const d=JSON.parse(e.data);if(!fChk(d.type))return;addLog(d)};
liveWs.onclose=()=>{liveOn=false;document.getElementById('btn-live').textContent='연결';addLog({type:'system',message:'연결 종료'})}
}
function addLog(d){const el=document.getElementById('live-inner');const div=document.createElement('div');div.className='log-entry';
if(d.type==='system'){div.innerHTML=`<span class="log-ts">${fNow()}</span> <span style="color:var(--amber)">[시스템]</span> ${d.message}`}
else{const dir=d.direction==='charger_to_server'?'<span class="log-in">▶ CP→CS</span>':'<span class="log-out">◀ CS→CP</span>';
div.innerHTML=`<span class="log-ts">${fT(d.timestamp)}</span> ${dir} <span class="log-charger">${d.charger_id}</span> [${d.type}] <span class="log-action">${d.action||''}</span><br><span style="color:var(--text-3);font-size:10px;padding-left:20px">${(d.payload_preview||'').substring(0,200)}</span>`}
el.appendChild(div);if(el.children.length>500)el.removeChild(el.firstChild);
if(document.getElementById('f-scroll').checked)document.getElementById('live-log').scrollTop=document.getElementById('live-log').scrollHeight}
function fChk(t){if(t==='CALL'&&!document.getElementById('f-call').checked)return false;if(t==='CALLRESULT'&&!document.getElementById('f-res').checked)return false;if(t==='CALLERROR'&&!document.getElementById('f-err').checked)return false;return true}
function clearLive(){document.getElementById('live-inner').innerHTML=''}
// ── 파일 ──
async function loadFiles(){const d=await api('/api/logs');if(!d)return;const el=document.getElementById('files-list');
if(!d.files||!d.files.length){el.innerHTML='<div class="empty">로그 파일 없음</div>';return}
el.innerHTML=d.files.map(f=>{const ext=f.name.split('.').pop();const ic=ext==='pcap'?'📦':ext==='jsonl'?'📊':'📝';
return`<div class="row-item"><div style="font-size:16px;opacity:0.5">${ic}</div><div class="row-info"><div class="row-title">${f.name}</div><div class="row-meta">${fB(f.size)} · ${fT(f.modified)}</div></div><div class="row-actions">${ext!=='pcap'?`<button class="btn btn-sm" onclick="viewFile('${f.name}')">보기</button>`:''}<button class="btn btn-sm" onclick="window.open('/api/logs/${f.name}/download')">다운로드</button><button class="btn btn-sm btn-danger" onclick="delFile('${f.name}')">삭제</button></div></div>`}).join('')}
async function viewFile(n){const d=await api('/api/logs/'+n);if(!d)return;document.getElementById('viewer-card').classList.remove('hidden');document.getElementById('viewer-title').textContent=n;
const v=document.getElementById('viewer-inner');
if(d.entries){v.innerHTML=d.entries.map(e=>`<div class="log-entry"><span class="log-ts">${fT(e.timestamp)}</span> ${e.direction==='charger_to_server'?'<span class="log-in">▶</span>':'<span class="log-out">◀</span>'} <span class="log-charger">${e.charger_id}</span> [${e.message_type}] <span class="log-action">${e.action||''}</span><br><span style="color:var(--text-3);font-size:10px;padding-left:16px">${JSON.stringify(e.payload||{}).substring(0,300)}</span></div>`).join('')}
else if(d.content){v.innerHTML=`<pre style="white-space:pre-wrap;color:var(--text-2)">${esc(d.content)}</pre>`}
document.getElementById('viewer-card').scrollIntoView({behavior:'smooth'})}
async function delFile(n){if(!confirm(n+' 삭제?'))return;await api('/api/logs/'+n,{method:'DELETE'});loadFiles()}
function closeViewer(){document.getElementById('viewer-card').classList.add('hidden')}
// ── 연결 이력 ──
async function loadConns(){const d=await api('/api/connections');if(!d)return;const el=document.getElementById('conns-list');
const all=[...Object.values(d.active).map(c=>({...c,cur:true})),...(d.history||[]).reverse()];
if(!all.length){el.innerHTML='<div class="empty">이력 없음</div>';return}
el.innerHTML=all.map(c=>`<div class="row-item"><div class="row-dot ${c.cur?'active':c.status==='error'?'err':'off'}"></div><div class="row-info"><div class="row-title">${c.charger_id} ${c.cur?'<span style="color:var(--green);font-size:10px">현재</span>':''}</div><div class="row-meta">→ ${c.target_url}</div><div class="row-meta">${fT(c.connected_at)} ~ ${fT(c.last_activity)}</div></div><div class="row-stats">↑${c.messages_in}${c.messages_out}<br>${fB((c.bytes_in||0)+(c.bytes_out||0))}</div></div>`).join('')}
// ── 사용자 관리 ──
async function loadUsers(){if(curUser?.role!=='admin')return;const d=await api('/api/users');if(!d)return;
document.getElementById('users-list').innerHTML=d.users.length===0?'<div class="empty">사용자 없음</div>':d.users.map(u=>`<div class="row-item"><div style="width:36px;height:36px;border-radius:50%;background:var(--accent-dim);display:flex;align-items:center;justify-content:center;font-family:var(--display);font-size:14px;font-weight:600;color:var(--accent);flex-shrink:0">${(u.display_name||u.username).charAt(0).toUpperCase()}</div><div class="row-info"><div class="row-title">${u.display_name||u.username}</div><div class="row-meta">@${u.username} · ${u.last_login?fT(u.last_login)+' 마지막':'로그인 기록 없음'}</div></div><span class="row-badge ${u.role}">${u.role}</span><div class="row-actions"><button class="btn btn-sm" onclick='showEditUser(${JSON.stringify(u)})'>수정</button>${u.username!==curUser.username?`<button class="btn btn-sm btn-danger" onclick="delUser('${u.username}')">삭제</button>`:''}</div></div>`).join('')}
function showAddUser(){document.getElementById('modal-root').innerHTML=`<div class="modal-overlay" onclick="if(event.target===this)closeModal()"><div class="modal"><h3>사용자 추가</h3>
<div class="form-group"><div class="form-label">아이디</div><input class="form-input" id="mu-user"></div>
<div class="form-group"><div class="form-label">비밀번호</div><input class="form-input" id="mu-pass" type="password"></div>
<div class="form-group"><div class="form-label">표시 이름</div><input class="form-input" id="mu-name"></div>
<div class="form-group"><div class="form-label">역할</div><select class="form-input" id="mu-role"><option value="viewer">viewer</option><option value="operator">operator</option><option value="admin">admin</option></select></div>
<div id="mu-err" style="color:var(--red);font-size:11px;min-height:16px"></div>
<div class="modal-actions"><button class="btn" onclick="closeModal()">취소</button><button class="btn btn-primary" onclick="doAddUser()">생성</button></div></div></div>`}
async function doAddUser(){const u=document.getElementById('mu-user').value.trim(),p=document.getElementById('mu-pass').value;
if(!u||!p){document.getElementById('mu-err').textContent='필수 입력';return}
try{await api('/api/users',{method:'POST',body:JSON.stringify({username:u,password:p,display_name:document.getElementById('mu-name').value||u,role:document.getElementById('mu-role').value})});closeModal();loadUsers()}catch(e){document.getElementById('mu-err').textContent=e.error||'실패'}}
function showEditUser(u){document.getElementById('modal-root').innerHTML=`<div class="modal-overlay" onclick="if(event.target===this)closeModal()"><div class="modal"><h3>${u.username} 수정</h3>
<div class="form-group"><div class="form-label">표시 이름</div><input class="form-input" id="eu-name" value="${u.display_name||''}"></div>
<div class="form-group"><div class="form-label">역할</div><select class="form-input" id="eu-role"><option value="viewer" ${u.role==='viewer'?'selected':''}>viewer</option><option value="operator" ${u.role==='operator'?'selected':''}>operator</option><option value="admin" ${u.role==='admin'?'selected':''}>admin</option></select></div>
<div class="form-group"><div class="form-label">새 비밀번호 (변경 시)</div><input class="form-input" id="eu-pass" type="password" placeholder="비워두면 유지"></div>
<div class="form-group"><label style="display:flex;align-items:center;gap:8px;font-size:12px;color:var(--text-2);cursor:pointer"><input type="checkbox" id="eu-active" ${u.is_active?'checked':''}> 계정 활성화</label></div>
<div id="eu-err" style="color:var(--red);font-size:11px;min-height:16px"></div>
<div class="modal-actions"><button class="btn" onclick="closeModal()">취소</button><button class="btn btn-primary" onclick="doEditUser('${u.username}')">저장</button></div></div></div>`}
async function doEditUser(username){const body={display_name:document.getElementById('eu-name').value,role:document.getElementById('eu-role').value,is_active:document.getElementById('eu-active').checked};
const p=document.getElementById('eu-pass').value;if(p)body.password=p;
try{await api('/api/users/'+username,{method:'PUT',body:JSON.stringify(body)});closeModal();loadUsers()}catch(e){document.getElementById('eu-err').textContent=e.error||'실패'}}
async function delUser(n){if(!confirm(n+' 삭제?'))return;try{await api('/api/users/'+n,{method:'DELETE'});loadUsers()}catch(e){alert(e.error||'실패')}}
function closeModal(){document.getElementById('modal-root').innerHTML=''}
// ── 유틸 ──
function fB(b){if(!b)return'0 B';if(b<1024)return b+' B';if(b<1048576)return(b/1024).toFixed(1)+' KB';return(b/1048576).toFixed(1)+' MB'}
function fT(ts){if(!ts)return'-';const d=new Date(ts);return`${d.getMonth()+1}/${String(d.getDate()).padStart(2,'0')} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}:${String(d.getSeconds()).padStart(2,'0')}`}
function fNow(){const d=new Date();return`${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}:${String(d.getSeconds()).padStart(2,'0')}`}
function esc(s){return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')}
// ── 초기화 ──
load();if(token&&curUser)enterApp();
setInterval(()=>{if(token)loadStatus()},15000);
</script>
</body>
</html>

16
requirements.txt Normal file
View File

@@ -0,0 +1,16 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
sqlalchemy[asyncio]==2.0.36
asyncpg==0.30.0
alembic==1.14.0
psycopg2-binary==2.9.10
redis[hiredis]==5.2.1
httpx==0.28.1
pydantic==2.10.3
pydantic-settings==2.7.0
python-dotenv==1.0.1
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
bcrypt==4.0.1
apscheduler==3.10.4
qrcode[pil]==8.0

625
simulator.html Normal file
View File

@@ -0,0 +1,625 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>EV 충전 시뮬레이터</title>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&family=JetBrains+Mono:wght@400;500;600&family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<style>
:root{
--bg-0:#06090f;--bg-1:#0c1018;--bg-2:#121824;--bg-3:#1a2236;
--bg-card:rgba(18,24,36,0.85);
--accent:#00d4ff;--accent-dim:rgba(0,212,255,0.12);
--green:#10b981;--green-dim:rgba(16,185,129,0.12);
--amber:#f59e0b;--amber-dim:rgba(245,158,11,0.12);
--red:#ef4444;--red-dim:rgba(239,68,68,0.12);
--purple:#8b5cf6;--purple-dim:rgba(139,92,246,0.12);
--text:#e2e8f0;--text-2:#94a3b8;--text-3:#64748b;
--border:rgba(255,255,255,0.06);--border-accent:rgba(0,212,255,0.15);
--radius:12px;--radius-sm:8px;
--font-display:'Outfit',sans-serif;--font-body:'Noto Sans KR',sans-serif;--font-mono:'JetBrains Mono',monospace;
}
*{margin:0;padding:0;box-sizing:border-box}
body{background:var(--bg-0);color:var(--text);font-family:var(--font-body);min-height:100vh}
body::before{content:'';position:fixed;inset:0;background:radial-gradient(ellipse 60% 40% at 15% 5%,rgba(0,212,255,0.04) 0%,transparent 60%),radial-gradient(ellipse 50% 30% at 85% 90%,rgba(139,92,246,0.03) 0%,transparent 60%);pointer-events:none}
.container{max-width:1100px;margin:0 auto;padding:32px 24px;position:relative;z-index:1}
/* 헤더 */
.header{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:32px;padding-bottom:24px;border-bottom:1px solid var(--border)}
.header h1{font-family:var(--font-display);font-size:28px;font-weight:700;color:#fff;letter-spacing:-0.02em}
.header small{font-family:var(--font-mono);font-size:10px;color:var(--accent);letter-spacing:0.2em;display:block;margin-top:4px}
.header-right{text-align:right}
.header-link{font-family:var(--font-mono);font-size:11px;color:var(--text-3);text-decoration:none;transition:color 0.15s}
.header-link:hover{color:var(--accent)}
/* 레이아웃 */
.layout{display:grid;grid-template-columns:340px 1fr;gap:24px}
@media(max-width:800px){.layout{grid-template-columns:1fr}}
/* 파라미터 패널 */
.params-panel{background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);backdrop-filter:blur(12px);position:sticky;top:24px;height:fit-content}
.params-header{padding:16px 20px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center}
.params-title{font-family:var(--font-display);font-size:15px;font-weight:600;color:#fff}
.params-body{padding:20px}
.param-section{margin-bottom:20px}
.param-section-title{font-family:var(--font-mono);font-size:9px;letter-spacing:0.2em;color:var(--accent);text-transform:uppercase;margin-bottom:10px;padding-bottom:6px;border-bottom:1px solid var(--border)}
.param-row{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:10px}
.param-row.full{grid-template-columns:1fr}
.param-group{display:flex;flex-direction:column;gap:4px}
.param-label{font-size:11px;color:var(--text-3);font-weight:500}
.param-input{padding:8px 10px;background:var(--bg-3);border:1px solid var(--border);border-radius:6px;color:var(--text);font-family:var(--font-mono);font-size:12px;outline:none;transition:border-color 0.15s;width:100%}
.param-input:focus{border-color:var(--accent)}
.param-input::placeholder{color:var(--text-3)}
select.param-input{cursor:pointer}
.param-hint{font-size:10px;color:var(--text-3);font-family:var(--font-mono);margin-top:2px}
/* 충전 시뮬레이션 슬라이더 */
.charge-slider-wrap{margin:12px 0}
.charge-slider{width:100%;-webkit-appearance:none;height:6px;border-radius:3px;background:var(--bg-3);outline:none}
.charge-slider::-webkit-slider-thumb{-webkit-appearance:none;width:18px;height:18px;border-radius:50%;background:var(--accent);cursor:pointer;box-shadow:0 0 8px rgba(0,212,255,0.3)}
.charge-preview{display:flex;justify-content:space-between;margin-top:6px;font-family:var(--font-mono);font-size:11px;color:var(--text-2)}
.charge-preview .val{color:var(--accent);font-weight:500}
/* 버튼 */
.btn-run{width:100%;padding:14px;background:linear-gradient(135deg,rgba(0,212,255,0.15),rgba(16,185,129,0.1));border:1px solid var(--accent);border-radius:var(--radius-sm);color:var(--accent);font-family:var(--font-display);font-size:15px;font-weight:600;cursor:pointer;transition:all 0.2s;margin-top:16px;letter-spacing:0.02em}
.btn-run:hover{background:rgba(0,212,255,0.2);box-shadow:0 0 24px rgba(0,212,255,0.1)}
.btn-run:disabled{opacity:0.4;cursor:not-allowed}
.btn-run.running{animation:runPulse 1.5s ease-in-out infinite}
@keyframes runPulse{0%,100%{box-shadow:0 0 0 0 rgba(0,212,255,0.2)}50%{box-shadow:0 0 0 8px rgba(0,212,255,0)}}
.btn-step{width:100%;padding:10px;background:var(--bg-3);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text-2);font-family:var(--font-mono);font-size:11px;cursor:pointer;transition:all 0.15s;margin-top:8px}
.btn-step:hover{border-color:var(--amber);color:var(--amber)}
.btn-row{display:flex;gap:8px;margin-top:8px}
.btn-row .btn-step{flex:1}
/* 결과 패널 */
.results-panel{display:flex;flex-direction:column;gap:12px}
.step-card{background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);backdrop-filter:blur(12px);overflow:hidden;transition:border-color 0.3s}
.step-card.success{border-color:rgba(16,185,129,0.3)}
.step-card.error{border-color:rgba(239,68,68,0.3)}
.step-card.running{border-color:rgba(0,212,255,0.3)}
.step-card.waiting{opacity:0.5}
.step-header{display:flex;align-items:center;gap:12px;padding:14px 18px;cursor:pointer;user-select:none}
.step-num{width:28px;height:28px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-family:var(--font-mono);font-size:11px;font-weight:600;flex-shrink:0;border:1.5px solid var(--border);color:var(--text-3);background:var(--bg-3);transition:all 0.3s}
.step-card.success .step-num{border-color:var(--green);color:var(--green);background:var(--green-dim)}
.step-card.error .step-num{border-color:var(--red);color:var(--red);background:var(--red-dim)}
.step-card.running .step-num{border-color:var(--accent);color:var(--accent);background:var(--accent-dim);animation:runPulse 1.5s infinite}
.step-title{flex:1;font-size:13px;font-weight:500;color:#fff}
.step-subtitle{font-family:var(--font-mono);font-size:10px;color:var(--text-3);margin-top:2px}
.step-status{font-family:var(--font-mono);font-size:10px;padding:3px 8px;border-radius:4px}
.step-status.ok{background:var(--green-dim);color:var(--green)}
.step-status.fail{background:var(--red-dim);color:var(--red)}
.step-status.run{background:var(--accent-dim);color:var(--accent)}
.step-status.wait{background:rgba(100,116,139,0.1);color:var(--text-3)}
.step-time{font-family:var(--font-mono);font-size:10px;color:var(--text-3);margin-left:8px}
.step-body{padding:0 18px 14px;display:none}
.step-card.open .step-body{display:block}
.step-json{background:#0a0e17;border:1px solid rgba(255,255,255,0.04);border-radius:6px;padding:12px 14px;font-family:var(--font-mono);font-size:11px;color:#8ec8e8;line-height:1.6;overflow-x:auto;max-height:300px;overflow-y:auto;white-space:pre-wrap;word-break:break-all}
.step-json .k{color:#f472b6} .step-json .s{color:#a5f3c4} .step-json .n{color:#c4b5fd} .step-json .b{color:#fdba74}
/* 요약 카드 */
.summary-card{background:linear-gradient(135deg,rgba(16,185,129,0.08),rgba(0,212,255,0.05));border:1px solid rgba(16,185,129,0.2);border-radius:var(--radius);padding:24px;text-align:center}
.summary-card h3{font-family:var(--font-display);font-size:18px;color:#fff;margin-bottom:16px}
.summary-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}
.summary-item{text-align:center}
.summary-val{font-family:var(--font-display);font-size:24px;font-weight:700;color:var(--green);line-height:1}
.summary-label{font-size:11px;color:var(--text-3);margin-top:4px}
.summary-saved{margin-top:16px;font-family:var(--font-mono);font-size:13px;color:var(--amber);padding:10px;background:var(--amber-dim);border-radius:6px}
/* 프리셋 */
.preset-row{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:16px}
.preset-btn{padding:5px 10px;background:var(--bg-3);border:1px solid var(--border);border-radius:4px;color:var(--text-2);font-family:var(--font-mono);font-size:10px;cursor:pointer;transition:all 0.15s}
.preset-btn:hover{border-color:var(--accent);color:var(--accent)}
.preset-btn.active{background:var(--accent-dim);border-color:var(--accent);color:var(--accent)}
@keyframes fadeIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}
.fade-in{animation:fadeIn 0.3s ease-out forwards}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div>
<h1>EV Charging Simulator</h1>
<small>OCPP TEST CONSOLE</small>
</div>
<div class="header-right">
<a class="header-link" href="/dashboard">← 대시보드로 돌아가기</a>
</div>
</div>
<div class="layout">
<!-- 좌측: 파라미터 -->
<div class="params-panel">
<div class="params-header">
<div class="params-title">테스트 파라미터</div>
</div>
<div class="params-body">
<!-- 프리셋 -->
<div class="param-section">
<div class="param-section-title">프리셋</div>
<div class="preset-row">
<button class="preset-btn active" onclick="applyPreset('basic')">기본 (7kW)</button>
<button class="preset-btn" onclick="applyPreset('fast')">급속 (50kW)</button>
<button class="preset-btn" onclick="applyPreset('slow')">완속 (3kW)</button>
<button class="preset-btn" onclick="applyPreset('short')">단시간</button>
<button class="preset-btn" onclick="applyPreset('full')">완충</button>
<button class="preset-btn" onclick="applyPreset('error')">에러 테스트</button>
</div>
</div>
<!-- 충전기 설정 -->
<div class="param-section">
<div class="param-section-title">충전기</div>
<div class="param-row">
<div class="param-group">
<div class="param-label">충전기 ID</div>
<input class="param-input" id="p-charger" value="CHARGER_001">
</div>
<div class="param-group">
<div class="param-label">커넥터 번호</div>
<input class="param-input" id="p-connector" type="number" value="1" min="1" max="4">
</div>
</div>
<div class="param-row">
<div class="param-group">
<div class="param-label">충전기 이름</div>
<input class="param-input" id="p-name" value="A동 주차장 1번">
</div>
<div class="param-group">
<div class="param-label">출력 (kW)</div>
<input class="param-input" id="p-power" type="number" value="7" min="1" max="350" step="0.1">
</div>
</div>
<div class="param-row full">
<div class="param-group">
<div class="param-label">설치 위치</div>
<input class="param-input" id="p-location" value="수원시 영통구 테스트 아파트 지하1층">
</div>
</div>
</div>
<!-- 충전 설정 -->
<div class="param-section">
<div class="param-section-title">충전 시뮬레이션</div>
<div class="param-row">
<div class="param-group">
<div class="param-label">미터 시작값 (Wh)</div>
<input class="param-input" id="p-meter-start" type="number" value="100000" min="0" step="1000">
</div>
<div class="param-group">
<div class="param-label">Transaction ID</div>
<input class="param-input" id="p-txn-id" type="number" value="" placeholder="자동 생성">
<div class="param-hint">비워두면 타임스탬프 기반 자동 생성</div>
</div>
</div>
<div class="param-group" style="margin-bottom:10px">
<div class="param-label">목표 충전량 (kWh)</div>
<div class="charge-slider-wrap">
<input class="charge-slider" id="p-target-kwh" type="range" min="1" max="100" value="30" oninput="updateChargePreview()">
<div class="charge-preview">
<span>1 kWh</span>
<span class="val" id="charge-val">30 kWh</span>
<span>100 kWh</span>
</div>
</div>
</div>
<div class="param-row">
<div class="param-group">
<div class="param-label">MeterValues 횟수</div>
<input class="param-input" id="p-meter-steps" type="number" value="4" min="1" max="20">
<div class="param-hint">충전 중 보고 횟수</div>
</div>
<div class="param-group">
<div class="param-label">스텝 딜레이 (ms)</div>
<input class="param-input" id="p-delay" type="number" value="500" min="0" max="5000" step="100">
<div class="param-hint">각 단계 사이 대기</div>
</div>
</div>
</div>
<!-- 결제/종료 -->
<div class="param-section">
<div class="param-section-title">결제 / 종료</div>
<div class="param-row">
<div class="param-group">
<div class="param-label">선결제 금액 (원)</div>
<input class="param-input" id="p-amount" type="number" value="10000" min="100" step="1000">
</div>
<div class="param-group">
<div class="param-label">종료 사유</div>
<select class="param-input" id="p-stop-reason">
<option value="Local">Local (사용자 종료)</option>
<option value="Remote">Remote (서버 종료)</option>
<option value="EVDisconnected">EVDisconnected</option>
<option value="PowerLoss">PowerLoss (정전)</option>
<option value="EmergencyStop">EmergencyStop</option>
<option value="Other">Other</option>
</select>
</div>
</div>
</div>
<!-- 에러 시뮬레이션 -->
<div class="param-section">
<div class="param-section-title">에러 시뮬레이션</div>
<div class="param-row full">
<div class="param-group">
<div class="param-label">충전기 에러 코드</div>
<select class="param-input" id="p-error-code">
<option value="NoError">NoError (정상)</option>
<option value="ConnectorLockFailure">ConnectorLockFailure</option>
<option value="GroundFailure">GroundFailure</option>
<option value="HighTemperature">HighTemperature</option>
<option value="OverCurrentFailure">OverCurrentFailure</option>
<option value="OverVoltage">OverVoltage</option>
<option value="UnderVoltage">UnderVoltage</option>
<option value="PowerMeterFailure">PowerMeterFailure</option>
<option value="PowerSwitchFailure">PowerSwitchFailure</option>
<option value="InternalError">InternalError</option>
<option value="OtherError">OtherError</option>
</select>
</div>
</div>
</div>
<!-- 실행 버튼 -->
<button class="btn-run" id="btn-run" onclick="runFullTest()">전체 흐름 실행</button>
<div class="btn-row">
<button class="btn-step" onclick="runStepByStep()">단계별 실행</button>
<button class="btn-step" onclick="resetAll()">초기화</button>
</div>
</div>
</div>
<!-- 우측: 실행 결과 -->
<div class="results-panel" id="results"></div>
</div>
</div>
<script>
const API='/api/v1';
const STEPS=[
{id:'health',num:'0',title:'헬스체크',sub:'서버 연결 확인'},
{id:'register',num:'1',title:'충전기 등록',sub:'chargeBoxId 등록'},
{id:'reset',num:'1-1',title:'세션 정리',sub:'미완료 세션 취소'},
{id:'status',num:'2',title:'충전기 상태',sub:'Available 설정'},
{id:'session',num:'3',title:'세션 생성',sub:'QR 스캔 시뮬레이션'},
{id:'payment',num:'4',title:'결제 준비',sub:'orderId 발급'},
{id:'authorize',num:'5',title:'결제 우회',sub:'AUTHORIZED 강제 설정'},
{id:'start',num:'6',title:'StartTransaction',sub:'충전 시작'},
{id:'meter',num:'7',title:'MeterValues',sub:'실시간 전력량 보고'},
{id:'poll',num:'8',title:'세션 조회',sub:'충전 중 상태 확인'},
{id:'stop',num:'9',title:'StopTransaction',sub:'충전 종료 + 정산'},
{id:'billing',num:'10',title:'최종 정산',sub:'요금 내역'},
{id:'dashboard',num:'11',title:'대시보드',sub:'전체 요약'},
];
let running=false, stepMode=false, stepResolve=null;
let sessionUid='', idTag='', txnId=0;
function getParams(){
const targetKwh=parseFloat(document.getElementById('p-target-kwh').value);
const meterStart=parseInt(document.getElementById('p-meter-start').value);
const meterSteps=parseInt(document.getElementById('p-meter-steps').value);
const stepWh=Math.round(targetKwh*1000/meterSteps);
const txnInput=document.getElementById('p-txn-id').value;
return {
charger:document.getElementById('p-charger').value,
connector:parseInt(document.getElementById('p-connector').value),
name:document.getElementById('p-name').value,
location:document.getElementById('p-location').value,
power:parseFloat(document.getElementById('p-power').value),
meterStart,
targetKwh,
meterSteps,
stepWh,
meterStop:meterStart+targetKwh*1000,
txnId:txnInput?parseInt(txnInput):Math.floor(Date.now()/1000)%100000,
amount:parseInt(document.getElementById('p-amount').value),
stopReason:document.getElementById('p-stop-reason').value,
errorCode:document.getElementById('p-error-code').value,
delay:parseInt(document.getElementById('p-delay').value),
}
}
function updateChargePreview(){
const v=document.getElementById('p-target-kwh').value;
document.getElementById('charge-val').textContent=v+' kWh';
}
// ── 프리셋 ──
const PRESETS={
basic:{power:7,targetKwh:30,meterSteps:4,amount:10000,delay:500,errorCode:'NoError',stopReason:'Local'},
fast:{power:50,targetKwh:60,meterSteps:6,amount:30000,delay:300,errorCode:'NoError',stopReason:'Local'},
slow:{power:3,targetKwh:10,meterSteps:3,amount:5000,delay:800,errorCode:'NoError',stopReason:'Local'},
short:{power:7,targetKwh:5,meterSteps:2,amount:2000,delay:300,errorCode:'NoError',stopReason:'Local'},
full:{power:11,targetKwh:80,meterSteps:8,amount:50000,delay:400,errorCode:'NoError',stopReason:'Local'},
error:{power:7,targetKwh:15,meterSteps:3,amount:10000,delay:500,errorCode:'OverCurrentFailure',stopReason:'EmergencyStop'},
};
function applyPreset(name){
const p=PRESETS[name];
document.getElementById('p-power').value=p.power;
document.getElementById('p-target-kwh').value=p.targetKwh;
document.getElementById('p-meter-steps').value=p.meterSteps;
document.getElementById('p-amount').value=p.amount;
document.getElementById('p-delay').value=p.delay;
document.getElementById('p-error-code').value=p.errorCode;
document.getElementById('p-stop-reason').value=p.stopReason;
updateChargePreview();
document.querySelectorAll('.preset-btn').forEach(b=>b.classList.remove('active'));
event.target.classList.add('active');
}
// ── API 호출 ──
async function api(path,opt={}){
const r=await fetch(API+path,{headers:{'Content-Type':'application/json'},...opt});
const data=await r.json().catch(()=>({error:'응답 파싱 실패',status:r.status}));
return {ok:r.ok,status:r.status,data};
}
// ── UI ──
function renderSteps(){
document.getElementById('results').innerHTML=STEPS.map(s=>`
<div class="step-card waiting" id="step-${s.id}" onclick="toggleStep('${s.id}')">
<div class="step-header">
<div class="step-num">${s.num}</div>
<div style="flex:1"><div class="step-title">${s.title}</div><div class="step-subtitle">${s.sub}</div></div>
<span class="step-status wait" id="status-${s.id}">대기</span>
<span class="step-time" id="time-${s.id}"></span>
</div>
<div class="step-body"><div class="step-json" id="json-${s.id}"></div></div>
</div>
`).join('');
}
function setStepState(id,state,data,ms){
const card=document.getElementById('step-'+id);
const status=document.getElementById('status-'+id);
const time=document.getElementById('time-'+id);
const json=document.getElementById('json-'+id);
card.className='step-card '+state+(state!=='waiting'?' open':'');
const labels={success:'성공',error:'실패',running:'실행중',waiting:'대기'};
const classes={success:'ok',error:'fail',running:'run',waiting:'wait'};
status.className='step-status '+classes[state];
status.textContent=labels[state];
if(ms!==undefined)time.textContent=ms+'ms';
if(data!==undefined)json.innerHTML=syntaxHL(JSON.stringify(data,null,2));
card.scrollIntoView({behavior:'smooth',block:'nearest'});
}
function syntaxHL(s){
return s.replace(/(".*?")\s*:/g,'<span class="k">$1</span>:')
.replace(/:\s*(".*?")/g,': <span class="s">$1</span>')
.replace(/:\s*(\d+\.?\d*)/g,': <span class="n">$1</span>')
.replace(/:\s*(true|false|null)/g,': <span class="b">$1</span>');
}
function toggleStep(id){
document.getElementById('step-'+id).classList.toggle('open');
}
function addSummary(billing){
const el=document.createElement('div');
el.className='summary-card fade-in';
el.innerHTML=`<h3>충전 완료</h3>
<div class="summary-grid">
<div class="summary-item"><div class="summary-val">${billing.charged_kwh}</div><div class="summary-label">kWh 충전</div></div>
<div class="summary-item"><div class="summary-val">${billing.total_bill.toLocaleString()}</div><div class="summary-label">원 요금</div></div>
<div class="summary-item"><div class="summary-val">${billing.saved_vs_cpo.toLocaleString()}</div><div class="summary-label">원 절감</div></div>
</div>
<div class="summary-saved">CPO 대비 ${billing.saved_vs_cpo.toLocaleString()}원 절감 (전기 ${billing.electricity_cost.toLocaleString()}원 + 서비스 ${billing.service_fee.toLocaleString()}원)</div>`;
document.getElementById('results').appendChild(el);
}
// ── 딜레이 + 스텝 ──
function wait(ms){return new Promise(r=>setTimeout(r,ms))}
function waitStep(){return stepMode?new Promise(r=>{stepResolve=r}):Promise.resolve()}
// ── 실행 ──
async function runFullTest(){
if(running)return;
stepMode=false;
await execute();
}
async function runStepByStep(){
if(running)return;
stepMode=true;
const btn=document.querySelector('.btn-step');
btn.textContent='다음 단계 ▶';
btn.onclick=()=>{if(stepResolve){stepResolve();stepResolve=null}};
await execute();
btn.textContent='단계별 실행';
btn.onclick=()=>runStepByStep();
}
async function execute(){
running=true;
const btn=document.getElementById('btn-run');
btn.disabled=true;btn.classList.add('running');btn.textContent='실행 중...';
const P=getParams();
renderSteps();
async function step(id,fn){
await waitStep();
setStepState(id,'running');
const t0=performance.now();
try{
const result=await fn();
const ms=Math.round(performance.now()-t0);
setStepState(id,'success',result,ms);
await wait(P.delay);
return result;
}catch(e){
const ms=Math.round(performance.now()-t0);
setStepState(id,'error',{error:e.message||e,detail:e.data||null},ms);
throw e;
}
}
try{
// 0. 헬스체크
await step('health',async()=>{
const r=await api('/../health');
if(!r.ok)throw{message:'서버 연결 실패',data:r.data};
return r.data;
});
// 1. 충전기 등록
await step('register',async()=>{
const r=await api('/chargers/',{method:'POST',body:JSON.stringify({
charge_box_id:P.charger,name:P.name,location:P.location,
connector_count:1,power_kw:P.power,
})});
if(r.status===409)return{message:'이미 등록됨',charge_box_id:P.charger};
if(!r.ok)throw{message:'등록 실패',data:r.data};
return r.data;
});
// 1-1. 세션 정리
await step('reset',async()=>{
const r=await api('/sessions/reset/'+P.charger,{method:'POST'});
return r.data;
});
// 2. 상태 업데이트
const statusVal=P.errorCode==='NoError'?'Available':'Faulted';
await step('status',async()=>{
const r=await api('/ocpp/status',{method:'POST',body:JSON.stringify({
charge_box_id:P.charger,connector_id:P.connector,
status:statusVal,error_code:P.errorCode,
})});
if(!r.ok)throw{message:'상태 업데이트 실패',data:r.data};
return r.data;
});
// 에러 시뮬레이션 시 여기서 중단
if(P.errorCode!=='NoError'){
addSummary({charged_kwh:0,total_bill:0,saved_vs_cpo:0,electricity_cost:0,service_fee:0});
const remaining=['session','payment','authorize','start','meter','poll','stop','billing','dashboard'];
remaining.forEach(id=>setStepState(id,'error',{message:'에러 상태에서 충전 불가',errorCode:P.errorCode}));
return;
}
// 3. 세션 생성
const session=await step('session',async()=>{
const r=await api('/sessions/',{method:'POST',body:JSON.stringify({
charge_box_id:P.charger,connector_id:P.connector,
})});
if(!r.ok)throw{message:'세션 생성 실패',data:r.data};
sessionUid=r.data.session_uid;
idTag=r.data.id_tag;
return r.data;
});
// 4. 결제 준비
await step('payment',async()=>{
const r=await api('/payments/prepare',{method:'POST',body:JSON.stringify({
session_uid:sessionUid,amount:P.amount,
})});
if(!r.ok)throw{message:'결제 준비 실패',data:r.data};
return r.data;
});
// 5. 결제 우회
await step('authorize',async()=>{
const r=await api('/sessions/'+sessionUid+'/force-authorize',{method:'POST'});
if(!r.ok)throw{message:'인증 실패',data:r.data};
return r.data;
});
// 6. StartTransaction
txnId=P.txnId;
await step('start',async()=>{
const r=await api('/ocpp/start-transaction',{method:'POST',body:JSON.stringify({
charge_box_id:P.charger,connector_id:P.connector,
id_tag:idTag,meter_start:P.meterStart,transaction_id:txnId,
})});
if(!r.ok)throw{message:'StartTransaction 실패',data:r.data};
return r.data;
});
// 7. MeterValues
await step('meter',async()=>{
const results=[];
for(let i=1;i<=P.meterSteps;i++){
const wh=P.meterStart+Math.round(P.stepWh*i);
const r=await api('/ocpp/meter-values',{method:'POST',body:JSON.stringify({
charge_box_id:P.charger,connector_id:P.connector,
transaction_id:txnId,value:wh,
})});
results.push({step:i,wh,kwh:((wh-P.meterStart)/1000).toFixed(1),status:r.data?.status});
await wait(Math.max(100,P.delay/2));
}
return {meter_reports:results,total_reports:P.meterSteps};
});
// 8. 세션 조회
await step('poll',async()=>{
const r=await api('/sessions/'+sessionUid);
return r.data;
});
// 9. StopTransaction
const stopResult=await step('stop',async()=>{
const r=await api('/ocpp/stop-transaction',{method:'POST',body:JSON.stringify({
charge_box_id:P.charger,transaction_id:txnId,
meter_stop:P.meterStop,reason:P.stopReason,
})});
if(!r.ok)throw{message:'StopTransaction 실패',data:r.data};
return r.data;
});
// 10. 정산
const billing=await step('billing',async()=>{
const r=await api('/sessions/'+sessionUid+'/billing');
if(!r.ok)throw{message:'정산 조회 실패',data:r.data};
return r.data;
});
// 11. 대시보드
await step('dashboard',async()=>{
const r=await api('/dashboard/summary');
return r.data;
});
addSummary(billing);
}catch(e){
console.error('테스트 중단:',e);
}finally{
running=false;
btn.disabled=false;btn.classList.remove('running');btn.textContent='전체 흐름 실행';
}
}
function resetAll(){
running=false;stepMode=false;stepResolve=null;
sessionUid='';idTag='';txnId=0;
document.getElementById('results').innerHTML=`
<div style="text-align:center;padding:60px 20px;color:var(--text-3)">
<div style="font-size:40px;margin-bottom:16px;opacity:0.2">⚡</div>
<div style="font-size:14px">파라미터를 설정하고 실행 버튼을 누르세요</div>
<div style="font-family:var(--font-mono);font-size:11px;margin-top:8px">전체 흐름 또는 단계별 실행 가능</div>
</div>`;
}
// 초기화
document.addEventListener('DOMContentLoaded',()=>{resetAll();updateChargePreview()});
</script>
</body>
</html>

162
test_flow.py Normal file
View File

@@ -0,0 +1,162 @@
"""전체 충전 흐름 테스트 스크립트
API 서버가 실행 중인 상태에서 실행.
실제 토스 결제 없이 전체 흐름을 시뮬레이션.
사용법:
pip3 install httpx
python3 test_flow.py
"""
import asyncio
import httpx
import json
import sys
BASE_URL = "http://localhost:8000/api/v1"
G = "\033[92m"
Y = "\033[93m"
C = "\033[96m"
R = "\033[91m"
E = "\033[0m"
def log(step, msg, data=None):
print(f"\n{C}{''*50}{E}")
print(f"{Y}[{step}]{E} {msg}")
if data:
print(f"{G}{json.dumps(data, indent=2, ensure_ascii=False, default=str)}{E}")
def check(resp, step):
"""응답 확인 - 실패 시 내용 출력 후 중단"""
if resp.status_code >= 400:
print(f"\n{R}{''*50}")
print(f" ERROR at [{step}] - HTTP {resp.status_code}")
print(f" {resp.json()}")
print(f"{''*50}{E}\n")
sys.exit(1)
return resp.json()
async def main():
async with httpx.AsyncClient(base_url=BASE_URL, timeout=10.0) as c:
# 0. 헬스체크
resp = await c.get("http://localhost:8000/health")
log("0. 헬스체크", f"HTTP {resp.status_code}", resp.json())
# 1. 충전기 등록
log("1. 충전기 등록", "CHARGER_001")
resp = await c.post("/chargers/", json={
"charge_box_id": "CHARGER_001",
"name": "A동 주차장 1번",
"location": "수원시 영통구 테스트 아파트 지하1층",
"power_kw": 7.0,
})
if resp.status_code == 409:
log("1. 충전기 등록", "이미 등록됨 - 건너뜀")
else:
log("1. 충전기 등록", f"결과: {resp.status_code}", resp.json())
# 1-1. 이전 테스트 잔여 세션 정리
log("1-1. 세션 정리", "미완료 세션 취소")
resp = await c.post("/sessions/reset/CHARGER_001")
log("1-1. 세션 정리", "결과", resp.json())
# 2. 충전기 상태 -> Available
log("2. 상태 업데이트", "충전기 Available 설정")
resp = await c.post("/ocpp/status", json={
"charge_box_id": "CHARGER_001",
"connector_id": 1,
"status": "Available",
})
log("2. 상태 업데이트", "결과", check(resp, "2"))
# 3. 세션 생성 (QR 스캔)
log("3. 세션 생성", "QR 스캔 -> 세션 생성")
resp = await c.post("/sessions/", json={
"charge_box_id": "CHARGER_001",
"connector_id": 1,
})
session = check(resp, "3")
session_uid = session["session_uid"]
id_tag = session["id_tag"]
log("3. 세션 생성", f"세션: {session_uid} / tag: {id_tag}", session)
# 4. 결제 준비
log("4. 결제 준비", "orderId 발급")
resp = await c.post("/payments/prepare", json={
"session_uid": session_uid,
"amount": 10000,
})
payment = check(resp, "4")
log("4. 결제 준비", f"주문: {payment['order_id']}", payment)
# 5. 결제 우회 (테스트용)
log("5. 결제 우회", "토스 결제 없이 AUTHORIZED 강제 설정")
print(f" {Y}(실제로는 토스페이먼츠 결제 UI -> POST /payments/confirm){E}")
resp = await c.post(f"/sessions/{session_uid}/force-authorize")
log("5. 결제 우회", "결과", check(resp, "5"))
# 6. StartTransaction (충전기 -> 서버)
log("6. StartTransaction", "충전기가 충전 시작 보고")
resp = await c.post("/ocpp/start-transaction", json={
"charge_box_id": "CHARGER_001",
"connector_id": 1,
"id_tag": id_tag,
"meter_start": 100000,
"transaction_id": 1001,
})
result = check(resp, "6")
log("6. StartTransaction", f"결과: {result.get('status')}", result)
# 7. MeterValues (충전 중 실시간 전력량)
for i, wh in enumerate([105000, 110000, 115000, 120000]):
kwh = (wh - 100000) / 1000
log(f"7-{i+1}. MeterValues", f"{wh}Wh (누적 {kwh}kWh 충전)")
await c.post("/ocpp/meter-values", json={
"charge_box_id": "CHARGER_001",
"connector_id": 1,
"transaction_id": 1001,
"value": wh,
})
# 8. 충전 중 세션 상태 확인
resp = await c.get(f"/sessions/{session_uid}")
log("8. 세션 상태", "충전 중", check(resp, "8"))
# 9. StopTransaction (충전 종료)
log("9. StopTransaction", "충전 종료 - 30kWh 충전 완료")
resp = await c.post("/ocpp/stop-transaction", json={
"charge_box_id": "CHARGER_001",
"transaction_id": 1001,
"meter_stop": 130000,
"reason": "Local",
})
result = check(resp, "9")
log("9. StopTransaction", "정산 결과", result)
# 10. 최종 정산
resp = await c.get(f"/sessions/{session_uid}/billing")
billing = check(resp, "10")
log("10. 최종 정산", "요금 내역", billing)
# 11. 대시보드
resp = await c.get("/dashboard/summary")
log("11. 대시보드", "전체 요약", check(resp, "11"))
# 완료
saved = billing.get("saved_vs_cpo", 0)
total = billing.get("total_bill", 0)
kwh = billing.get("charged_kwh", 0)
print(f"\n{G}{''*50}")
print(f" 충전 완료: {kwh}kWh")
print(f" 요금: {total:,}원 (170원/kWh)")
print(f" CPO 대비 절감: {saved:,}")
print(f"{''*50}{E}\n")
if __name__ == "__main__":
asyncio.run(main())