Reafctor app

This commit is contained in:
Oliver Traber 2024-04-13 22:43:03 +02:00
parent b8216f6ade
commit 7740be8bb5
Signed by: Bluemedia
GPG key ID: C0674B105057136C
36 changed files with 389 additions and 208 deletions

1
.gitignore vendored
View file

@ -1,3 +1,2 @@
**/__init__.py
**/__pycache__
simple-ocpp-cs.db

0
app/__init__.py Normal file
View file

View file

@ -15,3 +15,10 @@ else:
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

View file

@ -2,9 +2,11 @@ from fastapi import FastAPI
from starlette.middleware.authentication import AuthenticationMiddleware
import uvicorn
from routers import chargepoint_v1, ocpp_v1
from database import engine, Base
from util.websocket_auth_backend import BasicAuthBackend
from app.database import engine, Base
from app.models import *
from app.routers import chargepoint_v1, ocpp_v1
from app.util.websocket_auth_backend import BasicAuthBackend
Base.metadata.create_all(bind=engine)

8
app/models/__init__.py Normal file
View file

@ -0,0 +1,8 @@
__all__ = [
"chargepoint",
"connector",
"id_token",
"meter_value",
"transaction",
"user"
]

View file

@ -1,15 +1,16 @@
from sqlalchemy import Boolean, Column, DateTime, String
import uuid
from sqlalchemy import Uuid, Boolean, Column, DateTime, String
from sqlalchemy.orm import relationship
from database import Base
from app.database import Base
class ChargePoint(Base):
__tablename__ = "chargepoints"
id = Column(String, primary_key=True)
id = Column(Uuid, primary_key=True, default=uuid.uuid4)
friendly_name = Column(String, unique=True, index=True)
is_active = Column(Boolean, default=True)
password = Column(String)
last_seen = Column(DateTime, nullable=True)
connectors = relationship("Connector")
connectors = relationship("Connector", cascade="delete, delete-orphan")

15
app/models/connector.py Normal file
View file

@ -0,0 +1,15 @@
import uuid
from sqlalchemy import Uuid, Column, Enum, ForeignKey, Integer
from app.schemas.connector import ConnectorStatus
from app.database import Base
class Connector(Base):
__tablename__ = "connectors"
id = Column(Uuid, primary_key=True, default=uuid.uuid4)
evse = Column(Integer)
index = Column(Integer)
status = Column(Enum(ConnectorStatus))
chargepoint_id = Column(Uuid, ForeignKey("chargepoints.id"))

16
app/models/id_token.py Normal file
View file

@ -0,0 +1,16 @@
import uuid
from sqlalchemy import Uuid, Boolean, Column, ForeignKey, String
from sqlalchemy.orm import relationship
from app.database import Base
class IdToken(Base):
__tablename__ = "id_tokens"
id = Column(Uuid, primary_key=True, default=uuid.uuid4)
title = Column(String)
is_active = Column(Boolean, default=True)
token = Column(String, index=True)
owner_id = Column(Uuid, ForeignKey("users.id"))
owner = relationship("User", back_populates="id_tokens")

17
app/models/meter_value.py Normal file
View file

@ -0,0 +1,17 @@
import uuid
from sqlalchemy import Uuid, Column, DateTime, Enum, Float, ForeignKey, String
from app.database import Base
from app.schemas.meter_value import Measurand, PhaseType
class Transaction(Base):
__tablename__ = "meter_values"
id = Column(Uuid, primary_key=True, default=uuid.uuid4)
timestamp = Column(DateTime, index=True)
measurand = Column(Enum(Measurand))
phase_type = Column(Enum(PhaseType))
unit = Column(String)
value = Column(Float)
transaction_id = Column(Uuid, ForeignKey("transactions.id"), index=True)

20
app/models/transaction.py Normal file
View file

