Compare commits

...
Sign in to create a new pull request.

8 commits

Author SHA1 Message Date
f0eff338ff
Add possibility for signed firmware updates
All checks were successful
ci/woodpecker/push/docker Pipeline was successful
ci/woodpecker/cron/docker Pipeline was successful
2025-03-23 18:54:48 +00:00
b59aeeb5e5
Add firmware update logic 2025-03-23 18:54:36 +00:00
486977f828
Fix id token get endpoint
All checks were successful
ci/woodpecker/push/docker Pipeline was successful
ci/woodpecker/cron/docker Pipeline was successful
2025-03-14 00:19:22 +00:00
65183e26c0
Fix logout endpoint
All checks were successful
ci/woodpecker/push/docker Pipeline was successful
2025-03-14 00:11:39 +00:00
46b5040099
Fix user limiting filter condition
All checks were successful
ci/woodpecker/push/docker Pipeline was successful
2025-03-14 00:03:12 +00:00
e5c5e94989
Load dotenv in cli.py before model import
All checks were successful
ci/woodpecker/push/docker Pipeline was successful
2025-03-13 23:36:57 +00:00
44e6f85da1
Fix migration for PostgreSQL 2025-03-13 23:36:21 +00:00
50972c209e
Check if database URL is set in env 2025-03-13 23:09:36 +00:00
16 changed files with 310 additions and 12 deletions

View file

@ -9,6 +9,7 @@ from typing import Sequence, Union
from alembic import op from alembic import op
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
@ -31,16 +32,23 @@ def upgrade() -> None:
) )
op.create_index(op.f('ix_sessions_refresh_token'), 'sessions', ['refresh_token'], unique=True) op.create_index(op.f('ix_sessions_refresh_token'), 'sessions', ['refresh_token'], unique=True)
op.create_index(op.f('ix_sessions_user_id'), 'sessions', ['user_id'], unique=False) op.create_index(op.f('ix_sessions_user_id'), 'sessions', ['user_id'], unique=False)
op.add_column('users', sa.Column('email', sa.String(), nullable=True)) op.add_column('users', sa.Column('email', sa.String(), nullable=True))
op.add_column('users', sa.Column('password', sa.String(), nullable=True)) op.add_column('users', sa.Column('password', sa.String(), nullable=True))
op.add_column('users', sa.Column('role', sa.Enum('MEMBER', 'ADMINISTRATOR', name='role'), nullable=True))
role_enum = postgresql.ENUM('MEMBER', 'ADMINISTRATOR', name='role')
role_enum.create(op.get_bind(), checkfirst=False)
op.add_column('users', sa.Column('role', type_=role_enum, nullable=True))
op.execute('UPDATE users SET email = id || \'@example.com\'') op.execute('UPDATE users SET email = id || \'@example.com\'')
op.execute('UPDATE users SET password = \'invalid\'') op.execute('UPDATE users SET password = \'invalid\'')
op.execute('UPDATE users SET role = \'MEMBER\'') op.execute('UPDATE users SET role = \'MEMBER\'')
with op.batch_alter_table('users', schema=None) as batch_op: with op.batch_alter_table('users', schema=None) as batch_op:
batch_op.alter_column('email', nullable=False) batch_op.alter_column('email', nullable=False)
batch_op.alter_column('password', nullable=False) batch_op.alter_column('password', nullable=False)
batch_op.alter_column('role', nullable=False) batch_op.alter_column('role', nullable=False)
op.drop_index('ix_users_friendly_name', table_name='users') op.drop_index('ix_users_friendly_name', table_name='users')
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True) op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
# ### end Alembic commands ### # ### end Alembic commands ###

View file

