Compare commits

..

No commits in common. "main" and "feature/nats" have entirely different histories.

126 changed files with 1556 additions and 9623 deletions

View file

@ -1,24 +0,0 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
// README at: https://github.com/devcontainers/templates/tree/main/src/javascript-node
{
"name": "Python 3.12",
"dockerComposeFile": "docker-compose.yml",
"service": "app",
"workspaceFolder": "/simple-ocpp-cs",
"shutdownAction": "stopCompose",
// Use 'forwardPorts' to make a list of ports inside the container available locally.
"forwardPorts": [
8000
],
// Configure tool-specific properties.
"customizations": {
"vscode": {
"extensions": [
"eamodio.gitlens"
]
}
}
}

View file

@ -1,19 +0,0 @@
services:
app:
image: mcr.microsoft.com/devcontainers/python:3.13-bookworm
volumes:
- ../..:/simple-ocpp-cs:cached
command: sleep infinity
db:
image: postgres:17
restart: unless-stopped
volumes:
- postgres-data:/var/lib/postgresql/data
environment:
POSTGRES_USER: postgres
POSTGRES_DB: postgres
POSTGRES_PASSWORD: postgres
volumes:
postgres-data:

View file

@ -1,28 +0,0 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
// README at: https://github.com/devcontainers/templates/tree/main/src/javascript-node
{
"name": "Node.js",
"dockerComposeFile": "docker-compose.yml",
"service": "app",
"workspaceFolder": "/simple-ocpp-cs",
"shutdownAction": "stopCompose",
// Use 'forwardPorts' to make a list of ports inside the container available locally.
"forwardPorts": [
5173
],
// Configure tool-specific properties.
"customizations": {
"vscode": {
"extensions": [
"svelte.svelte-vscode",
"Lokalise.i18n-ally",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"eamodio.gitlens"
]
}
}
}

View file

@ -1,6 +0,0 @@
services:
app:
image: mcr.microsoft.com/devcontainers/javascript-node:3-22-bookworm
volumes:
- ../..:/simple-ocpp-cs:cached
command: sleep infinity

View file

@ -1,4 +1,3 @@
**/__pycache__
simple-ocpp-cs.db
.env
static
.env

View file

@ -5,13 +5,10 @@ when:
steps:
docker:
image: woodpeckerci/plugin-docker-buildx
secrets: [docker_username, docker_password]
settings:
pull_image: true
registry: git.bluemedia.dev
repo: git.bluemedia.dev/bluemedia/simple-ocpp-cs
username:
from_secret: docker_username
password:
from_secret: docker_password
platforms: linux/amd64
tag: latest

View file

@ -1,13 +1,5 @@
# Build frontend
FROM node:22-alpine AS frontend-builder
COPY frontend /usr/src/frontend
WORKDIR /usr/src/frontend
RUN npm ci
RUN npm run build
# `python-base` sets up all our shared environment variables
FROM python:3.13-slim AS python-base
FROM python:3.11-slim as python-base
# python
ENV PYTHONUNBUFFERED=1 \
@ -40,7 +32,7 @@ ENV PATH="$POETRY_HOME/bin:$VENV_PATH/bin:$PATH"
# `builder-base` stage is used to build deps + create our virtual environment
FROM python-base AS builder-base
FROM python-base as builder-base
RUN apt-get update \
&& apt-get install --no-install-recommends -y \
# deps for installing poetry
@ -53,23 +45,18 @@ RUN curl -sSL https://install.python-poetry.org | python3 -
# copy project requirement files here to ensure they will be cached.
WORKDIR $PYSETUP_PATH
COPY backend/poetry.lock backend/pyproject.toml ./
COPY poetry.lock pyproject.toml ./
# install runtime deps - uses $POETRY_VIRTUALENVS_IN_PROJECT internally
RUN poetry install
RUN poetry install --no-dev
# `production` image used for runtime
FROM python-base AS production
FROM python-base as production
ENV FASTAPI_ENV=production
COPY --from=builder-base $PYSETUP_PATH $PYSETUP_PATH
COPY backend/alembic.ini /usr/src/
COPY alembic.ini /usr/src/
COPY start.sh /usr/src/
COPY backend/alembic /usr/src/alembic
COPY backend/app /usr/src/app
COPY --from=frontend-builder /usr/src/frontend/build /usr/src/static
COPY ./alembic /usr/src/alembic
COPY ./app /usr/src/app
WORKDIR /usr/src
CMD ["bash", "start.sh"]

View file