@ -0,0 +1,20 @@
import uuid
from sqlalchemy import Uuid, Column, DateTime, Enum, Float, ForeignKey
from sqlalchemy.orm import relationship, backref
from app.schemas.transaction import TransactionEventTriggerReason, TransactionStatus
from app.database import Base
class Transaction(Base):
__tablename__ = "transactions"
id = Column(Uuid, primary_key=True, default=uuid.uuid4)
status = Column(Enum(TransactionStatus), index=True)
started_at = Column(DateTime, index=True)
ended_at = Column(DateTime, nullable=True, index=True)
meter_start = Column(Float)
meter_end = Column(Float, nullable=True)
end_reason = Column(Enum(TransactionEventTriggerReason))
connector_id = Column(Uuid, ForeignKey("connectors.id"), index=True)
id_token_id = Column(Uuid, ForeignKey("id_tokens.id"), index= True)

14
app/models/user.py Normal file
View file

@ -0,0 +1,14 @@
import uuid
from sqlalchemy import Uuid, Boolean, Column, String
from sqlalchemy.orm import relationship
from app.database import Base
class User(Base):
__tablename__ = "users"
id = Column(Uuid, primary_key=True, default=uuid.uuid4)
friendly_name = Column(String, unique=True, index=True)
is_active = Column(Boolean, default=True)
id_tokens = relationship("IdToken", back_populates="owner", cascade="delete, delete-orphan")

View file

View file

@ -0,0 +1,88 @@
from datetime import datetime, UTC
import os
from ocpp.routing import on
from ocpp.v201 import ChargePoint as cp
from ocpp.v201 import call_result
from ocpp.v201.enums import Action, RegistrationStatusType, AuthorizationStatusType
from app.database import SessionLocal
from app.models.chargepoint import ChargePoint
from app.models.connector import Connector
from app.models.id_token import IdToken
from app.schemas.connector import ConnectorStatus
class ChargePoint(cp):
@on(Action.BootNotification)
async def on_boot_notification(self, charging_station, reason, **kwargs):
with SessionLocal() as db:
db_chargepoint = db.query(ChargePoint).filter(ChargePoint.friendly_name == self.id).first()
db_chargepoint.last_seen = datetime.now(UTC)
db.commit()
return call_result.BootNotificationPayload(
current_time=datetime.now(UTC).isoformat(),
interval=int(os.getenv("CS_HEARTBEAT_INTERVAL", "1800")),
status=RegistrationStatusType.accepted
)
@on(Action.Heartbeat)
async def on_heartbeat_request(self):
with SessionLocal() as db:
db_chargepoint = db.query(ChargePoint).filter(ChargePoint.friendly_name == self.id).first()
db_chargepoint.last_seen = datetime.now(UTC)
db.commit()
return call_result.HeartbeatPayload(
current_time=datetime.now(UTC).isoformat()
)
@on(Action.StatusNotification)
async def on_status_notification(self, evse_id: int, connector_id: int, connector_status: str, **kwargs):
with SessionLocal() as db:
db_chargepoint = db.query(ChargePoint).filter(ChargePoint.friendly_name == self.id).first()
db_chargepoint.last_seen = datetime.now(UTC)
db_connector = db.query(Connector).filter(
Connector.chargepoint_id == db_chargepoint.id,
Connector.evse == evse_id,
Connector.index == connector_id
).first()
if db_connector == None:
db_connector = Connector(
chargepoint_id = db_chargepoint.id,
evse = evse_id,
index = connector_id,
status = ConnectorStatus(connector_status)
)
db.add(db_connector)
else:
db_connector.status = ConnectorStatus(connector_status)
db.commit()
return call_result.StatusNotificationPayload()
@on(Action.Authorize)
async def on_authorize(self, id_token, **kwargs):
if id_token == None:
return call_result.AuthorizePayload(id_token_info={'status': AuthorizationStatusType.invalid})
if id_token.type != "ISO14443" | "ISO15693":
return call_result.AuthorizePayload(id_token_info={'status': AuthorizationStatusType.invalid})
with SessionLocal() as db:
db_chargepoint = db.query(ChargePoint).filter(ChargePoint.friendly_name == self.id).first()
db_chargepoint.last_seen = datetime.now(UTC)
db_id_token = db.query(IdToken).filter(IdToken.token == id_token.id).first()
db.commit()
if db_id_token == None:
return call_result.AuthorizePayload(id_token_info={'status': AuthorizationStatusType.unknown})
if db_id_token.is_active == False:
return call_result.AuthorizePayload(id_token_info={'status': AuthorizationStatusType.blocked})
return call_result.AuthorizePayload(id_token_info={'status': AuthorizationStatusType.accepted, 'groupIdToken': str(db_id_token.owner_id)})
@on(Action.TransactionEvent)
async def on_transaction_event(self):
return call_result.TransactionEventPayload()