@ -0,0 +1,50 @@
"""Add firmware_update table
Revision ID: 00edfb13e611
Revises: c7f72154c90b
Create Date: 2025-03-23 14:01:14.029527+00:00
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '00edfb13e611'
down_revision: Union[str, None] = 'c7f72154c90b'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('firmware_updates',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('request_id', sa.Integer(), nullable=True),
sa.Column('status', sa.Enum('CREATED', 'SUBMITTED', 'DOWNLOADED', 'DOWNLOAD_FAILED', 'DOWNLOADING', 'DOWNLOAD_SCHEDULED', 'DOWNLOAD_PAUSED', 'IDLE', 'INSTALLATION_FAILED', 'INSTALLING', 'INSTALLED', 'INSTALL_REBOOTING', 'INSTALL_SCHEDULED', 'INSTALL_VERIFICATION_FAILED', 'INVALID_SIGNATURE', 'SIGNATURE_VERIFIED', name='firmwareupdatestatus'), nullable=True),
sa.Column('retries', sa.Integer(), nullable=True),
sa.Column('retry_interval', sa.Integer(), nullable=True),
sa.Column('location', sa.String(), nullable=True),
sa.Column('retrieve_date_time', sa.DateTime(), nullable=True),
sa.Column('install_date_time', sa.DateTime(), nullable=True),
sa.Column('chargepoint_id', sa.Uuid(), nullable=True),
sa.ForeignKeyConstraint(['chargepoint_id'], ['chargepoints.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_firmware_updates_chargepoint_id'), 'firmware_updates', ['chargepoint_id'], unique=False)
op.alter_column('users', 'is_active',
existing_type=sa.BOOLEAN(),
nullable=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('users', 'is_active',
existing_type=sa.BOOLEAN(),
nullable=True)
op.drop_index(op.f('ix_firmware_updates_chargepoint_id'), table_name='firmware_updates')
op.drop_table('firmware_updates')
# ### end Alembic commands ###

View file

@ -0,0 +1,32 @@
"""Add signature fields to firmware_update table
Revision ID: 506cc8d086c9
Revises: 00edfb13e611
Create Date: 2025-03-23 14:49:42.662564+00:00
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '506cc8d086c9'
down_revision: Union[str, None] = '00edfb13e611'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('firmware_updates', sa.Column('signing_certificate', sa.Text(), nullable=True))
op.add_column('firmware_updates', sa.Column('signature', sa.String(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('firmware_updates', 'signature')
op.drop_column('firmware_updates', 'signing_certificate')
# ### end Alembic commands ###

View file

@ -10,10 +10,9 @@ from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from argon2 import PasswordHasher from argon2 import PasswordHasher
from app.models import *
load_dotenv() load_dotenv()
from app.models import *
from app.database import SessionLocal from app.database import SessionLocal
def __get_user_by_email(db: Session, email: str): def __get_user_by_email(db: Session, email: str):

View file

@ -3,7 +3,10 @@ from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
SQLALCHEMY_DATABASE_URL = os.getenv("CS_DATABASE_URL", "sqlite:///./simple-ocpp-cs.db") if os.getenv("CS_DATABASE_URL", "invalid") == "invalid":
raise SystemExit('ERROR: Invalid CS_DATABASE_URL environment variable')
SQLALCHEMY_DATABASE_URL = os.getenv("CS_DATABASE_URL")
if SQLALCHEMY_DATABASE_URL.startswith("sqlite"): if SQLALCHEMY_DATABASE_URL.startswith("sqlite"):
engine = create_engine( engine = create_engine(

View file

@ -28,6 +28,7 @@ def create_ocpp_app():
def create_app(): def create_app():
app = FastAPI( app = FastAPI(
title="simple-ocpp-cs", title="simple-ocpp-cs",
version="0.2.0",
summary="Simple implementation of a basic OCPP 2.0.1 compliant central system (backend) for EV charging stations", summary="Simple implementation of a basic OCPP 2.0.1 compliant central system (backend) for EV charging stations",
responses={404: {"description": "Not found"}}, responses={404: {"description": "Not found"}},
) )

View file

@ -2,6 +2,7 @@ __all__ = [
"chargepoint_variable", "chargepoint_variable",
"chargepoint", "chargepoint",
"connector", "connector",
"firmware_update",
"id_token", "id_token",
"meter_value", "meter_value",
"session", "session",

View file

@ -0,0 +1,22 @@
import uuid
from sqlalchemy import Column, DateTime, Enum, ForeignKey, Integer, String, Text, Uuid
from app.database import Base
from app.schemas.firmware_update import FirmwareUpdateStatus
class FirmwareUpdate(Base):
__tablename__ = "firmware_updates"
id = Column(Uuid, primary_key=True, default=uuid.uuid4)
request_id = Column(Integer)
status = Column(Enum(FirmwareUpdateStatus))
retries = Column(Integer)
retry_interval = Column(Integer)
location = Column(String)
retrieve_date_time = Column(DateTime)
install_date_time = Column(DateTime, nullable=True)
signing_certificate = Column(Text, nullable=True)
signature = Column(String, nullable=True)
chargepoint_id = Column(Uuid, ForeignKey("chargepoints.id"), index=True)

View file

@ -8,7 +8,7 @@ class Session(Base):
id = Column(Uuid, primary_key=True, default=uuid.uuid4) id = Column(Uuid, primary_key=True, default=uuid.uuid4)
name = Column(String) name = Column(String)
refresh_token = Column(String, unique=True, index=True) refresh_token = Column(String, nullable=False, unique=True, index=True)
last_used = Column(DateTime(timezone=True)) last_used = Column(DateTime(timezone=True))
user_id = Column(Uuid, ForeignKey("users.id"), index=True) user_id = Column(Uuid, ForeignKey("users.id"), nullable=False, index=True)

View file

@ -8,6 +8,7 @@ from ocpp.v201.enums import Action, RegistrationStatusEnumType, TransactionEvent
from ocpp.v201.call import GetBaseReport from ocpp.v201.call import GetBaseReport
from app.services import ( from app.services import (
firmware_service,
variable_service, variable_service,
id_token_service, id_token_service,
chargepoint_service, chargepoint_service,
@ -109,6 +110,11 @@ class ChargePoint(cp):
else: else:
return call_result.TransactionEvent(id_token_info=id_token_info) return call_result.TransactionEvent(id_token_info=id_token_info)
@on(Action.firmware_status_notification)
async def on_firmware_status_notification(self, status, request_id, **kwargs):
await firmware_service.update_firmware_status(self.id, request_id, status)
return call_result.FirmwareStatusNotification()
@on(Action.meter_values) @on(Action.meter_values)
async def on_meter_values(self, **kwargs): async def on_meter_values(self, **kwargs):
return call_result.MeterValues() return call_result.MeterValues()

View file

@ -45,7 +45,7 @@ async def logout(
Remove the current session based on the access token, effectively invalidating the current refresh token. Remove the current session based on the access token, effectively invalidating the current refresh token.
""" """
await session_service.remove_session( await session_service.remove_session(
db=db, id=UUID(token.session), initiator=f"user:{token.subject}" db=db, id=UUID(token.session)
) )
return list() return list()

View file

@ -28,10 +28,13 @@ from app.schemas.chargepoint_variable import (
MutabilityType, MutabilityType,
SetVariableStatusType SetVariableStatusType
) )
from app.schemas.firmware_update import FirmwareUpdate, FirmwareUpdateCreate, FirmwareUpdateSubmissionResponse
from app.models.chargepoint import ChargePoint as DbChargePoint from app.models.chargepoint import ChargePoint as DbChargePoint
from app.models.user import User as DbUser from app.models.user import User as DbUser
from app.models.chargepoint_variable import ChargepointVariable as DbChargepointVariable from app.models.chargepoint_variable import ChargepointVariable as DbChargepointVariable
from app.models.firmware_update import FirmwareUpdate as DbFirmwareUpdate
from app.security.jwt_bearer import JWTBearer from app.security.jwt_bearer import JWTBearer
from app.services import firmware_service
router = APIRouter( router = APIRouter(
prefix="/chargepoints", prefix="/chargepoints",
@ -293,3 +296,67 @@ async def update_chargepoint_variable(
return ChargepointVariableResponse(status=status) return ChargepointVariableResponse(status=status)
except TimeoutError: except TimeoutError:
raise HTTPException(status_code=503, detail="Chargepoint didn't respond in time.") raise HTTPException(status_code=503, detail="Chargepoint didn't respond in time.")
@router.get(path="/{chargepoint_id}/firmware-updates", response_model=list[FirmwareUpdate])
async def get_firmware_updates(
chargepoint_id: UUID,
db: Session = Depends(get_db),
token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])),
):
chargepoint = db.get(DbChargePoint, chargepoint_id)
if chargepoint is None:
raise HTTPException(status_code=404, detail="Chargepoint not found")
firmware_updates = db.query(DbFirmwareUpdate).filter(
DbFirmwareUpdate.chargepoint_id == chargepoint_id
).all()
return firmware_updates
@router.get(path="/{chargepoint_id}/firmware-updates/{firmware_update_id}", response_model=FirmwareUpdate)
async def get_firmware_update(
chargepoint_id: UUID,
firmware_update_id: UUID,
db: Session = Depends(get_db),
token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])),
):
chargepoint = db.get(DbChargePoint, chargepoint_id)
if chargepoint is None:
raise HTTPException(status_code=404, detail="Chargepoint not found")
firmware_update = db.query(DbFirmwareUpdate).filter(
DbFirmwareUpdate.chargepoint_id == chargepoint_id,
DbFirmwareUpdate.id == firmware_update_id
).first()
if firmware_update is None:
raise HTTPException(status_code=404, detail="FirmwareUpdate not found")
return firmware_update
@router.post(path="/{chargepoint_id}/firmware-updates", response_model=FirmwareUpdate)
async def create_firmware_update(
chargepoint_id: UUID,
firmware_update: FirmwareUpdateCreate,
db: Session = Depends(get_db),
token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])),
):
chargepoint = db.get(DbChargePoint, chargepoint_id)
if chargepoint is None:
raise HTTPException(status_code=404, detail="Chargepoint not found")
firmware_update = await firmware_service.create_firmware_update(chargepoint_id, firmware_update)
return firmware_update
@router.post(path="/{chargepoint_id}/firmware-updates/{firmware_update_id}/submit", response_model=ChargePointResetResponse)
async def submit_firmware_update(
chargepoint_id: UUID,
firmware_update_id: UUID,
token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])),
):
if chargepoint_manager.is_connected(chargepoint_id) == False:
raise HTTPException(status_code=503, detail="Chargepoint not connected.")
try:
_, status = await firmware_service.submit_firmware_update(firmware_update_id)
return FirmwareUpdateSubmissionResponse(status=status)
except TimeoutError:
raise HTTPException(status_code=503, detail="Chargepoint didn't respond in time.")

