EV 충전 플랫폼 초기 백업
This commit is contained in:
35
.env.example
Normal file
35
.env.example
Normal 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
9
.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
proxy_users.json
|
||||
proxy_config.json
|
||||
ocpp_logs/
|
||||
pgdata/
|
||||
*.tar.gz
|
||||
*.sql
|
||||
15
Dockerfile
Normal file
15
Dockerfile
Normal 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
221
INSTALL_GUIDE.md
Executable 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
139
README.md
Normal 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
36
alembic.ini
Normal 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
0
app/__init__.py
Normal file
70
app/config.py
Normal file
70
app/config.py
Normal 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
47
app/database.py
Normal 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
142
app/main.py
Normal 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
180
app/models/__init__.py
Normal 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
0
app/routers/__init__.py
Normal file
219
app/routers/auth.py
Normal file
219
app/routers/auth.py
Normal 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
92
app/routers/chargers.py
Normal 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
131
app/routers/dashboard.py
Normal 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()
|
||||
]
|
||||
258
app/routers/ocpp_callbacks.py
Normal file
258
app/routers/ocpp_callbacks.py
Normal 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
179
app/routers/payments.py
Normal 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
38
app/routers/qr.py
Normal 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
276
app/routers/sessions.py
Normal 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
156
app/schemas/__init__.py
Normal 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
0
app/services/__init__.py
Normal file
113
app/services/auth.py
Normal file
113
app/services/auth.py
Normal 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
51
app/services/billing.py
Normal 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
115
app/services/payment.py
Normal 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
94
app/services/scheduler.py
Normal 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}")
|
||||
132
app/services/steve_client.py
Normal file
132
app/services/steve_client.py
Normal 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
0
app/utils/__init__.py
Normal file
419
dashboard.html
Normal file
419
dashboard.html
Normal 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&¤tUser){enterApp()}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
66
docker-compose.yml
Normal file
66
docker-compose.yml
Normal 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
707
ev_simulator.py
Normal 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
63
nginx_fastapi.conf
Normal 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
40
nohup.out
Normal file
@@ -0,0 +1,40 @@
|
||||
|
||||
[96m╔═══════════════════════════════════════════════════╗
|
||||
║ 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 ║
|
||||
╚═══════════════════════════════════════════════════╝[0m
|
||||
|
||||
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
214
ocpp_analyzer.py
Normal 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
645
ocpp_proxy_server.py
Normal 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
197
ocpp_sniffer.py
Normal 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
5
proxy.log
Normal 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] [92m서버 대기 중... (Ctrl+C 종료)[0m
|
||||
339
proxy_control.html
Normal file
339
proxy_control.html
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>')}
|
||||
|
||||
// ── 초기화 ──
|
||||
load();if(token&&curUser)enterApp();
|
||||
setInterval(()=>{if(token)loadStatus()},15000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
16
requirements.txt
Normal file
16
requirements.txt
Normal 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
625
simulator.html
Normal 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
162
test_flow.py
Normal 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())
|
||||
Reference in New Issue
Block a user