From 4558ac10c06ed5fa24035d90f1a629532b8624a0 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 18 Apr 2026 05:59:31 +0900 Subject: [PATCH] =?UTF-8?q?EV=20=EC=B6=A9=EC=A0=84=20=ED=94=8C=EB=9E=AB?= =?UTF-8?q?=ED=8F=BC=20=EC=B4=88=EA=B8=B0=20=EB=B0=B1=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 35 ++ .gitignore | 9 + Dockerfile | 15 + INSTALL_GUIDE.md | 221 +++++++++++ README.md | 139 +++++++ alembic.ini | 36 ++ app/__init__.py | 0 app/config.py | 70 ++++ app/database.py | 47 +++ app/main.py | 142 +++++++ app/models/__init__.py | 180 +++++++++ app/routers/__init__.py | 0 app/routers/auth.py | 219 +++++++++++ app/routers/chargers.py | 92 +++++ app/routers/dashboard.py | 131 +++++++ app/routers/ocpp_callbacks.py | 258 +++++++++++++ app/routers/payments.py | 179 +++++++++ app/routers/qr.py | 38 ++ app/routers/sessions.py | 276 +++++++++++++ app/schemas/__init__.py | 156 ++++++++ app/services/__init__.py | 0 app/services/auth.py | 113 ++++++ app/services/billing.py | 51 +++ app/services/payment.py | 115 ++++++ app/services/scheduler.py | 94 +++++ app/services/steve_client.py | 132 +++++++ app/utils/__init__.py | 0 dashboard.html | 419 ++++++++++++++++++++ docker-compose.yml | 66 ++++ ev_simulator.py | 707 ++++++++++++++++++++++++++++++++++ nginx_fastapi.conf | 63 +++ nohup.out | 40 ++ ocpp_analyzer.py | 214 ++++++++++ ocpp_proxy_server.py | 645 +++++++++++++++++++++++++++++++ ocpp_sniffer.py | 197 ++++++++++ proxy.log | 5 + proxy_control.html | 339 ++++++++++++++++ requirements.txt | 16 + simulator.html | 625 ++++++++++++++++++++++++++++++ test_flow.py | 162 ++++++++ 40 files changed, 6246 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100755 INSTALL_GUIDE.md create mode 100644 README.md create mode 100644 alembic.ini create mode 100644 app/__init__.py create mode 100644 app/config.py create mode 100644 app/database.py create mode 100644 app/main.py create mode 100644 app/models/__init__.py create mode 100644 app/routers/__init__.py create mode 100644 app/routers/auth.py create mode 100644 app/routers/chargers.py create mode 100644 app/routers/dashboard.py create mode 100644 app/routers/ocpp_callbacks.py create mode 100644 app/routers/payments.py create mode 100644 app/routers/qr.py create mode 100644 app/routers/sessions.py create mode 100644 app/schemas/__init__.py create mode 100644 app/services/__init__.py create mode 100644 app/services/auth.py create mode 100644 app/services/billing.py create mode 100644 app/services/payment.py create mode 100644 app/services/scheduler.py create mode 100644 app/services/steve_client.py create mode 100644 app/utils/__init__.py create mode 100644 dashboard.html create mode 100644 docker-compose.yml create mode 100644 ev_simulator.py create mode 100644 nginx_fastapi.conf create mode 100644 nohup.out create mode 100644 ocpp_analyzer.py create mode 100644 ocpp_proxy_server.py create mode 100644 ocpp_sniffer.py create mode 100644 proxy.log create mode 100644 proxy_control.html create mode 100644 requirements.txt create mode 100644 simulator.html create mode 100644 test_flow.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5f09019 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c4f21de --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +__pycache__/ +*.pyc +.env +proxy_users.json +proxy_config.json +ocpp_logs/ +pgdata/ +*.tar.gz +*.sql diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..675357a --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/INSTALL_GUIDE.md b/INSTALL_GUIDE.md new file mode 100755 index 0000000..2c9ab63 --- /dev/null +++ b/INSTALL_GUIDE.md @@ -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 +``` diff --git a/README.md b/README.md new file mode 100644 index 0000000..7906b0b --- /dev/null +++ b/README.md @@ -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, 보안 헤더) diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..03ab0b3 --- /dev/null +++ b/alembic.ini @@ -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 diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..db9ca15 --- /dev/null +++ b/app/config.py @@ -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() diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..526706f --- /dev/null +++ b/app/database.py @@ -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) diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..c89d0f1 --- /dev/null +++ b/app/main.py @@ -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") diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..fea46ba --- /dev/null +++ b/app/models/__init__.py @@ -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"" + + +# ── 충전 세션 ── + +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"" + + +# ── 결제 ── + +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"" + + +# ── 충전기 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"" diff --git a/app/routers/__init__.py b/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/routers/auth.py b/app/routers/auth.py new file mode 100644 index 0000000..bb552c4 --- /dev/null +++ b/app/routers/auth.py @@ -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} 삭제 완료"} diff --git a/app/routers/chargers.py b/app/routers/chargers.py new file mode 100644 index 0000000..c94bad1 --- /dev/null +++ b/app/routers/chargers.py @@ -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} 비활성화 완료"} diff --git a/app/routers/dashboard.py b/app/routers/dashboard.py new file mode 100644 index 0000000..e31a459 --- /dev/null +++ b/app/routers/dashboard.py @@ -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() + ] diff --git a/app/routers/ocpp_callbacks.py b/app/routers/ocpp_callbacks.py new file mode 100644 index 0000000..fdcea50 --- /dev/null +++ b/app/routers/ocpp_callbacks.py @@ -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() diff --git a/app/routers/payments.py b/app/routers/payments.py new file mode 100644 index 0000000..a7f37dc --- /dev/null +++ b/app/routers/payments.py @@ -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 diff --git a/app/routers/qr.py b/app/routers/qr.py new file mode 100644 index 0000000..a77b79c --- /dev/null +++ b/app/routers/qr.py @@ -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"' + }, + ) diff --git a/app/routers/sessions.py b/app/routers/sessions.py new file mode 100644 index 0000000..4390f80 --- /dev/null +++ b/app/routers/sessions.py @@ -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 diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py new file mode 100644 index 0000000..32d0c4a --- /dev/null +++ b/app/schemas/__init__.py @@ -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 diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/services/auth.py b/app/services/auth.py new file mode 100644 index 0000000..bf53707 --- /dev/null +++ b/app/services/auth.py @@ -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() diff --git a/app/services/billing.py b/app/services/billing.py new file mode 100644 index 0000000..6918a68 --- /dev/null +++ b/app/services/billing.py @@ -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)), + } diff --git a/app/services/payment.py b/app/services/payment.py new file mode 100644 index 0000000..05915b7 --- /dev/null +++ b/app/services/payment.py @@ -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() diff --git a/app/services/scheduler.py b/app/services/scheduler.py new file mode 100644 index 0000000..229cf76 --- /dev/null +++ b/app/services/scheduler.py @@ -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}") diff --git a/app/services/steve_client.py b/app/services/steve_client.py new file mode 100644 index 0000000..f321da9 --- /dev/null +++ b/app/services/steve_client.py @@ -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() diff --git a/app/utils/__init__.py b/app/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dashboard.html b/dashboard.html new file mode 100644 index 0000000..80d9bfc --- /dev/null +++ b/dashboard.html @@ -0,0 +1,419 @@ + + + + + +EV 충전 관리 대시보드 + + + + + + + + + + + + + + + + + + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c502827 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/ev_simulator.py b/ev_simulator.py new file mode 100644 index 0000000..64fee85 --- /dev/null +++ b/ev_simulator.py @@ -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("", lambda e: canvas.configure(scrollregion=canvas.bbox("all"))) + canvas.create_window((0, 0), window=scroll_frame, anchor="nw", width=360) + canvas.configure(yscrollcommand=scrollbar.set) + + canvas.pack(side="left", fill="both", expand=True) + scrollbar.pack(side="right", fill="y") + + # 마우스 휠 스크롤 + def _on_mousewheel(event): + canvas.yview_scroll(int(-1 * (event.delta / 120)), "units") + canvas.bind_all("", _on_mousewheel) + + f = scroll_frame + + # ── 서버 접속 ── + self._section(f, "서버 접속") + self.var_server = self._entry(f, "서버 주소", "https://s1.byunc.com") + + # ── 프리셋 ── + self._section(f, "프리셋") + preset_frame = tk.Frame(f, bg=BG) + preset_frame.pack(fill="x", pady=(0, 8)) + + presets = [ + ("기본 7kW", "basic"), ("급속 50kW", "fast"), ("완속 3kW", "slow"), + ("단시간", "short"), ("완충", "full"), ("에러", "error"), + ] + for i, (label, key) in enumerate(presets): + btn = tk.Button(preset_frame, text=label, font=("Consolas", 8), + bg=BG3, fg=FG2, bd=0, padx=6, pady=3, + activebackground=BG2, activeforeground=ACCENT, + command=lambda k=key: self._apply_preset(k)) + btn.grid(row=i // 3, column=i % 3, padx=2, pady=2, sticky="ew") + preset_frame.columnconfigure([0, 1, 2], weight=1) + + # ── 충전기 설정 ── + self._section(f, "충전기") + row1 = tk.Frame(f, bg=BG) + row1.pack(fill="x", pady=2) + self.var_charger = self._entry_in(row1, "충전기 ID", "CHARGER_001", side="left") + self.var_connector = self._entry_in(row1, "커넥터", "1", width=5, side="left") + + row2 = tk.Frame(f, bg=BG) + row2.pack(fill="x", pady=2) + self.var_name = self._entry_in(row2, "이름", "A동 주차장 1번", side="left") + self.var_power = self._entry_in(row2, "출력kW", "7", width=6, side="left") + + self.var_location = self._entry(f, "설치 위치", "수원시 영통구 테스트 아파트 지하1층") + + # ── 충전 시뮬레이션 ── + self._section(f, "충전 시뮬레이션") + + row3 = tk.Frame(f, bg=BG) + row3.pack(fill="x", pady=2) + self.var_meter_start = self._entry_in(row3, "미터시작(Wh)", "100000", side="left") + self.var_txn_id = self._entry_in(row3, "TxnID", "", width=8, side="left") + + # 목표 충전량 슬라이더 + slider_frame = tk.Frame(f, bg=BG) + slider_frame.pack(fill="x", pady=(8, 0)) + tk.Label(slider_frame, text="목표 충전량", font=("Segoe UI", 9), bg=BG, fg=FG3).pack(anchor="w") + + self.var_target_kwh = tk.IntVar(value=30) + self.target_label = tk.Label(slider_frame, text="30 kWh", font=("Consolas", 14, "bold"), + bg=BG, fg=ACCENT) + self.target_label.pack(anchor="w") + + slider = tk.Scale(slider_frame, from_=1, to=100, orient="horizontal", + variable=self.var_target_kwh, bg=BG, fg=ACCENT, troughcolor=BG3, + highlightthickness=0, bd=0, sliderrelief="flat", + activebackground=ACCENT, font=("Consolas", 8), + command=self._update_slider) + slider.pack(fill="x") + + # 예상 요금 표시 + self.estimate_label = tk.Label(slider_frame, text="", font=("Consolas", 9), + bg=BG, fg=FG3) + self.estimate_label.pack(anchor="w", pady=(2, 0)) + self._update_slider(30) + + row4 = tk.Frame(f, bg=BG) + row4.pack(fill="x", pady=6) + self.var_meter_steps = self._entry_in(row4, "보고횟수", "4", width=5, side="left") + self.var_delay = self._entry_in(row4, "딜레이(ms)", "500", width=7, side="left") + + # ── 결제 / 종료 ── + self._section(f, "결제 / 종료") + + row5 = tk.Frame(f, bg=BG) + row5.pack(fill="x", pady=2) + self.var_amount = self._entry_in(row5, "선결제(원)", "10000", side="left") + + tk.Label(f, text="종료 사유", font=("Segoe UI", 9), bg=BG, fg=FG3).pack(anchor="w", pady=(4, 0)) + self.var_stop_reason = ttk.Combobox(f, values=[ + "Local", "Remote", "EVDisconnected", "PowerLoss", "EmergencyStop", "Other" + ], state="readonly", font=("Consolas", 10)) + self.var_stop_reason.set("Local") + self.var_stop_reason.pack(fill="x", pady=2) + + # ── 에러 시뮬레이션 ── + self._section(f, "에러 시뮬레이션") + tk.Label(f, text="에러 코드", font=("Segoe UI", 9), bg=BG, fg=FG3).pack(anchor="w") + self.var_error_code = ttk.Combobox(f, values=[ + "NoError", "ConnectorLockFailure", "GroundFailure", + "HighTemperature", "OverCurrentFailure", "OverVoltage", + "UnderVoltage", "PowerMeterFailure", "PowerSwitchFailure", + "InternalError", "OtherError" + ], state="readonly", font=("Consolas", 10)) + self.var_error_code.set("NoError") + self.var_error_code.pack(fill="x", pady=2) + + # ── 실행 버튼 ── + tk.Frame(f, bg=BG, height=12).pack() + + self.btn_run = tk.Button(f, text="▶ 전체 흐름 실행", font=("Segoe UI", 12, "bold"), + bg="#0e3a4a", fg=ACCENT, bd=0, pady=10, + activebackground="#15485c", activeforeground=ACCENT, + command=self._run_full) + self.btn_run.pack(fill="x", pady=2) + + btn_row = tk.Frame(f, bg=BG) + btn_row.pack(fill="x", pady=2) + + tk.Button(btn_row, text="단계별 실행", font=("Segoe UI", 9), + bg=BG3, fg=AMBER, bd=0, pady=6, + activebackground=BG2, command=self._run_step).pack(side="left", fill="x", expand=True, padx=(0, 4)) + + tk.Button(btn_row, text="초기화", font=("Segoe UI", 9), + bg=BG3, fg=FG2, bd=0, pady=6, + activebackground=BG2, command=self._reset).pack(side="left", fill="x", expand=True, padx=(4, 0)) + + def _build_results(self, parent): + """결과 패널""" + + # 탭: 실행 로그 / JSON 상세 + self.notebook = ttk.Notebook(parent) + self.notebook.pack(fill="both", expand=True) + + # 탭1: 실행 로그 + tab1 = tk.Frame(self.notebook, bg=BG) + self.notebook.add(tab1, text=" 실행 로그 ") + + self.log_text = scrolledtext.ScrolledText( + tab1, bg="#0a0e17", fg="#8ec8e8", font=("Consolas", 10), + insertbackground=ACCENT, wrap="word", bd=0, padx=12, pady=12, + selectbackground="#2a3a4a" + ) + self.log_text.pack(fill="both", expand=True) + + # 태그 설정 + self.log_text.tag_configure("header", foreground="#fff", font=("Consolas", 10, "bold")) + self.log_text.tag_configure("step", foreground=ACCENT, font=("Consolas", 10, "bold")) + self.log_text.tag_configure("ok", foreground=GREEN) + self.log_text.tag_configure("fail", foreground=RED) + self.log_text.tag_configure("warn", foreground=AMBER) + self.log_text.tag_configure("info", foreground=FG2) + self.log_text.tag_configure("dim", foreground=FG3) + self.log_text.tag_configure("key", foreground="#f472b6") + self.log_text.tag_configure("val", foreground="#a5f3c4") + self.log_text.tag_configure("num", foreground="#c4b5fd") + self.log_text.tag_configure("summary_head", foreground="#fff", font=("Consolas", 12, "bold")) + self.log_text.tag_configure("summary_val", foreground=GREEN, font=("Consolas", 14, "bold")) + self.log_text.tag_configure("summary_save", foreground=AMBER, font=("Consolas", 11, "bold")) + + # 탭2: JSON 상세 + tab2 = tk.Frame(self.notebook, bg=BG) + self.notebook.add(tab2, text=" JSON 상세 ") + + self.json_text = scrolledtext.ScrolledText( + tab2, bg="#0a0e17", fg="#8ec8e8", font=("Consolas", 10), + insertbackground=ACCENT, wrap="word", bd=0, padx=12, pady=12 + ) + self.json_text.pack(fill="both", expand=True) + self.json_text.tag_configure("key", foreground="#f472b6") + self.json_text.tag_configure("str", foreground="#a5f3c4") + self.json_text.tag_configure("num", foreground="#c4b5fd") + + # 탭3: 요약 + tab3 = tk.Frame(self.notebook, bg=BG) + self.notebook.add(tab3, text=" 결과 요약 ") + + self.summary_frame = tk.Frame(tab3, bg=BG) + self.summary_frame.pack(fill="both", expand=True, padx=20, pady=20) + + # 하단 상태바 + status_bar = tk.Frame(parent, bg=BG2, height=28) + status_bar.pack(fill="x", side="bottom") + status_bar.pack_propagate(False) + + self.status_label = tk.Label(status_bar, text="준비", font=("Consolas", 9), + bg=BG2, fg=FG3, padx=12) + self.status_label.pack(side="left") + + self.progress = ttk.Progressbar(status_bar, length=200, mode="determinate") + self.progress.pack(side="right", padx=12, pady=6) + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # UI 유틸 + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + def _section(self, parent, title): + sep = tk.Frame(parent, bg=BORDER, height=1) + sep.pack(fill="x", pady=(12, 4)) + tk.Label(parent, text=title.upper(), font=("Consolas", 8), bg=BG, fg=ACCENT, + anchor="w").pack(fill="x") + + def _entry(self, parent, label, default=""): + tk.Label(parent, text=label, font=("Segoe UI", 9), bg=BG, fg=FG3).pack(anchor="w", pady=(4, 0)) + var = tk.StringVar(value=default) + ent = tk.Entry(parent, textvariable=var, font=("Consolas", 10), + bg=BG_INPUT, fg=FG, insertbackground=ACCENT, + bd=0, relief="flat", highlightthickness=1, + highlightbackground=BORDER, highlightcolor=ACCENT) + ent.pack(fill="x", pady=2, ipady=4) + return var + + def _entry_in(self, parent, label, default="", width=15, side="left"): + frame = tk.Frame(parent, bg=BG) + frame.pack(side=side, fill="x", expand=True, padx=(0, 6)) + tk.Label(frame, text=label, font=("Segoe UI", 8), bg=BG, fg=FG3).pack(anchor="w") + var = tk.StringVar(value=default) + tk.Entry(frame, textvariable=var, font=("Consolas", 10), width=width, + bg=BG_INPUT, fg=FG, insertbackground=ACCENT, + bd=0, highlightthickness=1, + highlightbackground=BORDER, highlightcolor=ACCENT).pack(fill="x", ipady=3) + return var + + def _update_slider(self, val): + kwh = int(val) + cost = kwh * 170 + cpo = kwh * 350 + self.target_label.config(text=f"{kwh} kWh") + self.estimate_label.config(text=f"예상 {cost:,}원 | CPO {cpo:,}원 | 절감 {cpo - cost:,}원") + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # 프리셋 + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + PRESETS = { + "basic": dict(power="7", target=30, steps="4", amount="10000", delay="500", error="NoError", stop="Local"), + "fast": dict(power="50", target=60, steps="6", amount="30000", delay="300", error="NoError", stop="Local"), + "slow": dict(power="3", target=10, steps="3", amount="5000", delay="800", error="NoError", stop="Local"), + "short": dict(power="7", target=5, steps="2", amount="2000", delay="300", error="NoError", stop="Local"), + "full": dict(power="11", target=80, steps="8", amount="50000", delay="400", error="NoError", stop="Local"), + "error": dict(power="7", target=15, steps="3", amount="10000", delay="500", error="OverCurrentFailure", stop="EmergencyStop"), + } + + def _apply_preset(self, name): + p = self.PRESETS[name] + self.var_power.set(p["power"]) + self.var_target_kwh.set(p["target"]) + self.var_meter_steps.set(p["steps"]) + self.var_amount.set(p["amount"]) + self.var_delay.set(p["delay"]) + self.var_error_code.set(p["error"]) + self.var_stop_reason.set(p["stop"]) + self._update_slider(p["target"]) + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # 로깅 + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + def _log(self, text, tag="info"): + self.log_text.insert("end", text + "\n", tag) + self.log_text.see("end") + + def _log_step(self, num, title): + ts = datetime.now().strftime("%H:%M:%S") + self._log(f"\n{'─' * 50}", "dim") + self._log(f"[{ts}] STEP {num} — {title}", "step") + + def _log_result(self, data, success=True): + tag = "ok" if success else "fail" + pretty = json.dumps(data, indent=2, ensure_ascii=False, default=str) + self._log(pretty, tag) + + # JSON 탭에도 추가 + self.json_text.insert("end", pretty + "\n\n") + self.json_text.see("end") + + def _log_summary(self, billing): + self._log(f"\n{'━' * 50}", "header") + self._log(f" 충전 완료", "summary_head") + self._log(f" {billing['charged_kwh']} kWh 충전", "summary_val") + self._log(f" 요금: {billing['total_bill']:,}원 (170원/kWh)", "summary_val") + self._log(f" 전기원가 {billing['electricity_cost']:,}원 + 서비스 {billing['service_fee']:,}원", "info") + self._log(f" CPO 대비 절감: {billing['saved_vs_cpo']:,}원", "summary_save") + self._log(f"{'━' * 50}", "header") + + # 요약 탭 업데이트 + for w in self.summary_frame.winfo_children(): + w.destroy() + + items = [ + ("충전량", f"{billing['charged_kwh']} kWh", GREEN), + ("요금", f"{billing['total_bill']:,}원", ACCENT), + ("전기원가", f"{billing['electricity_cost']:,}원", FG2), + ("서비스수익", f"{billing['service_fee']:,}원", PURPLE), + ("CPO대비 절감", f"{billing['saved_vs_cpo']:,}원", AMBER), + ] + for i, (label, value, color) in enumerate(items): + frame = tk.Frame(self.summary_frame, bg=BG2, padx=20, pady=16) + frame.pack(fill="x", pady=4) + tk.Label(frame, text=label, font=("Segoe UI", 11), bg=BG2, fg=FG3).pack(anchor="w") + tk.Label(frame, text=value, font=("Consolas", 20, "bold"), bg=BG2, fg=color).pack(anchor="w") + + def _set_status(self, text, progress=0): + self.status_label.config(text=text) + self.progress["value"] = progress + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # 파라미터 수집 + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + def _get_params(self): + target_kwh = self.var_target_kwh.get() + meter_start = int(self.var_meter_start.get()) + meter_steps = int(self.var_meter_steps.get()) + step_wh = round(target_kwh * 1000 / meter_steps) + txn_input = self.var_txn_id.get().strip() + + return { + "server": self.var_server.get().rstrip("/"), + "charger": self.var_charger.get(), + "connector": int(self.var_connector.get()), + "name": self.var_name.get(), + "location": self.var_location.get(), + "power": float(self.var_power.get()), + "meter_start": meter_start, + "target_kwh": target_kwh, + "meter_steps": meter_steps, + "step_wh": step_wh, + "meter_stop": meter_start + target_kwh * 1000, + "txn_id": int(txn_input) if txn_input else int(time.time()) % 100000, + "amount": int(self.var_amount.get()), + "stop_reason": self.var_stop_reason.get(), + "error_code": self.var_error_code.get(), + "delay": int(self.var_delay.get()) / 1000.0, + } + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # API 호출 + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + def _api(self, base, method, path, json_data=None): + url = base + "/api/v1" + path + try: + with httpx.Client(timeout=15.0, verify=True) as client: + if method == "GET": + r = client.get(url) + else: + r = client.post(url, json=json_data) + data = r.json() + return {"ok": r.status_code < 400, "status": r.status_code, "data": data} + except Exception as e: + return {"ok": False, "status": 0, "data": {"error": str(e)}} + + def _health(self, base): + try: + with httpx.Client(timeout=10.0, verify=True) as client: + r = client.get(base + "/health") + return {"ok": r.status_code == 200, "status": r.status_code, "data": r.json()} + except Exception as e: + return {"ok": False, "status": 0, "data": {"error": str(e)}} + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # 실행 + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + def _run_full(self): + if self.running: + return + self.step_mode = False + threading.Thread(target=self._execute, daemon=True).start() + + def _run_step(self): + if self.running: + # 이미 실행 중이면 다음 단계로 + if hasattr(self, "_step_event"): + self._step_event.set() + return + self.step_mode = True + self._step_event = threading.Event() + threading.Thread(target=self._execute, daemon=True).start() + + def _wait_step(self): + if self.step_mode: + self._step_event.clear() + self.root.after(0, lambda: self.status_label.config(text="[단계별] 다음 → '단계별 실행' 클릭")) + self._step_event.wait() + + def _reset(self): + self.running = False + self.log_text.delete("1.0", "end") + self.json_text.delete("1.0", "end") + for w in self.summary_frame.winfo_children(): + w.destroy() + self._set_status("준비", 0) + + def _execute(self): + self.running = True + self.root.after(0, lambda: self.btn_run.config(state="disabled", text="실행 중...")) + self.root.after(0, lambda: self._reset()) + + P = self._get_params() + base = P["server"] + total_steps = 12 + current_step = 0 + + def progress(): + nonlocal current_step + current_step += 1 + pct = int(current_step / total_steps * 100) + self.root.after(0, lambda: self._set_status(f"Step {current_step}/{total_steps}", pct)) + + try: + self.root.after(0, lambda: self._log("EV 충전 시뮬레이터 시작", "header")) + self.root.after(0, lambda: self._log(f"서버: {base}", "info")) + self.root.after(0, lambda: self._log(f"충전기: {P['charger']} | 목표: {P['target_kwh']}kWh | 출력: {P['power']}kW", "info")) + + # 0. 헬스체크 + self._wait_step() + self.root.after(0, lambda: self._log_step("0", "헬스체크")) + r = self._health(base) + self.root.after(0, lambda: self._log_result(r["data"], r["ok"])) + if not r["ok"]: + self.root.after(0, lambda: self._log("서버 연결 실패! 주소를 확인하세요.", "fail")) + return + progress() + time.sleep(P["delay"]) + + # 1. 충전기 등록 + self._wait_step() + self.root.after(0, lambda: self._log_step("1", f"충전기 등록 ({P['charger']})")) + r = self._api(base, "POST", "/chargers/", { + "charge_box_id": P["charger"], "name": P["name"], + "location": P["location"], "connector_count": 1, "power_kw": P["power"], + }) + if r["status"] == 409: + self.root.after(0, lambda: self._log("이미 등록됨 — 건너뜀", "warn")) + else: + self.root.after(0, lambda: self._log_result(r["data"], r["ok"])) + progress() + + # 1-1. 세션 정리 + self._wait_step() + self.root.after(0, lambda: self._log_step("1-1", "잔여 세션 정리")) + r = self._api(base, "POST", f"/sessions/reset/{P['charger']}") + self.root.after(0, lambda: self._log_result(r["data"])) + progress() + time.sleep(P["delay"]) + + # 2. 상태 업데이트 + self._wait_step() + status_val = "Available" if P["error_code"] == "NoError" else "Faulted" + self.root.after(0, lambda: self._log_step("2", f"충전기 상태 → {status_val}")) + r = self._api(base, "POST", "/ocpp/status", { + "charge_box_id": P["charger"], "connector_id": P["connector"], + "status": status_val, "error_code": P["error_code"], + }) + self.root.after(0, lambda: self._log_result(r["data"], r["ok"])) + progress() + + if P["error_code"] != "NoError": + self.root.after(0, lambda: self._log(f"\n에러 상태 설정 완료: {P['error_code']}", "warn")) + self.root.after(0, lambda: self._log("에러 상태에서는 충전을 진행하지 않습니다.", "warn")) + return + time.sleep(P["delay"]) + + # 3. 세션 생성 + self._wait_step() + self.root.after(0, lambda: self._log_step("3", "세션 생성 (QR 스캔)")) + r = self._api(base, "POST", "/sessions/", { + "charge_box_id": P["charger"], "connector_id": P["connector"], + }) + if not r["ok"]: + self.root.after(0, lambda: self._log_result(r["data"], False)) + return + self.session_uid = r["data"]["session_uid"] + self.id_tag = r["data"]["id_tag"] + self.root.after(0, lambda: self._log_result(r["data"])) + progress() + time.sleep(P["delay"]) + + # 4. 결제 준비 + self._wait_step() + self.root.after(0, lambda: self._log_step("4", f"결제 준비 ({P['amount']:,}원)")) + r = self._api(base, "POST", "/payments/prepare", { + "session_uid": self.session_uid, "amount": P["amount"], + }) + self.root.after(0, lambda: self._log_result(r["data"], r["ok"])) + progress() + time.sleep(P["delay"]) + + # 5. 결제 우회 + self._wait_step() + self.root.after(0, lambda: self._log_step("5", "결제 우회 → AUTHORIZED")) + r = self._api(base, "POST", f"/sessions/{self.session_uid}/force-authorize") + self.root.after(0, lambda: self._log_result(r["data"], r["ok"])) + progress() + time.sleep(P["delay"]) + + # 6. StartTransaction + self._wait_step() + self.root.after(0, lambda: self._log_step("6", f"StartTransaction (meter={P['meter_start']}Wh, txn={P['txn_id']})")) + r = self._api(base, "POST", "/ocpp/start-transaction", { + "charge_box_id": P["charger"], "connector_id": P["connector"], + "id_tag": self.id_tag, "meter_start": P["meter_start"], + "transaction_id": P["txn_id"], + }) + self.root.after(0, lambda: self._log_result(r["data"], r["ok"])) + if not r["ok"]: + return + progress() + time.sleep(P["delay"]) + + # 7. MeterValues + self._wait_step() + self.root.after(0, lambda: self._log_step("7", f"MeterValues ({P['meter_steps']}회 보고)")) + for i in range(1, P["meter_steps"] + 1): + wh = P["meter_start"] + round(P["step_wh"] * i) + kwh = (wh - P["meter_start"]) / 1000 + r = self._api(base, "POST", "/ocpp/meter-values", { + "charge_box_id": P["charger"], "connector_id": P["connector"], + "transaction_id": P["txn_id"], "value": wh, + }) + self.root.after(0, lambda w=wh, k=kwh, n=i: self._log( + f" [{n}/{P['meter_steps']}] {w:,}Wh ({k:.1f} kWh)", "info")) + time.sleep(max(0.1, P["delay"] / 2)) + progress() + + # 8. 세션 조회 + self._wait_step() + self.root.after(0, lambda: self._log_step("8", "세션 상태 확인")) + r = self._api(base, "GET", f"/sessions/{self.session_uid}") + self.root.after(0, lambda: self._log_result(r["data"])) + progress() + time.sleep(P["delay"]) + + # 9. StopTransaction + self._wait_step() + self.root.after(0, lambda: self._log_step("9", f"StopTransaction (meter={P['meter_stop']:.0f}Wh, reason={P['stop_reason']})")) + r = self._api(base, "POST", "/ocpp/stop-transaction", { + "charge_box_id": P["charger"], "transaction_id": P["txn_id"], + "meter_stop": int(P["meter_stop"]), "reason": P["stop_reason"], + }) + self.root.after(0, lambda: self._log_result(r["data"], r["ok"])) + if not r["ok"]: + return + progress() + time.sleep(P["delay"]) + + # 10. 정산 + self._wait_step() + self.root.after(0, lambda: self._log_step("10", "최종 정산")) + r = self._api(base, "GET", f"/sessions/{self.session_uid}/billing") + billing = r["data"] + self.root.after(0, lambda: self._log_result(billing, r["ok"])) + progress() + + # 11. 대시보드 + self._wait_step() + self.root.after(0, lambda: self._log_step("11", "대시보드 요약")) + r = self._api(base, "GET", "/dashboard/summary") + self.root.after(0, lambda: self._log_result(r["data"])) + progress() + + # 요약 + if billing and "charged_kwh" in billing: + self.root.after(0, lambda: self._log_summary(billing)) + self.root.after(0, lambda: self.notebook.select(2)) # 요약 탭으로 + + except Exception as e: + self.root.after(0, lambda: self._log(f"\n예외 발생: {e}", "fail")) + finally: + self.running = False + self.root.after(0, lambda: self.btn_run.config(state="normal", text="▶ 전체 흐름 실행")) + self.root.after(0, lambda: self._set_status("완료", 100)) + + +# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +# 메인 +# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +if __name__ == "__main__": + root = tk.Tk() + + # 아이콘 설정 (없어도 무관) + try: + root.iconbitmap(default="") + except Exception: + pass + + app = EVSimulator(root) + root.mainloop() diff --git a/nginx_fastapi.conf b/nginx_fastapi.conf new file mode 100644 index 0000000..f71341a --- /dev/null +++ b/nginx_fastapi.conf @@ -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/ { ... } diff --git a/nohup.out b/nohup.out new file mode 100644 index 0000000..a2ea7aa --- /dev/null +++ b/nohup.out @@ -0,0 +1,40 @@ + +╔═══════════════════════════════════════════════════╗ +║ OCPP 양방향 프록시 / 로깅 서버 v1.0 ║ +╠═══════════════════════════════════════════════════╣ +║ ║ +║ 프록시 포트 : 9000 ║ +║ 관리 웹 포트 : 9001 ║ +║ 타겟 서버 : ws://cp.e-csp.co.kr/ocppext ║ +║ 로그 폴더 : ocpp_logs/ ║ +║ ║ +╠═══════════════════════════════════════════════════╣ +║ 충전기 설정 변경: ║ +║ 기존 → ws://ecap.byunc.com/ocppext/{충전기ID} ║ +║ ║ +║ 관리 페이지: ║ +║ http://192.168.0.114:9001 ║ +║ http://ecap.byunc.com:9001 ║ +╚═══════════════════════════════════════════════════╝ + +Traceback (most recent call last): + File "/root/steve/ev-charging-backend/ocpp_proxy_server.py", line 607, in + 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 diff --git a/ocpp_analyzer.py b/ocpp_analyzer.py new file mode 100644 index 0000000..e9240d6 --- /dev/null +++ b/ocpp_analyzer.py @@ -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() diff --git a/ocpp_proxy_server.py b/ocpp_proxy_server.py new file mode 100644 index 0000000..929c25f --- /dev/null +++ b/ocpp_proxy_server.py @@ -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(" 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("서버 종료") diff --git a/ocpp_sniffer.py b/ocpp_sniffer.py new file mode 100644 index 0000000..48e8ea7 --- /dev/null +++ b/ocpp_sniffer.py @@ -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}") diff --git a/proxy.log b/proxy.log new file mode 100644 index 0000000..bd25d42 --- /dev/null +++ b/proxy.log @@ -0,0 +1,5 @@ +nohup: ignoring input +04:55:11 [INFO] server listening on 0.0.0.0:9002 +04:55:11 [INFO] 프록시 서버: 포트 9002 +04:55:11 [INFO] 관리 웹서버: 포트 9003 +04:55:11 [INFO] 서버 대기 중... (Ctrl+C 종료) diff --git a/proxy_control.html b/proxy_control.html new file mode 100644 index 0000000..b8207f8 --- /dev/null +++ b/proxy_control.html @@ -0,0 +1,339 @@ + + + + + +OCPP 프록시 관리 + + + + + + + + + + + + + + + + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..73e43eb --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/simulator.html b/simulator.html new file mode 100644 index 0000000..9fe918f --- /dev/null +++ b/simulator.html @@ -0,0 +1,625 @@ + + + + + +EV 충전 시뮬레이터 + + + + +
+
+
+

EV Charging Simulator

+ OCPP TEST CONSOLE +
+ +
+ +
+ +
+
+
테스트 파라미터
+
+
+ + +
+
프리셋
+
+ + + + + + +
+
+ + +
+
충전기
+
+
+
충전기 ID
+ +
+
+
커넥터 번호
+ +
+
+
+
+
충전기 이름
+ +
+
+
출력 (kW)
+ +
+
+
+
+
설치 위치
+ +
+
+
+ + +
+
충전 시뮬레이션
+
+
+
미터 시작값 (Wh)
+ +
+
+
Transaction ID
+ +
비워두면 타임스탬프 기반 자동 생성
+
+
+ +
+
목표 충전량 (kWh)
+
+ +
+ 1 kWh + 30 kWh + 100 kWh +
+
+
+ +
+
+
MeterValues 횟수
+ +
충전 중 보고 횟수
+
+
+
스텝 딜레이 (ms)
+ +
각 단계 사이 대기
+
+
+
+ + +
+
결제 / 종료
+
+
+
선결제 금액 (원)
+ +
+
+
종료 사유
+ +
+
+
+ + +
+
에러 시뮬레이션
+
+
+
충전기 에러 코드
+ +
+
+
+ + + +
+ + +
+
+
+ + +
+
+
+ + + + diff --git a/test_flow.py b/test_flow.py new file mode 100644 index 0000000..117b358 --- /dev/null +++ b/test_flow.py @@ -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())