@ -1,6 +1,6 @@
# LibreCharge
# Simple OCCP Central System
LibreCharge is a simple implementation of a basic [OCPP](https://openchargealliance.org/protocols/open-charge-point-protocol/) 2.0.1 compliant central system (backend) for EV charging stations.
This is a simple implementation of a basic [OCPP](https://openchargealliance.org/protocols/open-charge-point-protocol/) 2.0.1 compliant central system (backend) for EV charging stations.
## Features
@ -12,7 +12,5 @@ LibreCharge is a simple implementation of a basic [OCPP](https://openchargeallia
- ✅ RESTful API
## Tested charging stations
This project has been successfully tested with the following charging stations:
- Alfen Eve Single S-line

View file

@ -1,12 +1,9 @@
from logging.config import fileConfig
import os
from dotenv import load_dotenv
from sqlalchemy import create_engine
from alembic import context
load_dotenv()
# Import models for autogenerate support
from app.database import Base
from app.models import *

View file

@ -3,10 +3,7 @@ from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
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")
SQLALCHEMY_DATABASE_URL = os.getenv("CS_DATABASE_URL", "sqlite:///./simple-ocpp-cs.db")
if SQLALCHEMY_DATABASE_URL.startswith("sqlite"):
engine = create_engine(

40
app/main.py Normal file
View file

@ -0,0 +1,40 @@
from dotenv import load_dotenv
from fastapi import FastAPI
from starlette.middleware.authentication import AuthenticationMiddleware
load_dotenv()
from app.routers import (
chargepoint_v1,
id_token_v1,
meter_value_v1,
ocpp_v1,
transaction_v1,
user_v1
)
from app.util.websocket_auth_backend import BasicAuthBackend
def create_ocpp_app():
app_ocpp = FastAPI(
responses={404: {"description": "Not found"}},
)
app_ocpp.include_router(ocpp_v1.router)
app_ocpp.add_middleware(AuthenticationMiddleware, backend=BasicAuthBackend())
return app_ocpp
def create_app():
app = FastAPI(
responses={404: {"description": "Not found"}},
)
app.include_router(chargepoint_v1.router, prefix="/v1")
app.include_router(id_token_v1.router, prefix="/v1")
app.include_router(user_v1.router, prefix="/v1")
app.include_router(meter_value_v1.router, prefix="/v1")
app.include_router(transaction_v1.router, prefix="/v1")
app.mount(path="/v1/ocpp", app=create_ocpp_app())
return app
app = create_app()

View file

@ -2,10 +2,8 @@ __all__ = [
"chargepoint_variable",
"chargepoint",
"connector",
"firmware_update",
"id_token",
"meter_value",
"session",
"transaction",
"user"
]

View file

@ -25,4 +25,3 @@ class ChargePoint(Base):
connectors = relationship("Connector", cascade="delete, delete-orphan")
transactions = relationship("Transaction", cascade="delete, delete-orphan")
variables = relationship("ChargepointVariable", cascade="delete, delete-orphan")
firmware_updates = relationship("FirmwareUpdate", cascade="delete, delete-orphan")

View file

@ -1,6 +1,5 @@
import uuid
from sqlalchemy import Uuid, Column, DateTime, Enum, Float, ForeignKey, String
from sqlalchemy.orm import relationship
from app.database import Base
from app.schemas.meter_value import Measurand, PhaseType
@ -15,5 +14,4 @@ class MeterValue(Base):
unit = Column(String, nullable=True)
value = Column(Float)
transaction_id = Column(String, ForeignKey("transactions.id"), index=True)
transaction = relationship("Transaction", back_populates="meter_values")
transaction_id = Column(String, ForeignKey("transactions.id"), index=True)

View file

@ -1,5 +1,4 @@
from sqlalchemy import String, Uuid, Column, DateTime, Enum, Numeric, ForeignKey
from sqlalchemy.orm import relationship
from app.schemas.transaction import TransactionEventTriggerReason, TransactionStatus
from app.database import Base
@ -12,14 +11,9 @@ class Transaction(Base):
started_at = Column(DateTime, index=True)
ended_at = Column(DateTime, nullable=True, index=True)
meter_start = Column(Numeric(10,2))
meter_end = Column(Numeric(10,2))
meter_end = Column(Numeric(10,2), nullable=True)
end_reason = Column(Enum(TransactionEventTriggerReason), nullable=True)
price = Column(Numeric(10,2))
user_id = Column(Uuid, ForeignKey("users.id"), nullable=True, index=True)
user = relationship("User", back_populates="transactions")
chargepoint_id = Column(Uuid, ForeignKey("chargepoints.id"), index=True)
chargepoint = relationship("ChargePoint", back_populates="transactions")
meter_values = relationship("MeterValue", cascade="delete, delete-orphan")

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

@ -0,0 +1,15 @@
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")
transactions = relationship("Transaction", cascade="delete, delete-orphan")

View file

@ -4,11 +4,10 @@ import os
from ocpp.routing import on, after
from ocpp.v201 import ChargePoint as cp
from ocpp.v201 import call_result
from ocpp.v201.enums import Action, RegistrationStatusEnumType, TransactionEventEnumType
from ocpp.v201.call import GetBaseReport
from ocpp.v201.enums import Action, RegistrationStatusType, TransactionEventType
from ocpp.v201.call import GetBaseReportPayload
from app.services import (
firmware_service,
variable_service,
id_token_service,
chargepoint_service,
@ -17,42 +16,42 @@ from app.services import (
class ChargePoint(cp):
@on(Action.boot_notification)
@on(Action.BootNotification)
async def on_boot_notification(self, charging_station, **kwargs):
await chargepoint_service.update_attributes(
chargepoint_identity=self.id,
charging_station=charging_station
)
return call_result.BootNotification(
return call_result.BootNotificationPayload(
current_time=datetime.now(UTC).isoformat(),
interval=int(os.getenv("CS_HEARTBEAT_INTERVAL", "1800")),
status=RegistrationStatusEnumType.accepted
status=RegistrationStatusType.accepted
)
@after(Action.boot_notification)
@after(Action.BootNotification)
async def after_boot_notification(self, **kwargs):
await self.call(payload=GetBaseReport(request_id=0, report_base="FullInventory"))
await self.call(payload=GetBaseReportPayload(request_id=0, report_base="FullInventory"))
@on(Action.notify_report)
@on(Action.NotifyReport)
async def on_notify_report(self, report_data, **kwargs):
for entry in report_data:
await variable_service.create_or_update_variable(
chargepoint_identity=self.id,
report_entry=entry
)
return call_result.NotifyReport()
return call_result.NotifyReportPayload()
@on(Action.heartbeat)
@on(Action.Heartbeat)
async def on_heartbeat_request(self):
return call_result.Heartbeat(
return call_result.HeartbeatPayload(
current_time=datetime.now(UTC).isoformat()
)
@after(Action.heartbeat)
@after(Action.Heartbeat)
async def after_heartbeat_request(self):
await chargepoint_service.update_last_seen(chargepoint_identity=self.id)
@on(Action.status_notification)
@on(Action.StatusNotification)
async def on_status_notification(self, evse_id: int, connector_id: int, connector_status: str, **kwargs):
await chargepoint_service.create_or_update_connector(
chargepoint_identity=self.id,
@ -60,14 +59,14 @@ class ChargePoint(cp):
connector_id=connector_id,
connector_status=connector_status
)
return call_result.StatusNotification()
return call_result.StatusNotificationPayload()
@on(Action.authorize)
@on(Action.Authorize)
async def on_authorize(self, id_token, **kwargs):
id_token_info, _ = await id_token_service.get_id_token_info(chargepoint_id=self.id, id_token=id_token)
return call_result.Authorize(id_token_info)
return call_result.AuthorizePayload(id_token_info)
@on(Action.transaction_event)
@on(Action.TransactionEvent)
async def on_transaction_event(
self,
event_type,
@ -83,7 +82,7 @@ class ChargePoint(cp):
id_token_info = None
token_owner_id = None
if event_type == str(TransactionEventEnumType.started):
if event_type == str(TransactionEventType.started):
await transaction_service.create_transaction(
chargepoint_identity=self.id,
user_id=token_owner_id,
@ -91,12 +90,12 @@ class ChargePoint(cp):
transaction_info=transaction_info,
transaction_data=kwargs
)
elif event_type == str(TransactionEventEnumType.updated):
elif event_type == str(TransactionEventType.updated):
await transaction_service.update_transaction(
transaction_id=transaction_info["transaction_id"],
transaction_data=kwargs
)
elif event_type == str(TransactionEventEnumType.ended):
elif event_type == str(TransactionEventType.ended):
await transaction_service.end_transaction(
transaction_id=transaction_info["transaction_id"],
timestamp=datetime.fromisoformat(timestamp),
@ -106,19 +105,14 @@ class ChargePoint(cp):
)
if id_token_info == None:
return call_result.TransactionEvent()
return call_result.TransactionEventPayload()
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()
return call_result.TransactionEventPayload(id_token_info=id_token_info)
@on(Action.meter_values)
@on(Action.MeterValues)
async def on_meter_values(self, **kwargs):
return call_result.MeterValues()
return call_result.MeterValuesPayload()
@on(Action.security_event_notification)
@on(Action.SecurityEventNotification)
async def on_security_event_notification(self, **kwargs):
return call_result.SecurityEventNotification()
return call_result.SecurityEventNotificationPayload()

View file

@ -3,7 +3,6 @@ from typing import Any, Coroutine, Dict
from uuid import UUID
from websockets import ConnectionClosed
from starlette.websockets import WebSocketDisconnect
from app.ocpp_proto.chargepoint import ChargePoint
@ -13,7 +12,7 @@ async def start(id: UUID, cp: ChargePoint):
try:
__active_connections[id] = cp
await cp.start()
except (ConnectionClosed, WebSocketDisconnect):
except ConnectionClosed:
logging.info("Charging station '%s' (%s) disconnected", cp.id, id)
__active_connections.pop(id, None)

View file

@ -2,15 +2,14 @@ import random
import string
from datetime import datetime, timedelta, UTC
from uuid import UUID
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, HTTPException, Security
from fastapi.params import Depends
from sqlalchemy.orm import Session
from ocpp.v201.call import Reset, SetVariables
from ocpp.v201.call import ResetPayload, SetVariablesPayload
from app.database import get_db
from app.ocpp_proto import chargepoint_manager
from app.schemas.auth_token import AccessToken
from app.schemas.chargepoint import (
ChargePoint,
ChargePointCreate,
@ -28,13 +27,10 @@ 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
from app.security import get_api_key
router = APIRouter(
prefix="/chargepoints",
@ -45,16 +41,16 @@ router = APIRouter(
async def get_chargepoints(
skip: int = 0,
limit: int = 20,
db: Session = Depends(get_db),
token: AccessToken = Depends(JWTBearer()),
api_key: str = Security(get_api_key),
db: Session = Depends(get_db)
):
return db.query(DbChargePoint).order_by(DbChargePoint.identity).offset(skip).limit(limit).all()
return db.query(DbChargePoint).offset(skip).limit(limit).all()
@router.get(path="/{chargepoint_id}", response_model=ChargePoint)
async def get_chargepoint(
chargepoint_id: UUID,
db: Session = Depends(get_db),
token: AccessToken = Depends(JWTBearer()),
api_key: str = Security(get_api_key),
db: Session = Depends(get_db)
):
chargepoint = db.get(DbChargePoint, chargepoint_id)
if chargepoint is None:
@ -64,8 +60,8 @@ async def get_chargepoint(
@router.get(path="/{chargepoint_id}/password", response_model=ChargePointPassword)
async def get_chargepoint_password(
chargepoint_id: UUID,
db: Session = Depends(get_db),
token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])),
api_key: str = Security(get_api_key),
db: Session = Depends(get_db)
):
chargepoint = db.get(DbChargePoint, chargepoint_id)
if chargepoint is None:
@ -75,8 +71,8 @@ async def get_chargepoint_password(
@router.delete(path="/{chargepoint_id}/password", response_model=ChargePointPassword)
async def reset_chargepoint_password(
chargepoint_id: UUID,
db: Session = Depends(get_db),
token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])),
api_key: str = Security(get_api_key),
db: Session = Depends(get_db)
):
chargepoint = db.get(DbChargePoint, chargepoint_id)
if chargepoint is None:
@ -88,8 +84,8 @@ async def reset_chargepoint_password(
@router.post(path="", status_code=201, response_model=ChargePoint)
async def create_chargepoint(
chargepoint: ChargePointCreate,
db: Session = Depends(get_db),
token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])),
api_key: str = Security(get_api_key),
db: Session = Depends(get_db)
):
chargepoint_db = DbChargePoint(
identity=chargepoint.identity,
@ -106,8 +102,8 @@ async def create_chargepoint(
async def update_chargepoint(
chargepoint_id: UUID,
chargepoint_update: ChargePointUpdate,
db: Session = Depends(get_db),
token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])),
api_key: str = Security(get_api_key),
db: Session = Depends(get_db)
):
chargepoint = db.get(DbChargePoint, chargepoint_id)
if chargepoint is None:
@ -120,8 +116,8 @@ async def update_chargepoint(
@router.delete(path="/{chargepoint_id}", response_model=None)
async def delete_chargepoint(
chargepoint_id: UUID,
db: Session = Depends(get_db),
token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])),
api_key: str = Security(get_api_key),
db: Session = Depends(get_db)
):
chargepoint = db.get(DbChargePoint, chargepoint_id)
if chargepoint is None:
@ -133,7 +129,7 @@ async def delete_chargepoint(
@router.get(path="/{chargepoint_id}/status", response_model=ChargePointConnectionInfo)
async def get_chargepoint_status(
chargepoint_id: UUID,
token: AccessToken = Depends(JWTBearer()),
api_key: str = Security(get_api_key)
):
return ChargePointConnectionInfo(
connected=chargepoint_manager.is_connected(chargepoint_id)
@ -143,14 +139,14 @@ async def get_chargepoint_status(
async def reset_chargepoint(
chargepoint_id: UUID,
reset_request: ChargePointResetRequest,
token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])),
api_key: str = Security(get_api_key)
):
if chargepoint_manager.is_connected(chargepoint_id) == False:
raise HTTPException(status_code=503, detail="Chargepoint not connected.")
try:
response = await chargepoint_manager.call(
chargepoint_id,
payload=Reset(type=reset_request.type, evse_id=reset_request.evse_id)
payload=ResetPayload(type=reset_request.type, evse_id=reset_request.evse_id)
)
return ChargePointResetResponse(status=response.status)
except TimeoutError:
@ -160,8 +156,8 @@ async def reset_chargepoint(
async def create_id_token_learn_request(
chargepoint_id: UUID,
learn_request: IdTokenLearnRequest,
db: Session = Depends(get_db),
token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])),
api_key: str = Security(get_api_key),
db: Session = Depends(get_db)
):
chargepoint = db.get(DbChargePoint, chargepoint_id)
if chargepoint is None:
@ -192,8 +188,8 @@ async def create_id_token_learn_request(
@router.get(path="/{chargepoint_id}/token-learning", response_model=IdTokenLearnResponse)
async def get_id_token_learn_request(
chargepoint_id: UUID,
db: Session = Depends(get_db),
token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])),
api_key: str = Security(get_api_key),
db: Session = Depends(get_db)
):
chargepoint = db.get(DbChargePoint, chargepoint_id)
if chargepoint is None:
@ -210,8 +206,8 @@ async def get_id_token_learn_request(
@router.delete(path="/{chargepoint_id}/token-learning", response_model=[])
async def get_id_token_learn_request(
chargepoint_id: UUID,
db: Session = Depends(get_db),
token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])),
api_key: str = Security(get_api_key),
db: Session = Depends(get_db)
):
chargepoint = db.get(DbChargePoint, chargepoint_id)
if chargepoint is None:
@ -229,8 +225,8 @@ async def get_id_token_learn_request(
@router.get(path="/{chargepoint_id}/variables", response_model=list[ChargepointVariable])
async def get_chargepoint_variables(
chargepoint_id: UUID,
db: Session = Depends(get_db),
token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])),
api_key: str = Security(get_api_key),
db: Session = Depends(get_db)
):
chargepoint = db.get(DbChargePoint, chargepoint_id)
if chargepoint is None:
@ -243,8 +239,8 @@ async def update_chargepoint_variable(
chargepoint_id: UUID,
variable_id: UUID,
variable_update: ChargepointVariableUpdate,
db: Session = Depends(get_db),
token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])),
api_key: str = Security(get_api_key),
db: Session = Depends(get_db)
):
chargepoint = db.get(DbChargePoint, chargepoint_id)
if chargepoint is None:
@ -273,7 +269,7 @@ async def update_chargepoint_variable(
evse['connectorId'] = variable.connector_id
result = await chargepoint_manager.call(
chargepoint_id,
payload=SetVariables(set_variable_data=[
payload=SetVariablesPayload(set_variable_data=[
{
'attributeType': variable.type.value,
'attributeValue': variable_update.value,
@ -296,67 +292,3 @@ 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:
_, 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

@ -1,16 +1,14 @@
from uuid import UUID
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, HTTPException, Security
from fastapi.exceptions import RequestValidationError
from fastapi.params import Depends
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.database import get_db
from app.schemas.auth_token import AccessToken
from app.schemas.id_token import IdToken, IdTokenCreate, IdTokenUpdate
from app.models.id_token import IdToken as DbIdToken
from app.models.user import User as DbUser
from app.schemas.user import Role
from app.security.jwt_bearer import JWTBearer
from app.security import get_api_key
router = APIRouter(
prefix="/id-tokens",
@ -18,43 +16,32 @@ router = APIRouter(
)
@router.get(path="", response_model=list[IdToken])
async def get_id_tokens(
async def get_it_tokens(
skip: int = 0,
limit: int = 20,
db: Session = Depends(get_db),
token: AccessToken = Depends(JWTBearer()),
api_key: str = Security(get_api_key),
db: Session = Depends(get_db)
):
stmt = select(DbIdToken)
if token.role != Role.ADMINISTRATOR:
stmt = stmt.where(DbIdToken.owner_id == token.subject)
stmt = stmt.order_by(DbIdToken.id).offset(skip).limit(limit)
result = db.execute(stmt)
return result.scalars().all()
return db.query(DbIdToken).offset(skip).limit(limit).all()
@router.get(path="/{id_token_id}", response_model=IdToken)
async def get_id_token(
id_token_id: UUID,
db: Session = Depends(get_db),
token: AccessToken = Depends(JWTBearer()),
api_key: str = Security(get_api_key),
db: Session = Depends(get_db)
):
stmt = select(DbIdToken).where(DbIdToken.id == id_token_id)
result = db.execute(stmt)
id_token = result.scalars().first()
id_token = db.get(DbIdToken, id_token_id)
if id_token == None:
raise HTTPException(status_code=404, detail="IdToken not found")
if token.role != Role.ADMINISTRATOR and id_token.owner_id != token.subject:
raise HTTPException(status_code=404, detail="IdToken not found")
return id_token
@router.post(path="", status_code=201, response_model=IdToken)
async def create_id_token(
create_id_token: IdTokenCreate,
db: Session = Depends(get_db),
token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])),
api_key: str = Security(get_api_key),
db: Session = Depends(get_db)
):
stmt = select(DbUser).where(DbUser.id == create_id_token.owner_id)
result = db.execute(stmt)
owner = result.scalars().first()
owner = db.get(DbUser, create_id_token.owner_id)
if owner == None:
raise HTTPException(status_code=422, detail=[{
"loc": ["body", "owner_id"],
@ -76,19 +63,15 @@ async def create_id_token(
async def update_id_token(
id_token_id: UUID,
id_token_update: IdTokenUpdate,
db: Session = Depends(get_db),
token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])),
api_key: str = Security(get_api_key),
db: Session = Depends(get_db)
):
stmt = select(DbIdToken).where(DbIdToken.id == id_token_id)
result = db.execute(stmt)
id_token = result.scalars().first()
id_token = db.get(DbIdToken, id_token_id)
if id_token is None:
raise HTTPException(status_code=404, detail="IdToken not found")
for key, value in id_token_update.model_dump(exclude_unset=True).items():
if key == "owner_id":
stmt = select(DbUser).where(DbUser.id == id_token_update.owner_id)
result = db.execute(stmt)
owner = result.scalars().first()
owner = db.get(DbUser, value)
if owner == None:
raise HTTPException(status_code=422, detail=[{
"loc": ["body", "owner_id"],
@ -102,12 +85,10 @@ async def update_id_token(
@router.delete(path="/{id_token_id}", response_model=None)
async def delete_id_token(
id_token_id: UUID,
db: Session = Depends(get_db),
token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])),
api_key: str = Security(get_api_key),
db: Session = Depends(get_db)
):
stmt = select(DbIdToken).where(DbIdToken.id == id_token_id)
result = db.execute(stmt)
id_token = result.scalars().first()
id_token = db.get(DbIdToken, id_token_id)
if id_token == None:
raise HTTPException(status_code=404, detail="IdToken not found")
db.delete(id_token)

View file

@ -1,12 +1,11 @@
from fastapi import APIRouter, Depends
from sqlalchemy import select
from fastapi.params import Security
from sqlalchemy.orm import Session
from app.schemas.auth_token import AccessToken
from app.security import get_api_key
from app.database import get_db
from app.schemas.meter_value import MeterValue
from app.models.meter_value import MeterValue as DbMeterValue
from app.security.jwt_bearer import JWTBearer
router = APIRouter(
prefix="/meter-values",
@ -17,9 +16,7 @@ router = APIRouter(
async def get_meter_values(
skip: int = 0,
limit: int = 20,
db: Session = Depends(get_db),
token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])),
api_key: str = Security(get_api_key),
db: Session = Depends(get_db)
):
stmt = select(DbMeterValue).order_by(DbMeterValue.timestamp).offset(skip).limit(limit)
result = db.execute(stmt)
return result.scalars().all()
return db.query(DbMeterValue).offset(skip).limit(limit).all()

View file

@ -0,0 +1,66 @@
from fastapi import APIRouter, Depends, HTTPException
from fastapi.params import Security
from sqlalchemy.orm import Session
from ocpp.v201.call import RequestStopTransactionPayload
from app.ocpp_proto import chargepoint_manager
from app.security import get_api_key
from app.database import get_db
from app.schemas.transaction import Transaction, RemoteTransactionStartStopResponse, TransactionStatus, RemoteTransactionStartStopStatus
from app.models.transaction import Transaction as DbTransaction
router = APIRouter(
prefix="/transactions",
tags=["Transaction (v1)"]
)
@router.get(path="", response_model=list[Transaction])
async def get_transactions(
skip: int = 0,
limit: int = 20,
api_key: str = Security(get_api_key),
db: Session = Depends(get_db)
):
return db.query(DbTransaction).offset(skip).limit(limit).all()
@router.get(path="/{transaction_id}", response_model=Transaction)
async def get_transaction(
transaction_id: str,
api_key: str = Security(get_api_key),
db: Session = Depends(get_db)
):
transaction = db.get(DbTransaction, transaction_id)
if transaction == None:
raise HTTPException(404, "Transaction not found")
return transaction
@router.post(path="/{transaction_id}/remote-stop", response_model=RemoteTransactionStartStopResponse)
async def remote_stop_transaction(
transaction_id: str,
api_key: str = Security(get_api_key),
db: Session = Depends(get_db)
):
transaction = db.get(DbTransaction, transaction_id)
if transaction == None:
raise HTTPException(404, "Transaction not found")
if transaction.status != TransactionStatus.ONGOING:
raise HTTPException(status_code=422, detail=[{
"loc": ["path", "transaction_id"],
"msg": "Transaction is not ongoing",
"type": "invalid_transaction_state"
}])
if chargepoint_manager.is_connected(transaction.chargepoint_id) == False:
raise HTTPException(status_code=503, detail="Chargepoint not connected.")
try:
result = await chargepoint_manager.call(
transaction.chargepoint_id,
payload=RequestStopTransactionPayload(
transaction_id=transaction.id
)
)
if RemoteTransactionStartStopStatus(result.status) != RemoteTransactionStartStopStatus.REJECTED:
raise HTTPException(status_code=500, detail=result.status)
return RemoteTransactionStartStopResponse(status=result.status)
except TimeoutError:
raise HTTPException(status_code=503, detail="Chargepoint didn't respond in time.")

77
app/routers/user_v1.py Normal file
View file

@ -0,0 +1,77 @@
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.schemas.user import User, UserCreate, UserUpdate
from app.models.user import User as DbUser
from app.security import get_api_key
router = APIRouter(
prefix="/users",
tags=["User (v1)"],
)
@router.get(path="", response_model=list[User])
async def get_users(
skip: int = 0,
limit: int = 20,
api_key: str = Security(get_api_key),
db: Session = Depends(get_db)
):
return db.query(DbUser).offset(skip).limit(limit).all()
@router.get(path="/{user_id}", response_model=User)
async def get_user(
user_id: UUID,
api_key: str = Security(get_api_key),
db: Session = Depends(get_db)
):
user = db.get(DbUser, user_id)
if user == None:
raise HTTPException(status_code=404, detail="User not found")
return user
@router.post(path="", status_code=201, response_model=User)
async def create_user(
create_user: UserCreate,
api_key: str = Security(get_api_key),
db: Session = Depends(get_db)
):
db_user = DbUser(
friendly_name=create_user.friendly_name,
is_active=create_user.is_active
)
db.add(db_user)
db.commit()
db.refresh(db_user)
return db_user
@router.patch(path="/{user_id}", response_model=User)
async def update_user(
user_id: UUID,
user_update: UserUpdate,
api_key: str = Security(get_api_key),
db: Session = Depends(get_db)
):
user = db.get(DbUser, user_id)
if user is None:
raise HTTPException(status_code=404, detail="User not found")
for key, value in user_update.model_dump(exclude_unset=True).items():
setattr(user, key, value)
db.commit()
return user
@router.delete(path="/{user_id}", response_model=None)
async def delete_user(
user_id: UUID,
api_key: str = Security(get_api_key),
db: Session = Depends(get_db)
):
user = db.get(DbUser, user_id)
if user == None:
raise HTTPException(status_code=404, detail="User not found")
db.delete(user)
db.commit()
return []

View file

@ -6,9 +6,7 @@ from pydantic import BaseModel
from app.schemas.connector import Connector
from ocpp.v201.enums import ResetEnumType, ResetStatusEnumType
from app.util.encoders import decimal_encoder, force_utc_datetime
from ocpp.v201.enums import ResetType, ResetStatusType
class ChargePointBase(BaseModel):
identity: str
@ -34,16 +32,6 @@ class ChargePoint(ChargePointBase):
class Config:
from_attributes = True
json_encoders = {Decimal: decimal_encoder, datetime: force_utc_datetime}
class ChargePointThumb(BaseModel):
id: UUID
identity: str
price: Decimal
class Config:
from_attributes = True
json_encoders = {Decimal: decimal_encoder}
class ChargePointPassword(BaseModel):
password: str
@ -52,8 +40,8 @@ class ChargePointConnectionInfo(BaseModel):
connected: bool
class ChargePointResetRequest(BaseModel):
type: ResetEnumType
type: ResetType
evse_id: Optional[int] = None
class ChargePointResetResponse(BaseModel):
status: ResetStatusEnumType
status: ResetStatusType

View file

@ -4,8 +4,6 @@ from uuid import UUID
from pydantic import BaseModel
import enum
from app.util.encoders import decimal_encoder
class AttributeType(enum.Enum):
ACTUAL = "Actual"
TARGET = "Target"
@ -54,7 +52,6 @@ class ChargepointVariable(BaseModel):
class Config:
from_attributes = True
json_encoders = {Decimal: decimal_encoder}
class ChargepointVariableUpdate(BaseModel):
value: str

View file

@ -3,6 +3,8 @@ from typing import Optional
from uuid import UUID
from pydantic import BaseModel
from app.schemas.user import User
class IdTokenBase(BaseModel):
friendly_name: str
is_active: bool

View file

@ -5,8 +5,6 @@ from typing import Optional
from uuid import UUID
from pydantic import BaseModel
from app.util.encoders import decimal_encoder, force_utc_datetime
class PhaseType(enum.Enum):
L1 = "L1"
L2 = "L2"
@ -57,4 +55,3 @@ class MeterValue(BaseModel):
class Config:
from_attributes = True
json_encoders = {Decimal: decimal_encoder, datetime: force_utc_datetime}

View file

@ -1,13 +1,10 @@
import enum
from datetime import datetime
from decimal import Decimal
import enum
from typing import Optional
from uuid import UUID
from pydantic import BaseModel
from app.schemas.chargepoint import ChargePointThumb
from app.schemas.user import UserThumb
from app.util.encoders import decimal_encoder, force_utc_datetime
class TransactionStatus(enum.Enum):
ONGOING = "ongoing"
ENDED = "ended"
@ -45,15 +42,14 @@ class Transaction(BaseModel):
started_at: datetime
ended_at: Optional[datetime] = None
meter_start: Decimal
meter_end: Decimal
meter_end: Optional[Decimal] = None
end_reason: Optional[TransactionEventTriggerReason] = None
price: Decimal
user: UserThumb
chargepoint: ChargePointThumb
user_id: Optional[UUID] = None
chargepoint_id: UUID
class Config:
from_attributes = True
json_encoders = {Decimal: decimal_encoder, datetime: force_utc_datetime}
class RemoteTransactionStartStopResponse(BaseModel):
status: RemoteTransactionStartStopStatus

20
app/schemas/user.py Normal file
View file

@ -0,0 +1,20 @@
from typing import Optional
from uuid import UUID
from pydantic import BaseModel
class UserBase(BaseModel):
friendly_name: str
is_active: bool
class UserUpdate(BaseModel):
friendly_name: Optional[str] = None
is_active: Optional[bool] = None
class UserCreate(UserBase):
pass
class User(UserBase):
id: UUID
class Config:
from_attributes = True

34
app/security.py Normal file
View file

@ -0,0 +1,34 @@
import os
from fastapi import HTTPException, Security, status
from fastapi.security import APIKeyHeader, HTTPBasic
basic_auth = HTTPBasic()
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
def get_api_key(
api_key_header: str = Security(api_key_header),
) -> str:
"""Retrieve and validate an API key from the HTTP header.
Args:
api_key_header: The API key passed in the HTTP header.
Returns:
The validated API key.
Raises:
HTTPException: If the API key is invalid or missing.
"""
api_key = os.getenv("CS_API_KEY", "default")
if api_key == "default":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="API key not set. Authentication not possible.",
)
if api_key_header == api_key:
return api_key_header
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or missing API Key",
)

View file

@ -1,7 +1,7 @@
from datetime import datetime, UTC
from ocpp.v201.datatypes import IdTokenInfoType
from ocpp.v201.enums import AuthorizationStatusEnumType
from ocpp.v201.enums import AuthorizationStatusType
from app.database import SessionLocal
from app.models.id_token import IdToken
@ -11,14 +11,14 @@ async def get_id_token_info(chargepoint_id: str, id_token: str):
owner_id = None
if id_token["type"] not in ["ISO14443", "ISO15693"]:
return IdTokenInfoType(
status=AuthorizationStatusEnumType.invalid
status=AuthorizationStatusType.invalid
), owner_id
with SessionLocal() as db:
db_id_token = db.query(IdToken).filter(IdToken.token == id_token["id_token"]).first()
if db_id_token == None:
id_token_info = IdTokenInfoType(
status=AuthorizationStatusEnumType.unknown
status=AuthorizationStatusType.unknown
)
db_chargepoint = db.query(ChargePoint).filter(ChargePoint.identity == chargepoint_id).first()
# Learn token if requested
@ -32,7 +32,7 @@ async def get_id_token_info(chargepoint_id: str, id_token: str):
db.add(db_id_token)
id_token_info=IdTokenInfoType(
status=AuthorizationStatusEnumType.accepted
status=AuthorizationStatusType.accepted
)
owner_id = db_id_token.owner_id
db_chargepoint.learn_user_id = None
@ -42,10 +42,10 @@ async def get_id_token_info(chargepoint_id: str, id_token: str):
owner_id = db_id_token.owner_id
if db_id_token.is_active == False:
id_token_info=IdTokenInfoType(
status=AuthorizationStatusEnumType.blocked
status=AuthorizationStatusType.blocked
)
else:
id_token_info=IdTokenInfoType(
status=AuthorizationStatusEnumType.accepted
status=AuthorizationStatusType.accepted
)
return id_token_info, owner_id

View file

@ -21,23 +21,19 @@ async def create_transaction(
with SessionLocal() as db:
chargepoint = db.query(ChargePoint).filter(ChargePoint.identity == chargepoint_identity).first()
meter_start=0
meter_end=0
if "meter_value" in transaction_data.keys():
for meter_value_entry in transaction_data['meter_value']:
for sampled_value in meter_value_entry['sampled_value']:
if "measurand" in sampled_value.keys():
if sampled_value['measurand'] == str(Measurand.ENERGY_ACTIVE_IMPORT_REGISTER):
meter_start = sampled_value['value']
meter_end = sampled_value['value']
else:
meter_start = sampled_value['value']
meter_end = sampled_value['value']
transaction = Transaction(
id=transaction_info["transaction_id"],
status=TransactionStatus.ONGOING,
started_at=timestamp,
meter_start=meter_start,
meter_end=meter_end,
price=chargepoint.price,
chargepoint_id=chargepoint.id,
user_id=user_id
@ -59,12 +55,6 @@ async def update_transaction(
transaction_id=transaction.id,
meter_value_data=meter_value_entry
)
# Update current meter_end value
for sampled_value in meter_value_entry['sampled_value']:
if "measurand" in sampled_value.keys():
if sampled_value['measurand'] == str(Measurand.ENERGY_ACTIVE_IMPORT_REGISTER):
transaction.meter_end = sampled_value['value']
db.commit()
async def end_transaction(
transaction_id: str,

View file

@ -1,67 +0,0 @@
"""Add user authentication
Revision ID: c7f72154c90b
Revises: 097d427dfa07
Create Date: 2025-03-13 14:57:05.805469+00:00
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = 'c7f72154c90b'
down_revision: Union[str, None] = '097d427dfa07'
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('sessions',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('name', sa.String(), nullable=True),
sa.Column('refresh_token', sa.String(), nullable=False),
sa.Column('last_used', sa.DateTime(timezone=True), nullable=True),
sa.Column('user_id', sa.Uuid(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
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.add_column('users', sa.Column('email', sa.String(), nullable=True))
op.add_column('users', sa.Column('password', sa.String(), 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 password = \'invalid\'')
op.execute('UPDATE users SET role = \'MEMBER\'')
with op.batch_alter_table('users', schema=None) as batch_op:
batch_op.alter_column('email', nullable=False)
batch_op.alter_column('password', nullable=False)
batch_op.alter_column('role', nullable=False)
op.drop_index('ix_users_friendly_name', table_name='users')
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_users_email'), table_name='users')
op.create_index('ix_users_friendly_name', 'users', ['friendly_name'], unique=1)
op.drop_column('users', 'role')
op.drop_column('users', 'password')
op.drop_column('users', 'email')
op.drop_index(op.f('ix_sessions_user_id'), table_name='sessions')
op.drop_index(op.f('ix_sessions_refresh_token'), table_name='sessions')
op.drop_table('sessions')
# ### end Alembic commands ###

View file

@ -1,50 +0,0 @@
"""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

@ -1,32 +0,0 @@
"""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

@ -1,87 +0,0 @@
import secrets
import sys
import os
sys.path.append(os.path.join(os.path.dirname(__file__), ".."))
import argparse
from dotenv import load_dotenv
from sqlalchemy import select
from sqlalchemy.orm import Session
from argon2 import PasswordHasher
load_dotenv()
from app.models import *
from app.database import SessionLocal
def __get_user_by_email(db: Session, email: str):
stmt = select(user.User).where(user.User.email == email)
result = db.execute(stmt)
return result.scalars().first()
def create_user(args):
hasher = PasswordHasher(memory_cost=102400)
with SessionLocal() as db:
db_user = __get_user_by_email(db, args.email)
if db_user is not None:
print(f'Error: A user with email \'{args.email}\' already exists.')
sys.exit(1)
hashed_password = hasher.hash(args.password)
db_user = user.User(
friendly_name=args.name, email=args.email, password=hashed_password
)
db.add(db_user)
db.commit()
print(f'Success: Created user \'{args.email}\'.')
def reset_password(args):
hasher = PasswordHasher(memory_cost=102400)
with SessionLocal() as db:
db_user = __get_user_by_email(db, args.email)
if db_user is None:
print(f'Error: No user with email \'{args.email}\' found.')
sys.exit(1)
db_user.password = hasher.hash(args.password)
db.commit()
print(f'Success: Changed password for user \'{args.email}\'.')
def set_role(args):
with SessionLocal() as db:
db_user = __get_user_by_email(db, args.email)
if db_user is None:
print(f'Error: No user with email \'{args.email}\' found.')
sys.exit(1)
db_user.role = user.Role(args.role)
db.commit()
print(f'Success: Role of user \'{args.email}\' changed to \'{db_user.role}\'.')
def generate_secret(args):
print(f'Your secret: {secrets.token_urlsafe(64)}')
if __name__ == "__main__":
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers(help="subcommands", required=True)
p_create_user = subparsers.add_parser('user_create', help='Create a new user')
p_create_user.add_argument('--email', type=str, required=True, help='Email of the new user')
p_create_user.add_argument('--password', type=str, required=True, help='Password for the new user')
p_create_user.add_argument('--name', type=str, required=False, help='Display name of the new user')
p_create_user.add_argument('--role', type=str, choices=('member', 'administrator'), default='member', required=False, help='Role of the new user')
p_create_user.set_defaults(func=create_user)
p_reset_password = subparsers.add_parser('user_reset_password', help='Reset password for a user')
p_reset_password.add_argument('--email', type=str, required=True, help='Email of the user to modify')
p_reset_password.add_argument('--password', type=str, required=True, help='New password for the specified user')
p_reset_password.set_defaults(func=reset_password)
p_set_role = subparsers.add_parser('user_set_role', help='Update role of a user')
p_set_role.add_argument('--email', type=str, required=True, help='Email of the user to modify')
p_set_role.add_argument('--role', type=str, choices=('member', 'administrator'), required=True, help='New role for the specified user')
p_set_role.set_defaults(func=set_role)
p_set_role = subparsers.add_parser('generate_secret', help='Generate safe to use secret for token signing')
p_set_role.set_defaults(func=generate_secret)
args = parser.parse_args()
args.func(args)

View file

@ -1,81 +0,0 @@
import os
from dotenv import load_dotenv
from fastapi import APIRouter, FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from starlette.middleware.authentication import AuthenticationMiddleware
load_dotenv()
from app.routers import (
auth_v1,
chargepoint_v1,
id_token_v1,
me_v1,
meter_value_v1,
ocpp_v1,
transaction_v1,
user_v1
)
from app.security.websocket_auth_backend import BasicAuthBackend
def create_app():
# Common app config
title="LibreCharge"
version="0.2.0"
summary="Simple implementation of a basic OCPP 2.0.1 compliant central system (backend) for EV charging stations"
responses={404: {"description": "Not found"}}
# Root FastAPI app
root_app = FastAPI(
title=title,
version=version,
summary=summary,
responses=responses,
docs_url=None,
redoc_url=None,
)
# FastAPI app for OCPP handler
ocpp_app = FastAPI(
title=title,
version=version,
summary=summary,
responses=responses,
docs_url=None,
redoc_url=None,
)
ocpp_app.include_router(ocpp_v1.router)
ocpp_app.add_middleware(AuthenticationMiddleware, backend=BasicAuthBackend())
root_app.mount(path="/v1/ocpp", app=ocpp_app)
# FastAPI app for REST API
api_app = FastAPI(
title=title,
version=version,
summary=summary,
responses=responses,
)
api_v1_router = APIRouter(
prefix="/v1"
)
api_v1_router.include_router(auth_v1.router)
api_v1_router.include_router(chargepoint_v1.router)
api_v1_router.include_router(id_token_v1.router)
api_v1_router.include_router(me_v1.router)
api_v1_router.include_router(user_v1.router)
api_v1_router.include_router(meter_value_v1.router)
api_v1_router.include_router(transaction_v1.router)
api_app.include_router(api_v1_router)
root_app.mount(path="/api", app=api_app)
# Serve static files if existent
if os.path.isdir('static'):
static_files = StaticFiles(directory="static", html=True)
root_app.mount(path="/", app=static_files)
return root_app
app = create_app()

View file

@ -1,24 +0,0 @@
import uuid
from sqlalchemy import Column, DateTime, Enum, ForeignKey, Integer, String, Text, Uuid
from sqlalchemy.orm import relationship
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)
chargepoint = relationship("ChargePoint", back_populates="firmware_updates")

View file

@ -1,16 +0,0 @@
import uuid
from sqlalchemy import Column, DateTime, ForeignKey, String, Uuid
from sqlalchemy.orm import relationship
from app.database import Base
class Session(Base):
__tablename__ = "sessions"
id = Column(Uuid, primary_key=True, default=uuid.uuid4)
name = Column(String)
refresh_token = Column(String, nullable=False, unique=True, index=True)
last_used = Column(DateTime(timezone=True))
user_id = Column(Uuid, ForeignKey("users.id"), nullable=False, index=True)
user = relationship("User", back_populates="sessions")

View file

@ -1,20 +0,0 @@
import uuid
from sqlalchemy import Enum, Uuid, Boolean, Column, String
from sqlalchemy.orm import relationship
from app.database import Base
from app.schemas.user import Role
class User(Base):
__tablename__ = "users"
id = Column(Uuid, primary_key=True, default=uuid.uuid4)
friendly_name = Column(String, nullable=True)
is_active = Column(Boolean, nullable=False, default=True)
email = Column(String, nullable=False, unique=True, index=True)
password = Column(String, nullable=False)
role = Column(Enum(Role), nullable=False, default=Role.MEMBER)
id_tokens = relationship("IdToken", back_populates="owner", cascade="delete, delete-orphan")
transactions = relationship("Transaction", cascade="delete, delete-orphan")
sessions = relationship("Session", cascade="delete, delete-orphan")

View file

@ -1,72 +0,0 @@
from uuid import UUID
from fastapi import APIRouter, HTTPException
from fastapi.params import Depends
from sqlalchemy.orm import Session
from app.database import get_db
from app.schemas.auth_token import (
AccessToken,
TokenRefreshRequest,
TokenResponse,
)
from app.schemas.user import LoginRequest
from app.security.jwt_bearer import JWTBearer
from app.services import session_service, token_service, user_service
from app.util.errors import NotFoundError
router = APIRouter(prefix="/auth", tags=["Authentication (v1)"])
@router.post(path="/login", response_model=TokenResponse)
async def login(
login_request: LoginRequest, db: Session = Depends(get_db)
):
"""
Login to a existing account. Creates a new session and returns a access and refresh token.
"""
user = await user_service.validate_login(
db=db, login=login_request
)
if not user:
raise HTTPException(status_code=403, detail="invalid_email_or_password")
session = await session_service.create_session(db=db, user=user, useragent="")
token, expire = await token_service.create_access_token(
user=user, session_id=session.id
)
return TokenResponse(
access_token=token, refresh_token=session.refresh_token, not_after=expire
)
@router.post(path="/logout", response_model=list[None])
async def logout(
db: Session = Depends(get_db), token: AccessToken = Depends(JWTBearer())
):
"""
Remove the current session based on the access token, effectively invalidating the current refresh token.
"""
await session_service.remove_session(
db=db, id=UUID(token.session)
)
return list()
@router.post(path="/refresh", response_model=TokenResponse)
async def refresh_access_token(
token_request: TokenRefreshRequest,
db: Session = Depends(get_db),
):
"""
Use an existing refresh token to generate a new access token and a new refresh token.
"""
try:
session = await session_service.validate_and_rotate_refresh_token(
db=db, refresh_token=token_request.refresh_token
)
user = await user_service.get_user(db=db, id=session.user_id)
token, expire = await token_service.create_access_token(
user=user, session_id=session.id
)
return TokenResponse(
access_token=token, refresh_token=session.refresh_token, not_after=expire
)
except NotFoundError:
raise HTTPException(status_code=403, detail="invalid_refresh_token")

View file

@ -1,187 +0,0 @@
from datetime import UTC, datetime, timedelta
from uuid import UUID
from fastapi import APIRouter, HTTPException
from fastapi.params import Depends
from sqlalchemy import select
from sqlalchemy.orm import Session as DbSession
from app.database import get_db
from app.schemas.session import Session
from app.schemas.auth_token import AccessToken
from app.schemas.user import PasswordUpdate, UserUpdate, User
from app.security.jwt_bearer import JWTBearer
from app.services import session_service, user_service
from app.util.errors import InvalidStateError, NotFoundError
from app.schemas.dashboard import DashboardResponse, DashboardStats
from app.models.transaction import Transaction as DbTransaction
from app.schemas.transaction import TransactionStatus
router = APIRouter(prefix="/me", tags=["Me (v1)"])
@router.get(path="", response_model=User)
async def get_myself(
db: DbSession = Depends(get_db), token: AccessToken = Depends(JWTBearer())
):
"""
Get the currently authenticated user.
"""
user = await user_service.get_user(db=db, id=UUID(token.subject))
if not user:
raise HTTPException(status_code=404, detail="user_not_found")
else:
return user
@router.patch(path="", response_model=User)
async def update_myself(
user_update: UserUpdate,
db: DbSession = Depends(get_db),
token: AccessToken = Depends(JWTBearer()),
):
"""
Update the currently authenticated user. Changing the email address automatically marks it as not verified
and starts a new verification workflow.
"""
try:
return await user_service.update_user(
db, UUID(token.subject), user_update
)
except NotFoundError:
raise HTTPException(status_code=404, detail="user_not_found")
@router.post(path="/password", response_model=list[None])
async def change_password(
update: PasswordUpdate,
db: DbSession = Depends(get_db),
token: AccessToken = Depends(JWTBearer()),
):
"""
Change the password of the currently authenticated user.
"""
try:
await user_service.change_user_password(
db=db, id=UUID(token.subject), update=update
)
return list()
except NotFoundError:
raise HTTPException(status_code=404, detail="user_not_found")
except InvalidStateError:
raise HTTPException(status_code=409, detail="incorrect_password")
@router.get(path="/sessions", response_model=list[Session])
async def get_user_sessions(
db: DbSession = Depends(get_db), token: AccessToken = Depends(JWTBearer())
):
"""
List the active sessions of the currently authenticated user.
"""
return await session_service.get_sessions_by_user(
db=db, user_id=UUID(token.subject)
)
@router.delete(path="/sessions", response_model=list[None])
async def clear_user_sessions(
db: DbSession = Depends(get_db), token: AccessToken = Depends(JWTBearer())
):
"""
Clear all sessions of the currently authenticated user.
"""
await session_service.remove_all_sessions_for_user(
db=db, user_id=UUID(token.subject),
)
return list()
@router.delete(path="/sessions/{session_id}", response_model=list[None])
async def delete_user_session(
session_id: UUID,
db: DbSession = Depends(get_db),
token: AccessToken = Depends(JWTBearer()),
):
"""
Invalidate a specific session of the currently authenticated user.
"""
try:
await session_service.remove_session_for_user(
db=db,
id=session_id,
user_id=UUID(token.subject),
)
except NotFoundError:
raise HTTPException(status_code=404, detail="session_not_found")
return list()
@router.get(path="/dashboard", response_model=DashboardResponse)
async def get_dashboard(
db: DbSession = Depends(get_db),
token: AccessToken = Depends(JWTBearer()),
):
"""
Get dashboard information for the currently authenticated user.
"""
# Currently ongoing transaction
stmt_current_transaction = select(DbTransaction).where(DbTransaction.user_id == token.subject).where(DbTransaction.status == TransactionStatus.ONGOING)
current_transaction = db.execute(stmt_current_transaction).scalars().first()
# Common base query
stmt_base = select(DbTransaction).where(DbTransaction.user_id == token.subject).where( DbTransaction.status == TransactionStatus.ENDED).order_by(DbTransaction.ended_at.desc())
# 5 most recent transactions
stmt_transactions_recent = stmt_base.limit(5)
recent_transactions = db.execute(stmt_transactions_recent).scalars().all()
# Calculate beginning of the current and previous month
current_date = datetime.now(UTC).replace(hour=0, minute=0, second=0, microsecond=0, tzinfo=None)
beginning_current_month = current_date.replace(day=1)
beginning_previous_month = beginning_current_month.replace(
year=(beginning_current_month.year - 1 if beginning_current_month.month == 1 else beginning_current_month.year),
month=(12 if beginning_current_month.month == 1 else beginning_current_month.month - 1)
)
# Transactions for total calculations
stmt_transactions = stmt_base.where(DbTransaction.ended_at >= beginning_previous_month)
transactions = db.execute(stmt_transactions).scalars().all()
# Current month totals
count = 0
energy_total = 0
cost_total = 0
# Previous month totals
count_previous = 0
energy_total_previous = 0
cost_total_previous = 0
# Calculate totals
for trans in transactions:
trans_energy = trans.meter_end - trans.meter_start
trans_cost = trans_energy * trans.price
if trans.ended_at >= beginning_current_month:
# Current month
energy_total += trans_energy
cost_total += trans_cost
count += 1
else:
# Previous month
energy_total_previous += trans_energy
cost_total_previous += trans_cost
count_previous += 1
return DashboardResponse(
stats=DashboardStats(
transaction_count=count,
transaction_count_previous=count_previous,
transaction_energy_total=energy_total,
transaction_energy_total_previous=energy_total_previous,
transaction_cost_total=cost_total,
transaction_cost_total_previous=cost_total_previous
),
current_transaction=current_transaction,
recent_transactions=recent_transactions
)

View file

@ -1,100 +0,0 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.orm import Session
from ocpp.v201.call import RequestStopTransaction
from app.ocpp_proto import chargepoint_manager
from app.schemas.auth_token import AccessToken
from app.database import get_db
from app.schemas.meter_value import MeterValue
from app.schemas.transaction import Transaction, RemoteTransactionStartStopResponse, TransactionStatus, RemoteTransactionStartStopStatus
from app.models.transaction import Transaction as DbTransaction
from app.models.meter_value import MeterValue as DbMeterValue
from app.schemas.user import Role
from app.security.jwt_bearer import JWTBearer
router = APIRouter(
prefix="/transactions",
tags=["Transaction (v1)"]
)
@router.get(path="", response_model=list[Transaction])
async def get_transactions(
skip: int = 0,
limit: int = 20,
db: Session = Depends(get_db),
token: AccessToken = Depends(JWTBearer()),
):
stmt = select(DbTransaction)
if (token.role != Role.ADMINISTRATOR):
stmt = stmt.where(DbTransaction.user_id == token.subject)
stmt = stmt.order_by(DbTransaction.started_at).offset(skip).limit(limit)
result = db.execute(stmt)
return result.scalars().all()
@router.get(path="/{transaction_id}", response_model=Transaction)
async def get_transaction(
transaction_id: str,
db: Session = Depends(get_db),
token: AccessToken = Depends(JWTBearer()),
):
stmt = select(DbTransaction).where(DbTransaction.id == transaction_id)
result = db.execute(stmt)
transaction = result.scalars().first()
if transaction == None:
raise HTTPException(404, "Transaction not found")
if token.role != Role.ADMINISTRATOR and transaction.user_id != token.subject:
raise HTTPException(404, "Transaction not found")
return transaction
@router.get(path="/{transaction_id}/meter-values", response_model=list[MeterValue])
async def get_transaction_meter_values(
transaction_id: str,
db: Session = Depends(get_db),
token: AccessToken = Depends(JWTBearer()),
):
stmt = select(DbTransaction).where(DbTransaction.id == transaction_id)
result = db.execute(stmt)
transaction = result.scalars().first()
if transaction == None:
raise HTTPException(404, "Transaction not found")
if token.role != Role.ADMINISTRATOR and transaction.user_id != token.subject:
raise HTTPException(404, "Transaction not found")
stmt = select(DbMeterValue).where(DbMeterValue.transaction_id == transaction_id).order_by(DbMeterValue.timestamp)
result = db.execute(stmt)
return result.scalars().all()
@router.post(path="/{transaction_id}/remote-stop", response_model=RemoteTransactionStartStopResponse)
async def remote_stop_transaction(
transaction_id: str,
db: Session = Depends(get_db),
token: AccessToken = Depends(JWTBearer()),
):
stmt = select(DbTransaction).where(DbTransaction.id == transaction_id)
result = db.execute(stmt)
transaction = result.scalars().first()
if transaction == None:
raise HTTPException(404, "Transaction not found")
if token.role != Role.ADMINISTRATOR and transaction.user_id != token.subject:
raise HTTPException(404, "Transaction not found")
if transaction.status != TransactionStatus.ONGOING:
raise HTTPException(status_code=422, detail=[{
"loc": ["path", "transaction_id"],
"msg": "Transaction is not ongoing",
"type": "invalid_transaction_state"
}])
if chargepoint_manager.is_connected(transaction.chargepoint_id) == False:
raise HTTPException(status_code=503, detail="chargepoint_offline")
try:
result = await chargepoint_manager.call(
transaction.chargepoint_id,
payload=RequestStopTransaction(
transaction_id=transaction.id
)
)
if RemoteTransactionStartStopStatus(result.status) != RemoteTransactionStartStopStatus.REJECTED:
raise HTTPException(status_code=500, detail=result.status)
return RemoteTransactionStartStopResponse(status=result.status)
except TimeoutError:
raise HTTPException(status_code=503, detail="chargepoint_operation_timeout")

View file

@ -1,116 +0,0 @@
from uuid import UUID
from fastapi import APIRouter, HTTPException
from fastapi.params import Depends
from sqlalchemy.orm import Session as DbSession
from app.database import get_db
from app.schemas.session import Session
from app.schemas.auth_token import AccessToken
from app.schemas.user import AdministrativeUserUpdate, User, UserCreate
from app.security.jwt_bearer import JWTBearer
from app.services import session_service, user_service
from app.util.errors import NotFoundError
router = APIRouter(
prefix="/users",
tags=["User (v1)"],
)
@router.get(path="", response_model=list[User])
async def get_users(
email: str = None,
skip: int = 0,
limit: int = 20,
db: DbSession = Depends(get_db),
token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])),
):
return await user_service.get_users(db, skip, limit, email)
@router.post(path="", status_code=201, response_model=User)
async def create_user(
create_user: UserCreate,
db: DbSession = Depends(get_db),
token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])),
):
user = await user_service.create_user(
db=db, user=create_user
)
return user
@router.patch(path="/{user_id}", response_model=User)
async def update_user(
user_id: UUID,
user_update: AdministrativeUserUpdate,
db: DbSession = Depends(get_db),
token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])),
):
try:
return await user_service.update_user(
db, user_id, user_update
)
except NotFoundError:
raise HTTPException(status_code=404, detail="user_not_found")
@router.delete(path="/{user_id}", response_model=None)
async def delete_user(
user_id: UUID,
db: DbSession = Depends(get_db),
token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])),
):
try:
await user_service.remove_user(db, user_id)
return list()
except NotFoundError:
raise HTTPException(status_code=404, detail="user_not_found")
@router.get(
path="/{user_id}/sessions", response_model=list[Session]
)
async def get_user_sessions(
user_id: UUID,
db: DbSession = Depends(get_db),
token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])),
):
"""
Query sessions of the specified user. Requires the "administrator" role.
"""
return await session_service.get_sessions_by_user(db=db, user_id=user_id)
@router.delete(
path="/{user_id}/sessions", response_model=list[None]
)
async def remove_all_user_session(
user_id: UUID,
db: DbSession = Depends(get_db),
token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])),
):
"""
Delete all sessions of the specified user. Requires the "administrator" role.
"""
await session_service.remove_all_sessions_for_user(
db=db, user_id=user_id
)
return list()
@router.delete(
path="/{user_id}/sessions/{session_id}",
response_model=list[None],
)
async def remove_user_session(
user_id: UUID,
session_id: UUID,
db: DbSession = Depends(get_db),
token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])),
):
"""
Delete the specified session of the specified user. Requires the "administrator" role.
"""
try:
await session_service.remove_session_for_user(
db=db, id=session_id, user_id=user_id
)
except NotFoundError:
raise HTTPException(status_code=404, detail="session_not_found")
return list()

View file

@ -1,25 +0,0 @@
from dataclasses import dataclass
from datetime import datetime
from pydantic import BaseModel
from app.schemas.user import Role
from app.util.encoders import force_utc_datetime
@dataclass
class AccessToken:
subject: str
role: Role
session: str
class TokenRefreshRequest(BaseModel):
refresh_token: str
class TokenResponse(BaseModel):
access_token: str
refresh_token: str
not_after: datetime
class Config:
json_encoders = {datetime: force_utc_datetime}

View file

@ -1,25 +0,0 @@
from decimal import Decimal
from typing import Optional
from pydantic import BaseModel
from app.schemas.transaction import Transaction
from app.util.encoders import decimal_encoder
class DashboardStats(BaseModel):
transaction_count: int
transaction_count_previous: int
transaction_energy_total: Decimal
transaction_energy_total_previous: Decimal
transaction_cost_total: Decimal
transaction_cost_total_previous: Decimal
class Config:
json_encoders = {Decimal: decimal_encoder}
class DashboardResponse(BaseModel):
stats: DashboardStats
current_transaction: Optional[Transaction] = None
recent_transactions: list[Transaction]
class Config:
json_encoders = {Decimal: decimal_encoder}

View file

@ -1,43 +0,0 @@
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

@ -1,15 +0,0 @@
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel
from app.util.encoders import force_utc_datetime
class Session(BaseModel):
id: UUID
name: str
last_used: datetime
class Config:
from_attributes = True
json_encoders = {datetime: force_utc_datetime}

View file

@ -1,46 +0,0 @@
import enum
from typing import Optional
from uuid import UUID
from pydantic import BaseModel, EmailStr, Field
class Role(enum.StrEnum):
MEMBER = "member"
ADMINISTRATOR = "administrator"
class UserBase(BaseModel):
email: EmailStr = Field(max_length=60)
friendly_name: str
is_active: bool
class UserUpdate(BaseModel):
email: Optional[str] = None
friendly_name: Optional[str] = None
class AdministrativeUserUpdate(UserUpdate):
is_active: Optional[bool] = None
class UserCreate(UserBase):
password: str = Field(max_length=100)
pass
class User(UserBase):
id: UUID
role: Role
class Config:
from_attributes = True
class UserThumb(BaseModel):
id: UUID
friendly_name: str
class Config:
from_attributes = True
class PasswordUpdate(BaseModel):
old_password: str = Field(max_length=100)
new_password: str = Field(max_length=100)
class LoginRequest(BaseModel):
email: EmailStr = Field(max_length=60)
password: str = Field(max_length=100)

View file

@ -1,47 +0,0 @@
from typing import Optional
from fastapi import Request, HTTPException
from fastapi.params import Depends
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.services import token_service
from app.util.errors import InsufficientPermissionsError, InvalidTokenAudienceError
from app.schemas.auth_token import AccessToken
class JWTBearer(HTTPBearer):
__required_roles: list[str] | None
def __init__(
self, required_roles: Optional[list[str]] = None, auto_error: bool = True
):
self.__required_roles = required_roles
super(JWTBearer, self).__init__(auto_error=auto_error)
async def __call__(
self, request: Request, db: AsyncSession = Depends(get_db)
) -> AccessToken:
credentials: HTTPAuthorizationCredentials | None = await super(
JWTBearer, self
).__call__(request)
if credentials:
if not credentials.scheme == "Bearer":
raise HTTPException(
status_code=401, detail="authentication_scheme_invalid"
)
try:
token = await token_service.verify_access_token(
credentials.credentials, self.__required_roles
)
if not token:
raise HTTPException(
status_code=401, detail="token_invalid_or_expired"
)
return token
except InsufficientPermissionsError:
raise HTTPException(status_code=403, detail="insufficient_permissions")
except InvalidTokenAudienceError:
raise HTTPException(status_code=403, detail="invalid_token_audience")
else:
raise HTTPException(status_code=401, detail="authorization_code_invalid")

View file

@ -1,66 +0,0 @@
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()

View file

@ -1,83 +0,0 @@
from datetime import datetime, UTC
import secrets
from uuid import UUID
from sqlalchemy import select, delete
from sqlalchemy.orm import Session as SqlaSession
from app.models.session import Session
from app.models.user import User
from app.util.errors import NotFoundError
async def get_sessions(
db: SqlaSession, skip: int = 0, limit: int = 20
) -> tuple[Session]:
stmt = select(Session).offset(skip).limit(limit)
result = db.execute(stmt)
return result.scalars().all()
async def get_sessions_by_user(db: SqlaSession, user_id: UUID) -> tuple[Session]:
stmt = select(Session).where(Session.user_id == user_id)
result = db.execute(stmt)
return result.scalars().all()
async def create_session(db: SqlaSession, user: User, useragent: str) -> Session:
session = Session(
name=useragent,
refresh_token=secrets.token_urlsafe(64),
last_used=datetime.now(UTC),
user_id=user.id,
)
db.add(session)
db.commit()
db.refresh(session)
return session
async def remove_session(db: SqlaSession, id: UUID):
session = db.get(Session, id)
if not session:
raise NotFoundError
db.delete(session)
db.commit()
async def remove_session_for_user(
db: SqlaSession, id: UUID, user_id: UUID
):
stmt = select(Session).where(Session.id == id and Session.user_id == user_id)
result = db.execute(stmt)
session = result.scalars().first()
if not session:
raise NotFoundError
db.delete(session)
db.commit()
async def remove_all_sessions_for_user(db: SqlaSession, user_id: UUID):
stmt = delete(Session).where(Session.user_id == user_id)
db.execute(stmt)
db.commit()
async def remove_all_sessions(db: SqlaSession):
stmt = delete(Session)
db.execute(stmt)
db.commit()
async def validate_and_rotate_refresh_token(
db: SqlaSession, refresh_token: str
) -> Session:
stmt = select(Session).where(Session.refresh_token == refresh_token)
result = db.execute(stmt)
session = result.scalars().first()
if not session:
raise NotFoundError
session.refresh_token = secrets.token_urlsafe(64)
session.last_used = datetime.now(UTC)
db.commit()
return session

View file

@ -1,69 +0,0 @@
import json
import os
import secrets
from typing import Optional
from uuid import UUID
from jwcrypto import jwt, jwk
from datetime import datetime, timedelta, UTC
from app.models.user import User
from app.schemas.auth_token import AccessToken
from app.schemas.user import Role
from app.util.errors import InsufficientPermissionsError, InvalidTokenAudienceError
__signing_key = jwk.JWK.from_password(os.getenv("CS_TOKEN_SECRET", secrets.token_urlsafe(64)))
async def __create_token(claims: dict) -> str:
default_claims = {
"iss": os.getenv("CS_TOKEN_ISSUER", "https://localhost:8000"),
"iat": datetime.now(UTC).timestamp(),
}
header = {"alg": "HS256", "typ": "JWT", "kid": "default"}
token = jwt.JWT(header=header, claims=(claims | default_claims))
token.make_signed_token(__signing_key)
return token.serialize()
async def __verify_token(token: str, audience: str) -> dict | None:
try:
token = jwt.JWT(jwt=token, key=__signing_key)
claims = json.loads(token.claims)
if claims.get("aud") == audience:
return claims
else:
raise InvalidTokenAudienceError
except Exception:
return None
async def create_access_token(
user: User, session_id: UUID
) -> tuple[str, datetime]:
token_lifetime = float(os.getenv("CS_ACCESS_TOKEN_LIFETIME_SECONDS", "300"))
exp_time = datetime.now(UTC) + timedelta(seconds=token_lifetime)
claims = {
"aud": "access",
"sub": str(user.id),
"exp": exp_time.timestamp(),
"session": str(session_id),
"role": str(user.role),
}
return await __create_token(claims=claims), exp_time
async def verify_access_token(
token: str, required_roles: Optional[list[str]] = None
) -> AccessToken | None:
try:
claims = await __verify_token(token=token, audience="access")
if not claims:
return None
if not required_roles or claims.get("role") in required_roles:
return AccessToken(
subject=claims.get("sub"),
role=Role(claims.get("role")),
session=claims.get("session"),
)
else:
raise InsufficientPermissionsError
except InvalidTokenAudienceError:
pass

View file

@ -1,111 +0,0 @@
from uuid import UUID
import uuid
from sqlalchemy import select
from sqlalchemy.orm import Session
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
from app.models.user import User
from app.schemas.user import (
UserCreate,
UserUpdate,
AdministrativeUserUpdate,
PasswordUpdate,
LoginRequest,
)
from app.util.errors import InvalidStateError, NotFoundError
hasher = PasswordHasher(memory_cost=102400)
async def get_user(db: Session, id: UUID):
return db.get(User, id)
async def get_user_by_email(db: Session, email: str):
stmt = select(User).where(User.email == email)
result = db.execute(stmt)
return result.scalars().first()
async def get_users(
db: Session, skip: int = 0, limit: int = 20, email: str = None
):
stmt = select(User)
if email is not None:
stmt = stmt.where(User.email.like(email))
stmt = stmt.offset(skip).limit(limit)
result = db.execute(stmt)
return result.scalars().all()
async def create_user(db: Session, user: UserCreate) -> User:
if await get_user_by_email(db=db, email=user.email):
raise InvalidStateError
hashed_password = hasher.hash(user.password)
db_user = User(
friendly_name=user.friendly_name, email=user.email, password=hashed_password
)
db.add(db_user)
db.commit()
db.refresh(db_user)
return db_user
async def update_user(
db: Session,
id: UUID,
update: UserUpdate | AdministrativeUserUpdate,
) -> User:
db_user = await get_user(db, id)
if db_user is None:
raise NotFoundError
changed_attributes = dict()
for key, value in update.model_dump(exclude_unset=True).items():
changed_attributes[key] = {"old": getattr(db_user, key), "new": value}
setattr(db_user, key, value)
db.commit()
return db_user
async def change_user_password(db: Session, id: UUID, update: PasswordUpdate):
db_user = await get_user(db, id)
if db_user is None:
raise NotFoundError
try:
hasher.verify(hash=db_user.password, password=update.old_password)
db_user.password = hasher.hash(update.new_password)
db.commit()
except VerifyMismatchError:
raise InvalidStateError
async def remove_user(db: Session, id: UUID):
db_user = await get_user(db, id)
if db_user is None:
raise NotFoundError
db.delete(db_user)
db.commit()
async def validate_login(db: Session, login: LoginRequest) -> User | None:
stmt = select(User).where(User.email == login.email)
result = db.execute(stmt)
db_user = result.scalars().first()
if db_user is None:
db.commit()
return None
try:
hasher.verify(hash=db_user.password, password=login.password)
if hasher.check_needs_rehash(db_user.password):
db_user.password = hasher.hash(login.password)
if db_user.is_active:
db.commit()
return db_user
else:
db.commit()
return None
except VerifyMismatchError:
db.commit()
return None

View file

@ -1,27 +0,0 @@
from datetime import datetime, timezone
from decimal import Decimal
from typing import Union
def decimal_encoder(dec_value: Decimal) -> Union[int, float]:
"""Encodes a Decimal as int of there's no exponent, otherwise float.
This is useful when we use ConstrainedDecimal to represent Numeric(x,0)
where a integer (but not int typed) is used. Encoding this as a float
results in failed round-tripping between encode and parse.
Our Id type is a prime example of this.
>>> decimal_encoder(Decimal("1.0"))
1.0
>>> decimal_encoder(Decimal("1"))
1
"""
exponent = dec_value.as_tuple().exponent
if isinstance(exponent, int) and exponent >= 0:
return int(dec_value)
else:
return float(dec_value)
def force_utc_datetime(datetime_value: datetime) -> datetime:
"""Force a datetime to be in the UTC timzone"""
return datetime_value.replace(tzinfo=timezone.utc)

View file

@ -1,14 +0,0 @@
class NotFoundError(Exception):
pass
class InvalidStateError(Exception):
pass
class InsufficientPermissionsError(Exception):
pass
class InvalidTokenAudienceError(Exception):
pass

2081
backend/poetry.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,12 +0,0 @@
{
"folders": [
{
"name": "Backend",
"path": "../backend"
},
{
"name": "Root",
"path": "../"
}
]
}

23
frontend/.gitignore vendored
View file

@ -1,23 +0,0 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

View file

@ -1 +0,0 @@
engine-strict=true

View file

@ -1,4 +0,0 @@
# Package Managers
package-lock.json
pnpm-lock.yaml
yarn.lock

View file

@ -1,11 +0,0 @@
{
"trailingComma": "es5",
"semi": false,
"singleQuote": true,
"useTabs": false,
"tabWidth": 2,
"quoteProps": "consistent",
"bracketSpacing": true,
"arrowParens": "always",
"printWidth": 100
}

View file

@ -1,38 +0,0 @@
# sv
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```bash
# create a new project in the current directory
npx sv create
# create a new project in my-app
npx sv create my-app
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```bash
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```bash
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.

View file

@ -1,39 +0,0 @@
import prettier from 'eslint-config-prettier'
import js from '@eslint/js'
import { includeIgnoreFile } from '@eslint/compat'
import svelte from 'eslint-plugin-svelte'
import globals from 'globals'
import { fileURLToPath } from 'node:url'
import ts from 'typescript-eslint'
import svelteConfig from './svelte.config.js'
const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url))
export default ts.config(
includeIgnoreFile(gitignorePath),
js.configs.recommended,
...ts.configs.recommended,
...svelte.configs.recommended,
prettier,
...svelte.configs.prettier,
{
languageOptions: {
globals: {
...globals.browser,
...globals.node,
},
},
},
{
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
ignores: ['eslint.config.js', 'svelte.config.js'],
languageOptions: {
parserOptions: {
projectService: true,
extraFileExtensions: ['.svelte'],
parser: ts.parser,
svelteConfig,
},
},
}
)

File diff suppressed because it is too large Load diff

View file

@ -1,46 +0,0 @@
{
"name": "frontend",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"format": "prettier --write .",
"lint": "prettier --check . && eslint ."
},
"devDependencies": {
"@eslint/compat": "^1.2.5",
"@eslint/js": "^9.18.0",
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.16.0",
"@sveltejs/vite-plugin-svelte": "^6.1.0",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-svelte": "^3.0.0",
"globals": "^16.0.0",
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.3",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"typescript": "^5.0.0",
"typescript-eslint": "^8.20.0",
"vite": "^7.0.6"
},
"dependencies": {
"@tailwindcss/vite": "^4.0.14",
"axios": "^1.8.4",
"bootstrap-icons": "^1.11.3",
"daisyui": "^5.0.3",
"i18next": "^25.3.2",
"i18next-browser-languagedetector": "^8.0.4",
"i18next-chained-backend": "^4.6.2",
"i18next-fetch-backend": "^6.0.0",
"i18next-localstorage-backend": "^4.2.0",
"tailwindcss": "^4.0.14"
}
}

View file

@ -1,29 +0,0 @@
{
"folders": [
{
"name": "Frontend",
"path": "../frontend",
},
{
"name": "Root",
"path": "../",
},
],
"settings": {
"editor.formatOnSave": true,
"editor.tabSize": 2,
"editor.detectIndentation": false,
"i18n-ally.enabledFrameworks": ["i18next", "svelte"],
"i18n-ally.localesPaths": ["static/locales"],
"i18n-ally.keystyle": "nested",
"i18n-ally.namespace": true,
"i18n-ally.editor.preferEditor": true,
"i18n-ally.refactor.templates": [
{
"templates": ["{{ t('{key}'{args}) }}"],
},
],
"i18n-ally.sourceLanguage": "en",
"i18n-ally.displayLanguage": "en",
},
}

View file

@ -1,49 +0,0 @@
@import 'tailwindcss';
@import 'bootstrap-icons';
@plugin "daisyui" {
themes:
emerald --default;
}
@plugin "daisyui/theme" {
name: "emerald";
--radius-selector: 0.5rem;
--radius-field: 0.25rem;
--radius-box: 0.5rem;
}
@plugin "daisyui/theme" {
name: "darkgray";
default: false;
prefersdark: true;
color-scheme: "light";
--color-base-100: oklch(37% 0.034 259.733);
--color-base-200: oklch(27% 0.033 256.848);
--color-base-300: oklch(21% 0.006 285.885);
--color-base-content: oklch(96% 0.001 286.375);
--color-primary: oklch(70% 0.14 182.503);
--color-primary-content: oklch(98% 0.014 180.72);
--color-secondary: oklch(65% 0.241 354.308);
--color-secondary-content: oklch(94% 0.028 342.258);
--color-accent: oklch(58% 0.233 277.117);
--color-accent-content: oklch(96% 0.018 272.314);
--color-neutral: oklch(20% 0 0);
--color-neutral-content: oklch(96% 0.001 286.375);
--color-info: oklch(74% 0.16 232.661);
--color-info-content: oklch(95% 0.026 236.824);
--color-success: oklch(76% 0.177 163.223);
--color-success-content: oklch(26% 0.051 172.552);
--color-warning: oklch(82% 0.189 84.429);
--color-warning-content: oklch(27% 0.077 45.635);
--color-error: oklch(64% 0.246 16.439);
--color-error-content: oklch(96% 0.015 12.422);
--radius-selector: 0.5rem;
--radius-field: 0.25rem;
--radius-box: 0.5rem;
--size-selector: 0.25rem;
--size-field: 0.25rem;
--border: 1px;
--depth: 0;
--noise: 0;
}

13
frontend/src/app.d.ts vendored
View file

@ -1,13 +0,0 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {}

View file

@ -1,12 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View file

@ -1,108 +0,0 @@
import axios from 'axios';
import { dev } from '$app/environment';
import { goto } from '$app/navigation';
import { get } from 'svelte/store';
import { persistentSettings, clearLoginState } from '$lib/persistent_store';
if (dev) {
axios.defaults.baseURL = "http://localhost:8000/api/v1"
} else {
axios.defaults.baseURL = "/api/v1"
}
// Get access token from local storage
axios.defaults.headers.common['Authorization'] = "Bearer " + get(persistentSettings).accessToken;
function createTokenRefreshInterceptor() {
const interceptor = axios.interceptors.response.use(
(response) => response,
(error) => {
// Reject promise if usual error
if (error.response.status !== 401) {
return Promise.reject(error);
}
/*
* When response code is 401, try to refresh the token.
* Eject the interceptor so it doesn't loop in case
* token refresh causes the 401 response.
*
* Must be re-attached later on or the token refresh will only happen once
*/
axios.interceptors.response.eject(interceptor);
return axios
.post("/auth/refresh", {
refresh_token: get(persistentSettings).refreshToken,
})
.then((response) => {
// Save new tokens
persistentSettings.update(settings => {
settings.accessToken = response.data.access_token
settings.refreshToken = response.data.refresh_token;
return settings;
})
// Update access token
const authHeader = "Bearer " + response.data.access_token;
axios.defaults.headers.common['Authorization'] = authHeader;
error.response.config.headers["Authorization"] = authHeader;
// Retry initial request with new token
return axios(error.response.config);
})
.catch((retryError) => {
// Retry failed, clean up and reject the promise
clearLoginState();
axios.defaults.headers.common['Authorization'] = "";
goto('#/login?reauth')
return Promise.reject(retryError);
})
.finally(createTokenRefreshInterceptor); // Re-attach interceptor for future requests
}
);
}
createTokenRefreshInterceptor();
export const login = async function(email: string, password: string) {
await axios
.post('/auth/login', {
email,
password,
})
.then((response) => {
persistentSettings.update(settings => {
settings.loggedIn = true
settings.accessToken = response.data.access_token
settings.refreshToken = response.data.refresh_token
return settings;
})
axios.defaults.headers.common['Authorization'] = 'Bearer ' + response.data.access_token
axios.get('/me').then((response) => {
persistentSettings.update(settings => {
settings.email = response.data.email
settings.friendlyName = response.data.friendly_name
settings.role = response.data.role
return settings;
})
})
goto('/')
return Promise.resolve();
})
.catch((error) => {
return Promise.reject(error);
})
}
export const logout = function() {
axios
.post('/auth/logout')
.then(() => {
clearLoginState();
axios.defaults.headers.common['Authorization'] = "";
goto('#/login?logout')
});
}
export default axios;

View file

@ -1,51 +0,0 @@
<script lang="ts">
import i18n from '$lib/i18n'
let props: {
title: string
icon: string
value: number
previousValue: number
unit?: string
} = $props()
let diff = $derived(
Math.round(((props.previousValue - props.value) / props.previousValue) * 100 * 10 * -1) / 10
)
</script>
<div class="card bg-base-100 shadow rounded-md basis-0 grow">
<div class="card-body gap-2">
<div class="flex items-start justify-between gap-2 text-sm">
<div>
<p class="text-base-content/80 font-medium">
{props.title}
</p>
<div class="mt-3 flex items-center gap-2">
<p class="text-2xl font-semibold">
{props.value}{#if props.unit}{' ' + props.unit}{/if}
</p>
{#if !Number.isNaN(diff)}
{#if diff > 0}
<div class="badge badge-outline badge-success badge-sm gap-0.5 px-1 font-medium">
<i class="bi bi-arrow-up-right"></i>{diff}%
</div>
{:else if diff < 0}
<div class="badge badge-outline badge-error badge-sm gap-0.5 px-1 font-medium">
<i class="bi bi-arrow-down-right"></i>{diff}%
</div>
{:else}
<div class="badge badge-outline badge-warning badge-sm gap-0.5 px-1 font-medium">
<i class="bi bi-arrow-right"></i>{diff}%
</div>
{/if}
{/if}
</div>
</div>
<i class="bi {props.icon} text-primary text-4xl"></i>
</div>
<p class="text-base-content/60 text-sm">
{$i18n.t('dashboard:cards.lastMonth', { val: props.previousValue, unit: props.unit })}
</p>
</div>
</div>

View file

@ -1,89 +0,0 @@
<script lang="ts">
import i18n from '$lib/i18n'
import type { Transaction } from '$lib/types/transaction'
let props: {
transactions: Transaction[]
} = $props()
</script>
<div class="overflow-x-auto mt-4 bg-base-100 rounded-md">
<table class="table">
<thead>
<tr>
<th>{$i18n.t('common:transactionTable.headerDate')}</th>
<th>{$i18n.t('common:transactionTable.headerChargepoint')}</th>
<th>{$i18n.t('common:transactionTable.headerEnergyTotal')}</th>
<th>{$i18n.t('common:transactionTable.headerCost')}</th>
<th></th>
</tr>
</thead>
<tbody>
{#if props.transactions.length > 0}
{#each props.transactions as transaction}
<tr>
<td>
<div class="flex items-center gap-3">
<div>
<p>
{$i18n.t('common:transactionTable.startTime', {
time: new Date(transaction.started_at),
formatParams: {
time: {
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
second: 'numeric',
},
},
})}
</p>
<p>
{$i18n.t('common:transactionTable.endTime', {
time: new Date(transaction.ended_at!),
formatParams: {
time: {
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
second: 'numeric',
},
},
})}
</p>
</div>
</div>
</td>
<td>
<a href="#/chargepoint/{transaction.chargepoint.id}" class="btn btn-sm btn-primary">
<i class="bi bi-plug-fill text-lg"></i>
{transaction.chargepoint.identity}
</a>
</td>
<td class="font-bold"
>{(transaction.meter_end - transaction.meter_start).toFixed(2)} kWh</td
>
<td class="font-bold"
>{((transaction.meter_end - transaction.meter_start) * transaction.price).toFixed(2)}</td
>
<th>
<a href="#/transaction/{transaction.id}" class="btn btn-sm btn-primary">
{$i18n.t('common:transactionTable.detailButton')}
<i class="bi bi-arrow-right"></i>
</a>
</th>
</tr>
{/each}
{/if}
</tbody>
</table>
{#if props.transactions.length === 0}
<p class="w-full mt-15 mb-15 text-center text-xl font-bold">
{$i18n.t('common:transactionTable.noPreviousTransactions')}
</p>
{/if}
</div>

View file

@ -1,32 +0,0 @@
import i18next from 'i18next'
import Backend from 'i18next-chained-backend'
import Fetch from 'i18next-fetch-backend'
import LocalStorageBackend from 'i18next-localstorage-backend'
import I18nextBrowserLanguageDetector from 'i18next-browser-languagedetector'
import { createI18nStore } from './i18n_store'
i18next
.use(Backend)
.use(I18nextBrowserLanguageDetector)
.init({
supportedLngs: ['en', 'de'],
ns: ['common'],
defaultNS: 'common',
backend: {
backends: [LocalStorageBackend, Fetch],
backendOptions: [
{
expirationTime: 24 * 60 * 60 * 1000,
},
{
loadPath: '/locales/{{lng}}/{{ns}}.json',
},
],
},
interpolation: {
escapeValue: false,
},
})
const i18n = createI18nStore(i18next)
export default i18n

View file

@ -1,55 +0,0 @@
import type { i18n } from 'i18next'
import { writable, type Readable, type Writable } from 'svelte/store'
export interface TranslationService {
i18n: Readable<i18n>
}
export const isLoading = writable(true)
export class I18NextTranslationStore implements TranslationService {
public i18n: Writable<i18n>
public isLoading: Writable<boolean>
constructor(i18n: i18n) {
this.i18n = this.createInstance(i18n)
this.isLoading = this.createLoadingInstance(i18n)
}
private createInstance(i18n: i18n): Writable<i18n> {
const i18nWritable = writable(i18n)
i18n.on('initialized', () => {
i18nWritable.set(i18n)
})
i18n.on('loaded', () => {
i18nWritable.set(i18n)
})
i18n.on('added', () => i18nWritable.set(i18n))
i18n.on('languageChanged', () => {
i18nWritable.set(i18n)
})
return i18nWritable
}
private createLoadingInstance(i18n: i18n): Writable<boolean> {
// if loaded resources are empty || {}, set loading to true
i18n.on('loaded', (resources) => {
if (Object.keys(resources).length !== 0) {
isLoading.set(false)
}
})
// if resources failed loading, set loading to true
i18n.on('failedLoading', () => {
isLoading.set(true)
})
return isLoading
}
}
export const createI18nStore = (i18n: i18n) => {
const i18nStore = new I18NextTranslationStore(i18n)
return i18nStore.i18n
}

View file

@ -1,37 +0,0 @@
import { writable } from 'svelte/store'
interface PersistedSettings {
darkmode: boolean
loggedIn: boolean
friendlyName: string
email: string
role: string
accessToken: string,
refreshToken: string
}
const settingsDefault: PersistedSettings = {
darkmode: false,
loggedIn: false,
friendlyName: "",
email: "",
role: "member",
accessToken: "",
refreshToken: ""
}
export const persistentSettings = writable<PersistedSettings>(JSON.parse(localStorage.getItem('persistentSettings') || JSON.stringify(settingsDefault)))
persistentSettings.subscribe((value) => localStorage.persistentSettings = JSON.stringify(value))
export const clearLoginState = function() {
persistentSettings.update(settings => {
settings.accessToken = "";
settings.refreshToken = "";
settings.loggedIn = false;
settings.friendlyName = "";
settings.email = "";
settings.role = "member";
return settings;
})
}

View file

@ -1,25 +0,0 @@
export type ChargePoint = {
identity: string;
is_active: boolean;
price: number;
id: string;
last_seen: string;
vendor_name: string;
model: string;
serial_number: string,
firmware_version: string;
connectors: [
{
id: string;
evse: number;
index: number;
status: 'Available' | 'Occupied' | 'Reserved' | 'Unavailable' | 'Faulted'
}
]
}
export type ChargePointThumb = {
id: string;
identity: string;
price: number;
}

View file

@ -1,14 +0,0 @@
import type { Transaction } from "./transaction";
export type Dashboard = {
stats: {
transaction_count: number;
transaction_count_previous: number;
transaction_energy_total: number;
transaction_energy_total_previous: number;
transaction_cost_total: number;
transaction_cost_total_previous: number;
};
current_transaction: Transaction | undefined;
recent_transactions: Transaction[];
}

View file

@ -1,17 +0,0 @@
import type { ChargePointThumb } from "./chargepoint";
import type { UserThumb } from "./user";
export type TransactionEventTriggerReason = 'Authorized' | 'CablePluggedIn' | 'ChargingRateChanged' | 'ChargingStateChanged' | 'Deauthorized' | 'EnergyLimitReached' | 'EVCommunicationLost' | 'EVConnectTimeout' | 'MeterValueClock' | 'MeterValuePeriodic' | 'TimeLimitReached' | 'Trigger' | 'UnlockCommand' | 'StopAuthorized' | 'EVDeparted' | 'EVDetected' | 'RemoteStop' | 'RemoteStart' | 'AbnormalCondition' | 'SignedDataReceived' | 'ResetCommand';
export type Transaction = {
id: string;
status: 'ongoing' | 'ended';
started_at: string;
ended_at: string | undefined;
meter_start: number;
meter_end: number;
end_reason: TransactionEventTriggerReason;
price: number;
user: UserThumb;
chargepoint: ChargePointThumb;
}

View file

@ -1,4 +0,0 @@
export type UserThumb = {
id: string;
friendly_name: string;
}

Some files were not shown because too many files have changed in this diff Show more