View file

@ -1,22 +1,23 @@
import logging
from typing import Any, Coroutine, Dict
from uuid import UUID
from websockets import ConnectionClosed
from ocpp_proto.chargepoint import ChargePoint
from app.ocpp_proto.chargepoint import ChargePoint
__active_connections: Dict[str, ChargePoint] = {}
__active_connections: Dict[UUID, ChargePoint] = {}
async def start(cp: ChargePoint):
async def start(id: UUID, cp: ChargePoint):
try:
__active_connections[cp.id] = cp
__active_connections[id] = cp
await cp.start()
except ConnectionClosed:
logging.info("Charging station '%s' disconnected", cp.id)
__active_connections.pop(cp.id, None)
logging.info("Charging station '%s' (%s) disconnected", cp.id, id)
__active_connections.pop(id, None)
async def call(
chargepoint_id: str,
chargepoint_id: UUID,
payload: Any,
suppress: bool = True,
unique_id: Any | None = None
@ -27,5 +28,5 @@ async def call(
except KeyError as e:
raise e
def is_connected(chargepoint_id: str):
def is_connected(chargepoint_id: UUID):
return chargepoint_id in __active_connections.keys()

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

View file

@ -0,0 +1,114 @@
import random
import string
from uuid import UUID
from fastapi import APIRouter, HTTPException, Security
from fastapi.params import Depends
from sqlalchemy.orm import Session
from app.database import get_db
from app.ocpp_proto import chargepoint_manager
from app.schemas.chargepoint import ChargePoint, ChargePointCreate, ChargePointUpdate, ChargePointPassword, ChargePointConnectionInfo
from app.models.chargepoint import ChargePoint as DBChargePoint
from app.security import get_api_key
router = APIRouter(
prefix="/chargepoint",
tags=["chargepoint"],
)
@router.get(path="/", response_model=list[ChargePoint])
async def get_chargepoints(
skip: int = 0,
limit: int = 20,
api_key: str = Security(get_api_key),
db: Session = Depends(get_db)
):
return db.query(DBChargePoint).offset(skip).limit(limit).all()
@router.get(path="/{chargepoint_id}", response_model=ChargePoint)
async def get_chargepoint(
chargepoint_id: UUID,
api_key: str = Security(get_api_key),
db: Session = Depends(get_db)
):
chargepoint = db.query(DBChargePoint).filter(DBChargePoint.id == chargepoint_id).first()
if chargepoint is None:
raise HTTPException(status_code=404, detail="Chargepoint not found")
return chargepoint
@router.get(path="/{chargepoint_id}/password", response_model=ChargePointPassword)
async def get_chargepoint_password(
chargepoint_id: UUID,
api_key: str = Security(get_api_key),
db: Session = Depends(get_db)
):
chargepoint = db.query(DBChargePoint).filter(DBChargePoint.id == chargepoint_id).first()
if chargepoint is None:
raise HTTPException(status_code=404, detail="Chargepoint not found")
return ChargePointPassword(password=chargepoint.password)
@router.delete(path="/{chargepoint_id}/password", response_model=ChargePointPassword)
async def reset_chargepoint_password(
chargepoint_id: UUID,
api_key: str = Security(get_api_key),
db: Session = Depends(get_db)
):
chargepoint = db.query(DBChargePoint).filter(DBChargePoint.id == chargepoint_id).first()
if chargepoint is None:
raise HTTPException(status_code=404, detail="Chargepoint not found")
chargepoint.password = ''.join(random.choice(string.ascii_letters + string.digits) for i in range(24))
db.commit()
return ChargePointPassword(password=chargepoint.password)
@router.post(path="/", response_model=ChargePoint)
async def create_chargepoint(
chargepoint: ChargePointCreate,
api_key: str = Security(get_api_key),
db: Session = Depends(get_db)
):
chargepoint_db = DBChargePoint(
friendly_name=chargepoint.friendly_name,
is_active=chargepoint.is_active,
password=''.join(random.choice(string.ascii_letters + string.digits) for i in range(24))
)
db.add(chargepoint_db)
db.commit()
db.refresh(chargepoint_db)
return chargepoint_db
@router.patch(path="/{chargepoint_id}", response_model=ChargePoint)
async def update_chargepoint(
chargepoint_id: UUID,
chargepoint_update: ChargePointUpdate,
api_key: str = Security(get_api_key),
db: Session = Depends(get_db)
):
chargepoint = db.query(DBChargePoint).filter(DBChargePoint.id == chargepoint_id).first()
if chargepoint is None:
raise HTTPException(status_code=404, detail="Chargepoint not found")
for key, value in chargepoint_update.model_dump(exclude_unset=True).items():
setattr(chargepoint, key, value)
db.commit()
return chargepoint
@router.delete(path="/{chargepoint_id}", response_model=[])
async def delete_chargepoint(
chargepoint_id: UUID,
api_key: str = Security(get_api_key),
db: Session = Depends(get_db)
):
chargepoint = db.query(DBChargePoint).filter(DBChargePoint.id == chargepoint_id).first()
if chargepoint is None:
raise HTTPException(status_code=404, detail="Chargepoint not found")
db.delete(chargepoint)
db.commit()
return []
@router.get(path="/{chargepoint_id}/status", response_model=ChargePointConnectionInfo)
async def get_chargepoint_status(
chargepoint_id: UUID,
api_key: str = Security(get_api_key)
):
return ChargePointConnectionInfo(
connected=chargepoint_manager.is_connected(chargepoint_id)
)

View file

@ -1,24 +1,25 @@
import logging
from fastapi import APIRouter, WebSocket, WebSocketException
from ocpp_proto import chargepoint_manager
from ocpp_proto.chargepoint import ChargePoint
from util.websocket_wrapper import WebSocketWrapper
from app.ocpp_proto import chargepoint_manager
from app.ocpp_proto.chargepoint import ChargePoint
from app.util.websocket_wrapper import WebSocketWrapper
router = APIRouter()
@router.websocket("/{charging_station_id}")
@router.websocket("/{charging_station_friendly_name}")
async def websocket_endpoint(
*,
websocket: WebSocket,
charging_station_id: str,
charging_station_friendly_name: str,
):
""" For every new charging station that connects, create a ChargePoint
instance and start listening for messages.
"""
if (websocket.user.username != charging_station_id):
if (websocket.user.friendly_name != charging_station_friendly_name):
raise WebSocketException(code=1008, reason="Username doesn't match chargepoint identifier")
logging.info("Charging station '%s' connected", charging_station_id)
logging.info("Charging station '%s' (%s) connected", charging_station_friendly_name, websocket.user.id)
# Check protocols
try:
@ -42,5 +43,5 @@ async def websocket_endpoint(
# Accept connection and begin communication
await websocket.accept(subprotocol="ocpp2.0.1")
cp = ChargePoint(charging_station_id, WebSocketWrapper(websocket))
await chargepoint_manager.start(cp)
cp = ChargePoint(charging_station_friendly_name, WebSocketWrapper(websocket))
await chargepoint_manager.start(websocket.user.id, cp)

0
app/schemas/__init__.py Normal file
View file

View file

@ -0,0 +1,31 @@
from datetime import datetime
from typing import Optional
from uuid import UUID
from pydantic import BaseModel
from app.schemas.connector import Connector
class ChargePointBase(BaseModel):
friendly_name: str
is_active: bool
class ChargePointUpdate(BaseModel):
friendly_name: Optional[str] = None
is_active: Optional[bool] = None
class ChargePointCreate(ChargePointBase):
pass
class ChargePoint(ChargePointBase):
id: UUID
last_seen: datetime | None
connectors: list[Connector] = []
class Config:
from_attributes = True
class ChargePointPassword(BaseModel):
password: str
class ChargePointConnectionInfo(BaseModel):
connected: bool

View file

@ -1,4 +1,5 @@
import enum
from uuid import UUID
from pydantic import BaseModel
class ConnectorStatus(enum.Enum):
@ -9,7 +10,7 @@ class ConnectorStatus(enum.Enum):
FAULTED = "Faulted"
class Connector(BaseModel):
id: str
id: UUID
evse: int
index: int
status: ConnectorStatus

View file

@ -1,6 +1,7 @@
from uuid import UUID
from pydantic import BaseModel
from schemas.user import User
from app.schemas.user import User
class IdTokenBase(BaseModel):
title: str
@ -10,7 +11,7 @@ class IdTokenCreate(IdTokenBase):
pass
class IdToken(IdTokenBase):
id: str
id: UUID
owner: User
class Config:

View file

@ -1,5 +1,6 @@
from datetime import datetime
import enum
from uuid import UUID
from pydantic import BaseModel
class PhaseType(enum.Enum):
@ -42,7 +43,7 @@ class Measurand(enum.Enum):
VOLTAGE = "Voltage"
class MeterValue(BaseModel):
id: str
id: UUID
timestamp: datetime
measurand: Measurand
phase_type: PhaseType

View file

@ -1,5 +1,6 @@
from datetime import datetime
import enum
from uuid import UUID
from pydantic import BaseModel
class TransactionStatus(enum.Enum):
@ -30,7 +31,7 @@ class TransactionEventTriggerReason(enum.Enum):
RESET_COMMAND = "ResetCommand"
class Transaction(BaseModel):
id: str
id: UUID
status: TransactionStatus
started_at: datetime
ended_at: datetime

View file

@ -1,6 +1,7 @@
from uuid import UUID
from pydantic import BaseModel
from schemas.id_token import IdToken
from app.schemas.id_token import IdToken
class UserBase(BaseModel):
friendly_name: str
@ -10,7 +11,7 @@ class UserCreate(UserBase):
pass
class User(UserBase):
id: str
id: UUID
id_tokens: list[IdToken] = []
class Config:

0
app/util/__init__.py Normal file
View file

View file

@ -1,9 +1,13 @@
import base64
import binascii
from uuid import UUID
from starlette.authentication import (
AuthCredentials, AuthenticationBackend, AuthenticationError, SimpleUser
)
from app.database import SessionLocal
from app.models.chargepoint import ChargePoint
class BasicAuthBackend(AuthenticationBackend):
async def authenticate(self, conn):
if "Authorization" not in conn.headers:
@ -19,5 +23,16 @@ class BasicAuthBackend(AuthenticationBackend):
raise AuthenticationError('Invalid basic auth credentials')
username, _, password = decoded.partition(":")
# TODO: You'd want to verify the username and password here.
return AuthCredentials(["authenticated"]), SimpleUser(username)
try:
id = UUID(username)
except (ValueError) as exc:
raise AuthenticationError('Invalid basic auth credentials')
with SessionLocal() as db:
chargepoint = db.query(ChargePoint).filter(ChargePoint.id == id).first()
if chargepoint is None:
raise AuthenticationError('Invalid basic auth credentials')
if chargepoint.password != password:
raise AuthenticationError('Invalid basic auth credentials')
return AuthCredentials(["authenticated"]), chargepoint

View file

@ -1,14 +0,0 @@
from sqlalchemy import Column, Enum, ForeignKey, Integer, String
from schemas.connector import ConnectorStatus
from database import Base
class Connector(Base):
__tablename__ = "connectors"
id = Column(String, primary_key=True)
evse = Column(Integer)
index = Column(Integer)
status = Column(Enum(ConnectorStatus))
chargepoint_id = Column(String, ForeignKey("chargepoints.id"))

View file

@ -1,14 +0,0 @@
from sqlalchemy import Boolean, Column, ForeignKey, String
from sqlalchemy.orm import relationship
from database import Base
class IdToken(Base):
__tablename__ = "id_tokens"
id = Column(String, primary_key=True)
title = Column(String)
is_active = Column(Boolean, default=True)
owner_id = Column(String, ForeignKey("users.id"))
owner = relationship("User", back_populates="id_tokens")

View file

@ -1,16 +0,0 @@
from sqlalchemy import Column, DateTime, Enum, Float, ForeignKey, String
from database import Base
from schemas.meter_value import Measurand, PhaseType
class Transaction(Base):
__tablename__ = "meter_values"
id = Column(String, primary_key=True)
timestamp = Column(DateTime, index=True)
measurand = Column(Enum(Measurand))
phase_type = Column(Enum(PhaseType))
unit = Column(String)
value = Column(Float)
transaction_id = Column(String, ForeignKey("transactions.id", index=True))

View file

@ -1,19 +0,0 @@
import enum
from sqlalchemy import Column, DateTime, Enum, Float, ForeignKey, String
from schemas.transaction import TransactionEventTriggerReason, TransactionStatus
from database import Base
class Transaction(Base):
__tablename__ = "transactions"
id = Column(String, primary_key=True)
status = Column(Enum(TransactionStatus), index=True)
started_at = Column(DateTime, index=True)
ended_at = Column(DateTime, nullable=True, index=True)
meter_start = Column(Float)
meter_end = Column(Float, nullable=True)
end_reason = Column(Enum(TransactionEventTriggerReason))
connector_id = Column(String, ForeignKey("connectors.id", index=True))
id_token_id = Column(String, ForeignKey("id_tokens.id", index= True))

View file

@ -1,12 +0,0 @@
from sqlalchemy import Boolean, Column, String
from sqlalchemy.orm import relationship
from database import Base
class User(Base):
__tablename__ = "users"
id = Column(String, primary_key=True)
friendly_name = Column(String, unique=True, index=True)
is_active = Column(Boolean, default=True)
id_tokens = relationship("IdToken", back_populates="owner")

View file

@ -1,33 +0,0 @@
from datetime import datetime, UTC
import os
from ocpp.routing import on
from ocpp.v201 import ChargePoint as cp
from ocpp.v201 import call_result
from ocpp.v201.enums import Action, RegistrationStatusType, AuthorizationStatusType
class ChargePoint(cp):
@on(Action.BootNotification)
async def on_boot_notification(self, charging_station, reason, **kwargs):
return call_result.BootNotificationPayload(
current_time=datetime.now(UTC).isoformat(),
interval=int(os.getenv("CS_HEARTBEAT_INTERVAL", "1800")),
status=RegistrationStatusType.accepted
)
@on(Action.Heartbeat)
async def on_heartbeat_request(self):
return call_result.HeartbeatPayload(
current_time=datetime.now(UTC).isoformat()
)
@on(Action.StatusNotification)
async def on_status_notification(self, evse_id, connector_id, connector_status, **kwargs):
return call_result.StatusNotificationPayload()
@on(Action.Authorize)
async def on_authorize(self):
return call_result.AuthorizePayload(id_token_info={'status': AuthorizationStatusType.accepted, 'groupIdToken': ""})
@on(Action.TransactionEvent)
async def on_transaction_event(self):
return call_result.TransactionEventPayload()

View file

@ -1,45 +0,0 @@
from fastapi import APIRouter, Security
from ocpp_proto import chargepoint_manager
from schemas.chargepoint import ChargePoint
from security import get_api_key
router = APIRouter(
prefix="/chargepoint",
tags=["chargepoint"],
)
@router.get(path="/", response_model=list[ChargePoint])
async def get_chargepoints(api_key: str = Security(get_api_key)):
return
@router.post(path="/", response_model=ChargePoint)
async def create_chargepoint(api_key: str = Security(get_api_key)):
return
@router.get(path="/{chargepoint_id}", response_model=ChargePoint)
async def get_chargepoint(
chargepoint_id: str,
api_key: str = Security(get_api_key),
):
return
@router.patch(path="/{chargepoint_id}", response_model=ChargePoint)
async def update_chargepoint(
chargepoint_id: str,
api_key: str = Security(get_api_key),
):
return
@router.delete(path="/{chargepoint_id}", response_model=[])
async def delete_chargepoint(
chargepoint_id: str,
api_key: str = Security(get_api_key),
):
return
@router.get(path="/{chargepoint_id}/status", response_model=bool)
async def get_chargepoint(
chargepoint_id: str,
api_key: str = Security(get_api_key),
):
return chargepoint_manager.is_connected(chargepoint_id)

View file

@ -1,20 +0,0 @@
from datetime import datetime
from pydantic import BaseModel
from schemas.connector import Connector
class ChargePointBase(BaseModel):
friendly_name: str
is_active: bool
class ChargePointCreate(ChargePointBase):
pass
class ChargePoint(ChargePointBase):
id: str
password: str
last_seen: datetime
connectors: list[Connector] = []
class Config:
from_attributes = True