From b59aeeb5e5f9fd0f62f780152f59f2fb47387da1 Mon Sep 17 00:00:00 2001 From: Bluemedia Date: Fri, 21 Mar 2025 16:40:49 +0000 Subject: [PATCH] Add firmware update logic --- ..._00edfb13e611-add_firmware_update_table.py | 50 ++++++++++++++ backend/app/models/__init__.py | 1 + backend/app/models/firmware_update.py | 20 ++++++ backend/app/models/session.py | 4 +- backend/app/ocpp_proto/chargepoint.py | 6 ++ backend/app/routers/chargepoint_v1.py | 67 +++++++++++++++++++ backend/app/schemas/firmware_update.py | 42 ++++++++++++ backend/app/services/firmware_service.py | 62 +++++++++++++++++ 8 files changed, 250 insertions(+), 2 deletions(-) create mode 100644 backend/alembic/versions/20250323_00edfb13e611-add_firmware_update_table.py create mode 100644 backend/app/models/firmware_update.py create mode 100644 backend/app/schemas/firmware_update.py create mode 100644 backend/app/services/firmware_service.py diff --git a/backend/alembic/versions/20250323_00edfb13e611-add_firmware_update_table.py b/backend/alembic/versions/20250323_00edfb13e611-add_firmware_update_table.py new file mode 100644 index 0000000..39c447b --- /dev/null +++ b/backend/alembic/versions/20250323_00edfb13e611-add_firmware_update_table.py @@ -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 ### diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 26e5c03..f7b338d 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -2,6 +2,7 @@ __all__ = [ "chargepoint_variable", "chargepoint", "connector", + "firmware_update", "id_token", "meter_value", "session", diff --git a/backend/app/models/firmware_update.py b/backend/app/models/firmware_update.py new file mode 100644 index 0000000..96d4f94 --- /dev/null +++ b/backend/app/models/firmware_update.py @@ -0,0 +1,20 @@ +import uuid +from sqlalchemy import Column, DateTime, Enum, ForeignKey, Integer, String, 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) + + chargepoint_id = Column(Uuid, ForeignKey("chargepoints.id"), index=True) diff --git a/backend/app/models/session.py b/backend/app/models/session.py index b1c2213..cd0c5ba 100644 --- a/backend/app/models/session.py +++ b/backend/app/models/session.py @@ -8,7 +8,7 @@ class Session(Base): id = Column(Uuid, primary_key=True, default=uuid.uuid4) 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)) - user_id = Column(Uuid, ForeignKey("users.id"), index=True) \ No newline at end of file + user_id = Column(Uuid, ForeignKey("users.id"), nullable=False, index=True) \ No newline at end of file diff --git a/backend/app/ocpp_proto/chargepoint.py b/backend/app/ocpp_proto/chargepoint.py index 88550a0..f9b8b48 100644 --- a/backend/app/ocpp_proto/chargepoint.py +++ b/backend/app/ocpp_proto/chargepoint.py @@ -8,6 +8,7 @@ from ocpp.v201.enums import Action, RegistrationStatusEnumType, TransactionEvent from ocpp.v201.call import GetBaseReport from app.services import ( + firmware_service, variable_service, id_token_service, chargepoint_service, @@ -108,6 +109,11 @@ class ChargePoint(cp): return call_result.TransactionEvent() else: 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) async def on_meter_values(self, **kwargs): diff --git a/backend/app/routers/chargepoint_v1.py b/backend/app/routers/chargepoint_v1.py index ff22bfd..b96e29d 100644 --- a/backend/app/routers/chargepoint_v1.py +++ b/backend/app/routers/chargepoint_v1.py @@ -28,10 +28,13 @@ from app.schemas.chargepoint_variable import ( MutabilityType, SetVariableStatusType ) +from app.schemas.firmware_update import FirmwareUpdate, FirmwareUpdateCreate, FirmwareUpdateSubmissionResponse from app.models.chargepoint import ChargePoint as DbChargePoint from app.models.user import User as DbUser 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.services import firmware_service router = APIRouter( prefix="/chargepoints", @@ -293,3 +296,67 @@ async def update_chargepoint_variable( return ChargepointVariableResponse(status=status) except TimeoutError: 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: + firmware_update, status = await firmware_service.submit_firmware_update(firmware_update_id) + return FirmwareUpdateSubmissionResponse(firmware_update, status) + except TimeoutError: + raise HTTPException(status_code=503, detail="Chargepoint didn't respond in time.") diff --git a/backend/app/schemas/firmware_update.py b/backend/app/schemas/firmware_update.py new file mode 100644 index 0000000..9436810 --- /dev/null +++ b/backend/app/schemas/firmware_update.py @@ -0,0 +1,42 @@ +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] + +class FirmwareUpdate(FirmwareUpdateBase): + id: UUID + request_id: int + status: FirmwareUpdateStatus + +class FirmwareUpdateCreate(FirmwareUpdateBase): + pass + +class FirmwareUpdateSubmissionResponse(BaseModel): + firmware_update: FirmwareUpdate + status: str diff --git a/backend/app/services/firmware_service.py b/backend/app/services/firmware_service.py new file mode 100644 index 0000000..0a93ba4 --- /dev/null +++ b/backend/app/services/firmware_service.py @@ -0,0 +1,62 @@ +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 + ) + 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() + ) + )) + 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 = status + db.commit()