View file

@ -24,7 +24,7 @@ async def get_id_tokens(
db: Session = Depends(get_db), db: Session = Depends(get_db),
token: AccessToken = Depends(JWTBearer()), token: AccessToken = Depends(JWTBearer()),
): ):
stmt = select(Session) stmt = select(DbIdToken)
if token.role != Role.ADMINISTRATOR: if token.role != Role.ADMINISTRATOR:
stmt = stmt.where(DbIdToken.owner_id == token.subject) stmt = stmt.where(DbIdToken.owner_id == token.subject)
stmt = stmt.order_by(DbIdToken.id).offset(skip).limit(limit) stmt = stmt.order_by(DbIdToken.id).offset(skip).limit(limit)
@ -42,7 +42,7 @@ async def get_id_token(
id_token = result.scalars().first() id_token = result.scalars().first()
if id_token == None: if id_token == None:
raise HTTPException(status_code=404, detail="IdToken not found") raise HTTPException(status_code=404, detail="IdToken not found")
if token.role != Role.ADMINISTRATOR & id_token.owner_id != token.subject: if token.role != Role.ADMINISTRATOR and id_token.owner_id != token.subject:
raise HTTPException(status_code=404, detail="IdToken not found") raise HTTPException(status_code=404, detail="IdToken not found")
return id_token return id_token

View file

@ -44,7 +44,7 @@ async def get_transaction(
transaction = result.scalars().first() transaction = result.scalars().first()
if transaction == None: if transaction == None:
raise HTTPException(404, "Transaction not found") raise HTTPException(404, "Transaction not found")
if token.role != Role.ADMINISTRATOR & transaction.user_id != token.subject: if token.role != Role.ADMINISTRATOR and transaction.user_id != token.subject:
raise HTTPException(404, "Transaction not found") raise HTTPException(404, "Transaction not found")
return transaction return transaction
@ -59,7 +59,7 @@ async def get_transaction_meter_values(
transaction = result.scalars().first() transaction = result.scalars().first()
if transaction == None: if transaction == None:
raise HTTPException(404, "Transaction not found") raise HTTPException(404, "Transaction not found")
if token.role != Role.ADMINISTRATOR & transaction.user_id != token.subject: if token.role != Role.ADMINISTRATOR and transaction.user_id != token.subject:
raise HTTPException(404, "Transaction not found") raise HTTPException(404, "Transaction not found")
stmt = select(DbMeterValue).where(DbMeterValue.transaction_id == transaction_id).order_by(DbMeterValue.timestamp) stmt = select(DbMeterValue).where(DbMeterValue.transaction_id == transaction_id).order_by(DbMeterValue.timestamp)
result = db.execute(stmt) result = db.execute(stmt)
@ -76,7 +76,7 @@ async def remote_stop_transaction(
transaction = result.scalars().first() transaction = result.scalars().first()
if transaction == None: if transaction == None:
raise HTTPException(404, "Transaction not found") raise HTTPException(404, "Transaction not found")
if token.role != Role.ADMINISTRATOR & transaction.user_id != token.subject: if token.role != Role.ADMINISTRATOR and transaction.user_id != token.subject:
raise HTTPException(404, "Transaction not found") raise HTTPException(404, "Transaction not found")
if transaction.status != TransactionStatus.ONGOING: if transaction.status != TransactionStatus.ONGOING:
raise HTTPException(status_code=422, detail=[{ raise HTTPException(status_code=422, detail=[{

View file

@ -0,0 +1,43 @@
from datetime import datetime
import enum
from typing import Optional
from uuid import UUID
from pydantic import BaseModel
class FirmwareUpdateStatus(enum.Enum):
CREATED = "xCreated"
SUBMITTED = "xSubmitted"
DOWNLOADED = "Downloaded"
DOWNLOAD_FAILED = "DownloadFailed"
DOWNLOADING = "Downloading"
DOWNLOAD_SCHEDULED = "DownloadScheduled"
DOWNLOAD_PAUSED = "DownloadPaused"
IDLE = "Idle"
INSTALLATION_FAILED = "InstallationFailed"
INSTALLING = "Installing"
INSTALLED = "Installed"
INSTALL_REBOOTING = "InstallRebooting"
INSTALL_SCHEDULED = "InstallScheduled"
INSTALL_VERIFICATION_FAILED = "InstallVerificationFailed"
INVALID_SIGNATURE = "InvalidSignature"
SIGNATURE_VERIFIED = "SignatureVerified"
class FirmwareUpdateBase(BaseModel):
retries: int
retry_interval: int
location: str
retrieve_date_time: datetime
install_date_time: Optional[datetime]
signing_certificate: Optional[str]
signature: Optional[str]
class FirmwareUpdate(FirmwareUpdateBase):
id: UUID
request_id: int
status: FirmwareUpdateStatus
class FirmwareUpdateCreate(FirmwareUpdateBase):
pass
class FirmwareUpdateSubmissionResponse(BaseModel):
status: str

View file

@ -0,0 +1,66 @@
from uuid import UUID
from ocpp.v201.call import UpdateFirmware
from ocpp.v201.call_result import UpdateFirmware as UpdateFirmwareResult
from ocpp.v201.datatypes import FirmwareType
from app.database import SessionLocal
from app.models.chargepoint import ChargePoint
from app.models.firmware_update import FirmwareUpdate
from app.ocpp_proto import chargepoint_manager
from app.schemas.firmware_update import FirmwareUpdateCreate, FirmwareUpdateStatus
async def create_firmware_update(chargepoint_id: UUID, firmware_update: FirmwareUpdateCreate) -> FirmwareUpdate:
with SessionLocal() as db:
db_chargepoint = db.get(ChargePoint, chargepoint_id)
latest_firmware_update = db.query(FirmwareUpdate).filter(FirmwareUpdate.chargepoint_id == db_chargepoint.id).order_by(FirmwareUpdate.request_id.desc()).first()
new_request_id = latest_firmware_update.request_id + 1 if latest_firmware_update else 1
db_firmware_update = FirmwareUpdate(
request_id=new_request_id,
status=FirmwareUpdateStatus.CREATED,
retries=firmware_update.retries,
retry_interval=firmware_update.retry_interval,
location=firmware_update.location,
retrieve_date_time=firmware_update.retrieve_date_time,
install_date_time=firmware_update.install_date_time,
chargepoint_id=db_chargepoint.id,
signing_certificate=firmware_update.signing_certificate,
signature=firmware_update.signature
)
db.add(db_firmware_update)
db.commit()
db.refresh(db_firmware_update)
return db_firmware_update
async def submit_firmware_update(firmware_update_id: UUID) -> tuple[FirmwareUpdate, str]:
with SessionLocal() as db:
db_firmware_update = db.get(FirmwareUpdate, firmware_update_id)
try:
result: UpdateFirmwareResult = await chargepoint_manager.call(
db_firmware_update.chargepoint_id,
payload=UpdateFirmware(
request_id=db_firmware_update.request_id,
retries=db_firmware_update.retries,
retry_interval=db_firmware_update.retry_interval,
firmware=FirmwareType(
location=db_firmware_update.location,
retrieve_date_time=db_firmware_update.retrieve_date_time.isoformat(),
install_date_time=db_firmware_update.install_date_time.isoformat(),
signing_certificate=db_firmware_update.signing_certificate,
signature=db_firmware_update.signature
)
))
if result.status == "Accepted" or result.status == "AcceptedCanceled":
db_firmware_update.status = FirmwareUpdateStatus.SUBMITTED
db.commit()
return db_firmware_update, result.status
except TimeoutError as e:
raise e
async def update_firmware_status(chargepoint_identity: str, request_id: int, status: FirmwareUpdateStatus):
with SessionLocal() as db:
db_chargepoint = db.query(ChargePoint).filter(ChargePoint.identity == chargepoint_identity).first()
db_firmware_update = db.query(FirmwareUpdate).filter(FirmwareUpdate.chargepoint_id == db_chargepoint.id).filter(FirmwareUpdate.request_id == request_id).first()
db_firmware_update.status = FirmwareUpdateStatus(status)
db.commit()