Prepare monorepo

This commit is contained in:
Oliver Traber 2025-03-13 22:11:20 +01:00
parent a1ddb43ed0
commit 938582155d
Signed by: Bluemedia
GPG key ID: C0674B105057136C
61 changed files with 5 additions and 5 deletions

View file

@ -0,0 +1,24 @@
// 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": "/workspace",
"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

@ -0,0 +1,19 @@
services:
app:
image: mcr.microsoft.com/devcontainers/python:3.12-bookworm
volumes:
- ..:/workspace: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:

3
backend/.gitignore vendored Normal file
View file

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

113
backend/alembic.ini Normal file
View file

@ -0,0 +1,113 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
script_location = alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
file_template = %%(year)d%%(month).2d%%(day).2d_%%(rev)s-%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python>=3.9 or backports.zoneinfo library.
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
# string value is passed to ZoneInfo()
# leave blank for localtime
timezone = UTC
# max length of characters to apply to the
# "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to alembic/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "version_path_separator" below.
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
# version path separator; As mentioned above, this is the character used to split
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
# Valid values for version_path_separator are:
#
# version_path_separator = :
# version_path_separator = ;
# version_path_separator = space
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
output_encoding = utf-8
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
# hooks = ruff
# ruff.type = exec
# ruff.executable = %(here)s/.venv/bin/ruff
# ruff.options = --fix REVISION_SCRIPT_FILENAME
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

62
backend/alembic/env.py Normal file
View file

@ -0,0 +1,62 @@
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 *
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
SQLALCHEMY_DATABASE_URL = os.getenv("CS_DATABASE_URL", "sqlite:///./simple-ocpp-cs.db")
if SQLALCHEMY_DATABASE_URL.startswith("sqlite"):
connectable = create_engine(
SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}
)
else:
connectable = create_engine(SQLALCHEMY_DATABASE_URL)
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
raise NotImplementedError("Offline migration is not supported")
else:
run_migrations_online()

View file

@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View file

@ -0,0 +1,146 @@
"""Initial migration
Revision ID: 097d427dfa07
Revises:
Create Date: 2024-04-20 11:30:32.425878+00:00
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '097d427dfa07'
down_revision: Union[str, None] = None
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('users',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('friendly_name', sa.String(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_users_friendly_name'), 'users', ['friendly_name'], unique=True)
op.create_table('chargepoints',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('identity', sa.String(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=True),
sa.Column('password', sa.String(), nullable=True),
sa.Column('price', sa.Numeric(precision=10, scale=2), nullable=True),
sa.Column('last_seen', sa.DateTime(), nullable=True),
sa.Column('vendor_name', sa.String(), nullable=True),
sa.Column('model', sa.String(), nullable=True),
sa.Column('serial_number', sa.String(), nullable=True),
sa.Column('firmware_version', sa.String(), nullable=True),
sa.Column('learn_user_id', sa.Uuid(), nullable=True),
sa.Column('learn_until', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['learn_user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_chargepoints_identity'), 'chargepoints', ['identity'], unique=True)
op.create_table('id_tokens',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('friendly_name', sa.String(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=True),
sa.Column('token', sa.String(), nullable=True),
sa.Column('owner_id', sa.Uuid(), nullable=True),
sa.ForeignKeyConstraint(['owner_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_id_tokens_token'), 'id_tokens', ['token'], unique=False)
op.create_table('chargepoint_variables',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('name', sa.String(), nullable=True),
sa.Column('type', sa.Enum('ACTUAL', 'TARGET', 'MIN_SET', 'MAX_SET', name='attributetype'), nullable=True),
sa.Column('value', sa.String(), nullable=True),
sa.Column('mutability', sa.Enum('READ_ONLY', 'WRITE_ONLY', 'READ_WRITE', name='mutabilitytype'), nullable=True),
sa.Column('persistent', sa.Boolean(), nullable=True),
sa.Column('constant', sa.Boolean(), nullable=True),
sa.Column('unit', sa.String(), nullable=True),
sa.Column('data_type', sa.Enum('STRING', 'DECIMAL', 'INTEGER', 'DATETIME', 'BOOLEAN', 'OPTION_LIST', 'SEQUENCE_LIST', 'MEMBER_LIST', name='datatype'), nullable=True),
sa.Column('min_limit', sa.Numeric(), nullable=True),
sa.Column('max_limit', sa.Numeric(), nullable=True),
sa.Column('values_list', sa.String(), nullable=True),
sa.Column('component_name', sa.String(), nullable=True),
sa.Column('component_instance', sa.String(), nullable=True),
sa.Column('evse', sa.Integer(), nullable=True),
sa.Column('connector_id', sa.Integer(), 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_chargepoint_variables_chargepoint_id'), 'chargepoint_variables', ['chargepoint_id'], unique=False)
op.create_table('connectors',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('evse', sa.Integer(), nullable=True),
sa.Column('index', sa.Integer(), nullable=True),
sa.Column('status', sa.Enum('AVAILABLE', 'OCCUPIED', 'RESERVED', 'UNAVAILABLE', 'FAULTED', name='connectorstatus'), nullable=True),
sa.Column('chargepoint_id', sa.Uuid(), nullable=True),
sa.ForeignKeyConstraint(['chargepoint_id'], ['chargepoints.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('transactions',
sa.Column('id', sa.String(), nullable=False),
sa.Column('status', sa.Enum('ONGOING', 'ENDED', name='transactionstatus'), nullable=True),
sa.Column('started_at', sa.DateTime(), nullable=True),
sa.Column('ended_at', sa.DateTime(), nullable=True),
sa.Column('meter_start', sa.Numeric(precision=10, scale=2), nullable=True),
sa.Column('meter_end', sa.Numeric(precision=10, scale=2), nullable=True),
sa.Column('end_reason', sa.Enum('AUTHORIZED', 'CABLE_PLUGGED_IN', 'CHARGING_RATE_CHANGED', 'CHARGING_STATE_CHANGED', 'DEAUTHORIZED', 'ENERGY_LIMIT_REACHED', 'EV_COMMUNICATION_LOST', 'EV_CONNECT_TIMEOUT', 'METER_VALUE_CLOCK', 'METER_VALUE_PERIODIC', 'TIME_LIMIT_REACHED', 'TRIGGER', 'UNLOCK_COMMAND', 'STOP_AUTHORIZED', 'EV_DEPARTED', 'EV_DETECTED', 'REMOTE_STOP', 'REMOTE_START', 'ABNORMAL_CONDITION', 'SIGNED_DATA_RECEIVED', 'RESET_COMMAND', name='transactioneventtriggerreason'), nullable=True),
sa.Column('price', sa.Numeric(precision=10, scale=2), nullable=True),
sa.Column('user_id', sa.Uuid(), nullable=True),
sa.Column('chargepoint_id', sa.Uuid(), nullable=True),
sa.ForeignKeyConstraint(['chargepoint_id'], ['chargepoints.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_transactions_chargepoint_id'), 'transactions', ['chargepoint_id'], unique=False)
op.create_index(op.f('ix_transactions_ended_at'), 'transactions', ['ended_at'], unique=False)
op.create_index(op.f('ix_transactions_started_at'), 'transactions', ['started_at'], unique=False)
op.create_index(op.f('ix_transactions_status'), 'transactions', ['status'], unique=False)
op.create_index(op.f('ix_transactions_user_id'), 'transactions', ['user_id'], unique=False)
op.create_table('meter_values',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('timestamp', sa.DateTime(), nullable=True),
sa.Column('measurand', sa.Enum('CURRENT_EXPORT', 'CURRENT_IMPORT', 'CURRENT_OFFERED', 'ENERGY_ACTIVE_NET', 'ENERGY_ACTIVE_EXPORT_REGISTER', 'ENERGY_ACTIVE_IMPORT_REGISTER', 'ENERGY_ACTIVE_EXPORT_INTERVAL', 'ENERGY_ACTIVE_IMPORT_INTERVAL', 'ENERGY_REACTIVE_NET', 'ENERGY_REACTIVE_EXPORT_REGISTER', 'ENERGY_REACTIVE_IMPORT_REGISTER', 'ENERGY_REACTIVE_EXPORT_INTERVAL', 'ENERGY_REACTIVE_IMPORT_INTERVAL', 'ENERGY_APPARENT_NET', 'ENERGY_APPARENT_IMPORT', 'ENERGY_APPARENT_EXPORT', 'FREQUENCY', 'POWER_ACTIVE_EXPORT', 'POWER_ACTIVE_IMPORT', 'POWER_FACTOR', 'POWER_OFFERED', 'POWER_REACTIVE_EXPORT', 'POWER_REACTIVE_IMPORT', 'SOC', 'VOLTAGE', name='measurand'), nullable=True),
sa.Column('phase_type', sa.Enum('L1', 'L2', 'L3', 'N', 'L1_N', 'L2_N', 'L3_N', 'L1_L2', 'L2_L3', 'L3_L1', name='phasetype'), nullable=True),
sa.Column('unit', sa.String(), nullable=True),
sa.Column('value', sa.Float(), nullable=True),
sa.Column('transaction_id', sa.String(), nullable=True),
sa.ForeignKeyConstraint(['transaction_id'], ['transactions.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_meter_values_measurand'), 'meter_values', ['measurand'], unique=False)
op.create_index(op.f('ix_meter_values_timestamp'), 'meter_values', ['timestamp'], unique=False)
op.create_index(op.f('ix_meter_values_transaction_id'), 'meter_values', ['transaction_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_meter_values_transaction_id'), table_name='meter_values')
op.drop_index(op.f('ix_meter_values_timestamp'), table_name='meter_values')
op.drop_index(op.f('ix_meter_values_measurand'), table_name='meter_values')
op.drop_table('meter_values')
op.drop_index(op.f('ix_transactions_user_id'), table_name='transactions')
op.drop_index(op.f('ix_transactions_status'), table_name='transactions')
op.drop_index(op.f('ix_transactions_started_at'), table_name='transactions')
op.drop_index(op.f('ix_transactions_ended_at'), table_name='transactions')
op.drop_index(op.f('ix_transactions_chargepoint_id'), table_name='transactions')
op.drop_table('transactions')
op.drop_table('connectors')
op.drop_index(op.f('ix_chargepoint_variables_chargepoint_id'), table_name='chargepoint_variables')
op.drop_table('chargepoint_variables')
op.drop_index(op.f('ix_id_tokens_token'), table_name='id_tokens')
op.drop_table('id_tokens')
op.drop_index(op.f('ix_chargepoints_identity'), table_name='chargepoints')
op.drop_table('chargepoints')
op.drop_index(op.f('ix_users_friendly_name'), table_name='users')
op.drop_table('users')
# ### end Alembic commands ###

View file

@ -0,0 +1,59 @@
"""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
# 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))
op.add_column('users', sa.Column('role', sa.Enum('MEMBER', 'ADMINISTRATOR', name='role'), 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 ###

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

88
backend/app/cli.py Normal file
View file

@ -0,0 +1,88 @@
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
from app.models import *
load_dotenv()
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)

24
backend/app/database.py Normal file
View file

@ -0,0 +1,24 @@
import os
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
SQLALCHEMY_DATABASE_URL = os.getenv("CS_DATABASE_URL", "sqlite:///./simple-ocpp-cs.db")
if SQLALCHEMY_DATABASE_URL.startswith("sqlite"):
engine = create_engine(
SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}
)
else:
engine = create_engine(SQLALCHEMY_DATABASE_URL)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

51
backend/app/main.py Normal file
View file

@ -0,0 +1,51 @@
from dotenv import load_dotenv
from fastapi import APIRouter, FastAPI
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_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(
title="simple-ocpp-cs",
summary="Simple implementation of a basic OCPP 2.0.1 compliant central system (backend) for EV charging stations",
responses={404: {"description": "Not found"}},
)
api_v1_router = APIRouter(
prefix="/api/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)
app.include_router(api_v1_router)
app.mount(path="/v1/ocpp", app=create_ocpp_app())
return app
app = create_app()

View file

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

View file

@ -0,0 +1,27 @@
import uuid
from sqlalchemy import ForeignKey, Numeric, Uuid, Boolean, Column, DateTime, String
from sqlalchemy.orm import relationship
from app.database import Base
class ChargePoint(Base):
__tablename__ = "chargepoints"
id = Column(Uuid, primary_key=True, default=uuid.uuid4)
identity = Column(String, unique=True, index=True)
is_active = Column(Boolean, default=True)
password = Column(String)
price = Column(Numeric(10, 2))
last_seen = Column(DateTime, nullable=True)
vendor_name = Column(String, nullable=True)
model = Column(String, nullable=True)
serial_number = Column(String, nullable=True)
firmware_version = Column(String, nullable=True)
learn_user_id = Column(Uuid, ForeignKey("users.id"), nullable=True)
learn_until = Column(DateTime, nullable=True)
connectors = relationship("Connector", cascade="delete, delete-orphan")
transactions = relationship("Transaction", cascade="delete, delete-orphan")
variables = relationship("ChargepointVariable", cascade="delete, delete-orphan")

View file

@ -0,0 +1,29 @@
from dataclasses import dataclass
import uuid
from sqlalchemy import ForeignKey, Integer, Numeric, Uuid, Boolean, Column, String, Enum
from app.database import Base
from app.schemas.chargepoint_variable import AttributeType, MutabilityType, DataType
@dataclass
class ChargepointVariable(Base):
__tablename__ = "chargepoint_variables"
id = Column(Uuid, primary_key=True, default=uuid.uuid4)
name = Column(String)
type = Column(Enum(AttributeType), default=AttributeType.ACTUAL)
value = Column(String, nullable=True)
mutability = Column(Enum(MutabilityType), default=MutabilityType.READ_WRITE)
persistent = Column(Boolean, default=False)
constant = Column(Boolean, default=False)
unit = Column(String, nullable=True)
data_type = Column(Enum(DataType), nullable=True)
min_limit = Column(Numeric, nullable=True)
max_limit = Column(Numeric, nullable=True)
values_list = Column(String, nullable=True)
component_name = Column(String)
component_instance = Column(String, nullable=True)
evse = Column(Integer, nullable=True)
connector_id = Column(Integer, nullable=True)
chargepoint_id = Column(Uuid, ForeignKey("chargepoints.id"), index=True)

View file

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

View file

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

View file

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

View file

@ -0,0 +1,14 @@
import uuid
from sqlalchemy import Column, DateTime, ForeignKey, String, Uuid
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, unique=True, index=True)
last_used = Column(DateTime(timezone=True))
user_id = Column(Uuid, ForeignKey("users.id"), index=True)

View file

@ -0,0 +1,19 @@
from sqlalchemy import String, Uuid, Column, DateTime, Enum, Numeric, ForeignKey
from app.schemas.transaction import TransactionEventTriggerReason, TransactionStatus
from app.database import Base
class Transaction(Base):
__tablename__ = "transactions"
id = Column(String, primary_key=True)
status = Column(Enum(TransactionStatus), index=True)
started_at = Column(DateTime, index=True)
ended_at = Column(DateTime, nullable=True, index=True)
meter_start = Column(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)
chargepoint_id = Column(Uuid, ForeignKey("chargepoints.id"), index=True)

View file

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

View file

View file

@ -0,0 +1,118 @@
from datetime import datetime, UTC
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 app.services import (
variable_service,
id_token_service,
chargepoint_service,
transaction_service
)
class ChargePoint(cp):
@on(Action.boot_notification)
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(
current_time=datetime.now(UTC).isoformat(),
interval=int(os.getenv("CS_HEARTBEAT_INTERVAL", "1800")),
status=RegistrationStatusEnumType.accepted
)
@after(Action.boot_notification)
async def after_boot_notification(self, **kwargs):
await self.call(payload=GetBaseReport(request_id=0, report_base="FullInventory"))
@on(Action.notify_report)
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()
@on(Action.heartbeat)
async def on_heartbeat_request(self):
return call_result.Heartbeat(
current_time=datetime.now(UTC).isoformat()
)
@after(Action.heartbeat)
async def after_heartbeat_request(self):
await chargepoint_service.update_last_seen(chargepoint_identity=self.id)
@on(Action.status_notification)
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,
evse_id=evse_id,
connector_id=connector_id,
connector_status=connector_status
)
return call_result.StatusNotification()
@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)
@on(Action.transaction_event)
async def on_transaction_event(
self,
event_type,
timestamp,
trigger_reason,
transaction_info,
**kwargs
):
if "id_token" in kwargs.keys():
id_token_info, token_owner_id = await id_token_service.get_id_token_info(chargepoint_id=self.id, id_token=kwargs['id_token'])
else:
id_token_info = None
token_owner_id = None
if event_type == str(TransactionEventEnumType.started):
await transaction_service.create_transaction(
chargepoint_identity=self.id,
user_id=token_owner_id,
timestamp=datetime.fromisoformat(timestamp),
transaction_info=transaction_info,
transaction_data=kwargs
)
elif event_type == str(TransactionEventEnumType.updated):
await transaction_service.update_transaction(
transaction_id=transaction_info["transaction_id"],
transaction_data=kwargs
)
elif event_type == str(TransactionEventEnumType.ended):
await transaction_service.end_transaction(
transaction_id=transaction_info["transaction_id"],
timestamp=datetime.fromisoformat(timestamp),
trigger_reason=trigger_reason,
transaction_data=kwargs,
user_id=token_owner_id
)
if id_token_info == None:
return call_result.TransactionEvent()
else:
return call_result.TransactionEvent(id_token_info=id_token_info)
@on(Action.meter_values)
async def on_meter_values(self, **kwargs):
return call_result.MeterValues()
@on(Action.security_event_notification)
async def on_security_event_notification(self, **kwargs):
return call_result.SecurityEventNotification()

View file

@ -0,0 +1,33 @@
import logging
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
__active_connections: Dict[UUID, ChargePoint] = {}
async def start(id: UUID, cp: ChargePoint):
try:
__active_connections[id] = cp
await cp.start()
except (ConnectionClosed, WebSocketDisconnect):
logging.info("Charging station '%s' (%s) disconnected", cp.id, id)
__active_connections.pop(id, None)
async def call(
chargepoint_id: UUID,
payload: Any,
suppress: bool = True,
unique_id: Any | None = None
) -> Coroutine[Any, Any, Any | None]:
try:
cp = __active_connections[chargepoint_id]
return await cp.call(payload, suppress, unique_id)
except KeyError as e:
raise e
def is_connected(chargepoint_id: UUID):
return chargepoint_id in __active_connections.keys()

View file

View file

@ -0,0 +1,72 @@
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), initiator=f"user:{token.subject}"
)
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

@ -0,0 +1,295 @@
import random
import string
from datetime import datetime, timedelta, UTC
from uuid import UUID
from fastapi import APIRouter, HTTPException
from fastapi.params import Depends
from sqlalchemy.orm import Session
from ocpp.v201.call import Reset, SetVariables
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,
ChargePointUpdate,
ChargePointPassword,
ChargePointConnectionInfo,
ChargePointResetRequest,
ChargePointResetResponse
)
from app.schemas.id_token import IdTokenLearnRequest, IdTokenLearnResponse
from app.schemas.chargepoint_variable import (
ChargepointVariable,
ChargepointVariableUpdate,
ChargepointVariableResponse,
MutabilityType,
SetVariableStatusType
)
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.security.jwt_bearer import JWTBearer
router = APIRouter(
prefix="/chargepoints",
tags=["Chargepoint (v1)"],
)
@router.get(path="", response_model=list[ChargePoint])
async def get_chargepoints(
skip: int = 0,
limit: int = 20,
db: Session = Depends(get_db),
token: AccessToken = Depends(JWTBearer()),
):
return db.query(DbChargePoint).order_by(DbChargePoint.identity).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()),
):
chargepoint = db.get(DbChargePoint, chargepoint_id)
if chargepoint is None:
raise HTTPException(status_code=404, detail="Chargepoint not found")
return chargepoint
@router.get(path="/{chargepoint_id}/password", response_model=ChargePointPassword)
async def get_chargepoint_password(
chargepoint_id: UUID,
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")
return ChargePointPassword(password=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"])),
):
chargepoint = db.get(DbChargePoint, chargepoint_id)
if chargepoint is None:
raise HTTPException(status_code=404, detail="Chargepoint not found")
chargepoint.password = ''.join(random.choice(string.ascii_letters + string.digits) for i in range(24))
db.commit()
return ChargePointPassword(password=chargepoint.password)
@router.post(path="", status_code=201, response_model=ChargePoint)
async def create_chargepoint(
chargepoint: ChargePointCreate,
db: Session = Depends(get_db),
token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])),
):
chargepoint_db = DbChargePoint(
identity=chargepoint.identity,
is_active=chargepoint.is_active,
password=''.join(random.choice(string.ascii_letters + string.digits) for i in range(24)),
price=chargepoint.price
)
db.add(chargepoint_db)
db.commit()
db.refresh(chargepoint_db)
return chargepoint_db
@router.patch(path="/{chargepoint_id}", response_model=ChargePoint)
async def update_chargepoint(
chargepoint_id: UUID,
chargepoint_update: ChargePointUpdate,
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")
for key, value in chargepoint_update.model_dump(exclude_unset=True).items():
setattr(chargepoint, key, value)
db.commit()
return chargepoint
@router.delete(path="/{chargepoint_id}", response_model=None)
async def delete_chargepoint(
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")
db.delete(chargepoint)
db.commit()
return []
@router.get(path="/{chargepoint_id}/status", response_model=ChargePointConnectionInfo)
async def get_chargepoint_status(
chargepoint_id: UUID,
token: AccessToken = Depends(JWTBearer()),
):
return ChargePointConnectionInfo(
connected=chargepoint_manager.is_connected(chargepoint_id)
)
@router.post(path="/{chargepoint_id}/reset", response_model=ChargePointResetResponse)
async def reset_chargepoint(
chargepoint_id: UUID,
reset_request: ChargePointResetRequest,
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:
response = await chargepoint_manager.call(
chargepoint_id,
payload=Reset(type=reset_request.type, evse_id=reset_request.evse_id)
)
return ChargePointResetResponse(status=response.status)
except TimeoutError:
raise HTTPException(status_code=503, detail="Chargepoint didn't respond in time.")
@router.post(path="/{chargepoint_id}/token-learning", status_code=201, response_model=IdTokenLearnResponse)
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"])),
):
chargepoint = db.get(DbChargePoint, chargepoint_id)
if chargepoint is None:
raise HTTPException(status_code=404, detail="Chargepoint not found")
owner = db.get(DbUser, learn_request.user_id)
if owner == None:
raise HTTPException(status_code=422, detail=[{
"loc": ["body", "user_id"],
"msg": "Target user not found",
"type": "invalid_relation"
}])
chargepoint.learn_user_id = learn_request.user_id
if learn_request.until == None:
chargepoint.learn_until = datetime.now(UTC) + timedelta(minutes=5)
else:
chargepoint.learn_until = learn_request.until
db.commit()
return IdTokenLearnResponse(
user_id=chargepoint.learn_user_id,
until=chargepoint.learn_until
)
@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"])),
):
chargepoint = db.get(DbChargePoint, chargepoint_id)
if chargepoint is None:
raise HTTPException(status_code=404, detail="Chargepoint not found")
if chargepoint.learn_user_id == None:
raise HTTPException(status_code=404, detail="No active learning request")
return IdTokenLearnResponse(
user_id=chargepoint.learn_user_id,
until=chargepoint.learn_until
)
@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"])),
):
chargepoint = db.get(DbChargePoint, chargepoint_id)
if chargepoint is None:
raise HTTPException(status_code=404, detail="Chargepoint not found")
if chargepoint.learn_user_id == None:
raise HTTPException(status_code=404, detail="No active learning request")
chargepoint.learn_user_id = None
chargepoint.learn_until = None
db.commit()
return []
@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"])),
):
chargepoint = db.get(DbChargePoint, chargepoint_id)
if chargepoint is None:
raise HTTPException(status_code=404, detail="Chargepoint not found")
return db.query(DbChargepointVariable).filter(DbChargepointVariable.chargepoint_id == chargepoint_id).all()
@router.put(path="/{chargepoint_id}/variables/{variable_id}", response_model=ChargepointVariableResponse)
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"])),
):
chargepoint = db.get(DbChargePoint, chargepoint_id)
if chargepoint is None:
raise HTTPException(status_code=404, detail="Chargepoint not found")
variable = db.query(DbChargepointVariable).filter(
DbChargepointVariable.chargepoint_id == chargepoint_id,
DbChargepointVariable.id == variable_id
).first()
if variable is None:
raise HTTPException(status_code=404, detail="ChargepointVariable not found")
if variable.mutability == MutabilityType.READ_ONLY:
raise HTTPException(status_code=422, detail="ChargepointVariable is read-only")
variable.value = variable_update.value
if chargepoint_manager.is_connected(chargepoint_id) == False:
raise HTTPException(status_code=503, detail="Chargepoint not connected.")
try:
evse = None
if variable.evse != None:
evse = {
'id': variable.evse
}
if variable.connector_id != None:
evse['connectorId'] = variable.connector_id
result = await chargepoint_manager.call(
chargepoint_id,
payload=SetVariables(set_variable_data=[
{
'attributeType': variable.type.value,
'attributeValue': variable_update.value,
'component': {
'name': variable.component_name,
'instance': variable.component_instance,
'evse': evse
},
'variable': {
'name': variable.name
}
}
])
)
status = result.set_variable_result[0]['attribute_status']
if SetVariableStatusType(status) in [SetVariableStatusType.ACCEPTED, SetVariableStatusType.REBOOT_REQUIRED]:
db.commit()
else:
raise HTTPException(status_code=500, detail=status)
return ChargepointVariableResponse(status=status)
except TimeoutError:
raise HTTPException(status_code=503, detail="Chargepoint didn't respond in time.")

View file

@ -0,0 +1,115 @@
from uuid import UUID
from fastapi import APIRouter, HTTPException
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
router = APIRouter(
prefix="/id-tokens",
tags=["IdToken (v1)"]
)
@router.get(path="", response_model=list[IdToken])
async def get_id_tokens(
skip: int = 0,
limit: int = 20,
db: Session = Depends(get_db),
token: AccessToken = Depends(JWTBearer()),
):
stmt = select(Session)
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()
@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()),
):
stmt = select(DbIdToken).where(DbIdToken.id == id_token_id)
result = db.execute(stmt)
id_token = result.scalars().first()
if id_token == None:
raise HTTPException(status_code=404, detail="IdToken not found")
if token.role != Role.ADMINISTRATOR & 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"])),
):
stmt = select(DbUser).where(DbUser.id == create_id_token.owner_id)
result = db.execute(stmt)
owner = result.scalars().first()
if owner == None:
raise HTTPException(status_code=422, detail=[{
"loc": ["body", "owner_id"],
"msg": "Owner not found",
"type": "invalid_relation"
}])
id_token = DbIdToken(
friendly_name=create_id_token.friendly_name,
is_active=create_id_token.is_active,
token=create_id_token.token,
owner_id=create_id_token.owner_id
)
db.add(id_token)
db.commit()
db.refresh(id_token)
return id_token
@router.patch(path="/{id_token_id}", response_model=IdToken)
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"])),
):
stmt = select(DbIdToken).where(DbIdToken.id == id_token_id)
result = db.execute(stmt)
id_token = result.scalars().first()
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()
if owner == None:
raise HTTPException(status_code=422, detail=[{
"loc": ["body", "owner_id"],
"msg": "Owner not found",
"type": "invalid_relation"
}])
setattr(id_token, key, value)
db.commit()
return 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"])),
):
stmt = select(DbIdToken).where(DbIdToken.id == id_token_id)
result = db.execute(stmt)
id_token = result.scalars().first()
if id_token == None:
raise HTTPException(status_code=404, detail="IdToken not found")
db.delete(id_token)
db.commit()
return []

View file

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

View file

@ -0,0 +1,25 @@
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.schemas.auth_token import AccessToken
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",
tags=["MeterValue (v1)"]
)
@router.get(path="", response_model=list[MeterValue])
async def get_meter_values(
skip: int = 0,
limit: int = 20,
db: Session = Depends(get_db),
token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])),
):
stmt = select(DbMeterValue).order_by(DbMeterValue.timestamp).offset(skip).limit(limit)
result = db.execute(stmt)
return result.scalars().all()

View file

@ -0,0 +1,47 @@
import logging
from fastapi import APIRouter, WebSocket, WebSocketException
from app.ocpp_proto import chargepoint_manager
from app.ocpp_proto.chargepoint import ChargePoint
from app.util.websocket_wrapper import WebSocketWrapper
router = APIRouter()
@router.websocket("/{chargepoint_identity}")
async def websocket_endpoint(
*,
websocket: WebSocket,
chargepoint_identity: str,
):
""" For every new charging station that connects, create a ChargePoint
instance and start listening for messages.
"""
if (websocket.user.identity != chargepoint_identity):
raise WebSocketException(code=1008, reason="Username doesn't match chargepoint identifier")
logging.info("Charging station '%s' (%s) connected", chargepoint_identity, websocket.user.id)
# Check protocols
try:
requested_protocols = websocket.headers['sec-websocket-protocol']
logging.info("Protocols advertised by charging station: %s", requested_protocols)
except KeyError:
logging.warning("Charging station hasn't advertised any subprotocol. "
"Closing Connection")
return await websocket.close()
if "ocpp2.0.1" in requested_protocols:
logging.info("Matched supported protocol: ocpp2.0.1")
else:
logging.warning('Protocols mismatched | Expected subprotocols: %s,'
' but client supports %s | Closing connection',
"ocpp2.0.1",
requested_protocols)
await websocket.accept()
await websocket.close()
return
# Accept connection and begin communication
await websocket.accept(subprotocol="ocpp2.0.1")
cp = ChargePoint(chargepoint_identity, WebSocketWrapper(websocket))
await chargepoint_manager.start(websocket.user.id, cp)

View file

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

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

View file

@ -0,0 +1,21 @@
from dataclasses import dataclass
from datetime import datetime
from pydantic import BaseModel
from app.schemas.user import Role
@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

View file

@ -0,0 +1,47 @@
from datetime import datetime
from decimal import Decimal
from typing import Optional
from uuid import UUID
from pydantic import BaseModel
from app.schemas.connector import Connector
from ocpp.v201.enums import ResetEnumType, ResetStatusEnumType
class ChargePointBase(BaseModel):
identity: str
is_active: bool
price: Decimal
class ChargePointUpdate(BaseModel):
identity: Optional[str] = None
is_active: Optional[bool] = None
price: Optional[Decimal]= None
class ChargePointCreate(ChargePointBase):
pass
class ChargePoint(ChargePointBase):
id: UUID
last_seen: datetime | None
vendor_name: str | None
model: str | None
serial_number: str | None
firmware_version: str | None
connectors: list[Connector] = []
class Config:
from_attributes = True
class ChargePointPassword(BaseModel):
password: str
class ChargePointConnectionInfo(BaseModel):
connected: bool
class ChargePointResetRequest(BaseModel):
type: ResetEnumType
evse_id: Optional[int] = None
class ChargePointResetResponse(BaseModel):
status: ResetStatusEnumType

View file

@ -0,0 +1,60 @@
from decimal import Decimal
from typing import Optional
from uuid import UUID
from pydantic import BaseModel
import enum
class AttributeType(enum.Enum):
ACTUAL = "Actual"
TARGET = "Target"
MIN_SET = "MinSet"
MAX_SET = "MaxSet"
class MutabilityType(enum.Enum):
READ_ONLY = "ReadOnly"
WRITE_ONLY = "WriteOnly"
READ_WRITE = "ReadWrite"
class DataType(enum.Enum):
STRING = "string"
DECIMAL = "decimal"
INTEGER = "integer"
DATETIME = "dateTime"
BOOLEAN = "boolean"
OPTION_LIST = "OptionList"
SEQUENCE_LIST = "SequenceList"
MEMBER_LIST = "MemberList"
class SetVariableStatusType(enum.Enum):
ACCEPTED = "Accepted"
REJECTED = "Rejected"
UNKNOWN_COMPONENT = "UnknownComponent"
NOT_SUPPORTED_ATTRIBUTE_TYPE = "NotSupportedAttributeType"
REBOOT_REQUIRED = "RebootRequired"
class ChargepointVariable(BaseModel):
id: UUID
name: str
type: AttributeType
value: Optional[str] = None
mutability: MutabilityType
persistent: bool
constant: bool
unit: Optional[str] = None
data_type: Optional[DataType] = None
min_limit: Optional[Decimal] = None
max_limit: Optional[Decimal] = None
values_list: Optional[str] = None
component_name: str
component_instance: Optional[str] = None
evse: Optional[int] = None
connector_id: Optional[int] = None
class Config:
from_attributes = True
class ChargepointVariableUpdate(BaseModel):
value: str
class ChargepointVariableResponse(BaseModel):
status: SetVariableStatusType

View file

@ -0,0 +1,19 @@
import enum
from uuid import UUID
from pydantic import BaseModel
class ConnectorStatus(enum.Enum):
AVAILABLE = "Available"
OCCUPIED = "Occupied"
RESERVED = "Reserved"
UNAVAILABLE = "Unavailable"
FAULTED = "Faulted"
class Connector(BaseModel):
id: UUID
evse: int
index: int
status: ConnectorStatus
class Config:
from_attributes = True

View file

@ -0,0 +1,33 @@
from datetime import datetime
from typing import Optional
from uuid import UUID
from pydantic import BaseModel
class IdTokenBase(BaseModel):
friendly_name: str
is_active: bool
owner_id: UUID
token: str
class IdTokenCreate(IdTokenBase):
pass
class IdTokenUpdate(BaseModel):
friendly_name: Optional[str] = None
is_active: Optional[bool] = None
owner_id: Optional[UUID] = None
class IdToken(IdTokenBase):
id: UUID
class Config:
from_attributes = True
class IdTokenLearnBase(BaseModel):
user_id: UUID
class IdTokenLearnRequest(IdTokenLearnBase):
until: Optional[datetime] = None
class IdTokenLearnResponse(IdTokenLearnBase):
until: datetime

View file

@ -0,0 +1,57 @@
from datetime import datetime
from decimal import Decimal
import enum
from typing import Optional
from uuid import UUID
from pydantic import BaseModel
class PhaseType(enum.Enum):
L1 = "L1"
L2 = "L2"
L3 = "L3"
N = "N"
L1_N = "L1-N"
L2_N = "L2-N"
L3_N = "L3-N"
L1_L2 = "L1-L2"
L2_L3 = "L2-L3"
L3_L1 = "L3-L1"
class Measurand(enum.Enum):
CURRENT_EXPORT = "Current.Export"
CURRENT_IMPORT = "Current.Import"
CURRENT_OFFERED = "Current.Offered"
ENERGY_ACTIVE_NET = "Energy.Active.Net"
ENERGY_ACTIVE_EXPORT_REGISTER = "Energy.Active.Export.Register"
ENERGY_ACTIVE_IMPORT_REGISTER = "Energy.Active.Import.Register"
ENERGY_ACTIVE_EXPORT_INTERVAL = "Energy.Active.Export.Interval"
ENERGY_ACTIVE_IMPORT_INTERVAL = "Energy.Active.Import.Interval"
ENERGY_REACTIVE_NET = "Energy.Reactive.Net"
ENERGY_REACTIVE_EXPORT_REGISTER = "Energy.Reactive.Export.Register"
ENERGY_REACTIVE_IMPORT_REGISTER = "Energy.Reactive.Import.Register"
ENERGY_REACTIVE_EXPORT_INTERVAL = "Energy.Reactive.Export.Interval"
ENERGY_REACTIVE_IMPORT_INTERVAL = "Energy.Reactive.Import.Interval"
ENERGY_APPARENT_NET = "Energy.Apparent.Net"
ENERGY_APPARENT_IMPORT = "Energy.Apparent.Import"
ENERGY_APPARENT_EXPORT = "Energy.Apparent.Export"
FREQUENCY = "Frequency"
POWER_ACTIVE_EXPORT = "Power.Active.Export"
POWER_ACTIVE_IMPORT = "Power.Active.Import"
POWER_FACTOR = "Power.Factor"
POWER_OFFERED = "Power.Offered"
POWER_REACTIVE_EXPORT = "Power.Reactive.Export"
POWER_REACTIVE_IMPORT = "Power.Reactive.Import"
SOC = "SoC"
VOLTAGE = "Voltage"
class MeterValue(BaseModel):
id: UUID
timestamp: datetime
measurand: Measurand
phase_type: Optional[PhaseType] = None
unit: Optional[str] = None
value: Decimal
transaction_id: str
class Config:
from_attributes = True

View file

@ -0,0 +1,11 @@
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel
class Session(BaseModel):
id: UUID
name: str
last_used: datetime
model_config = {"from_attributes": True}

View file

@ -0,0 +1,55 @@
from datetime import datetime
from decimal import Decimal
import enum
from typing import Optional
from uuid import UUID
from pydantic import BaseModel
class TransactionStatus(enum.Enum):
ONGOING = "ongoing"
ENDED = "ended"
class TransactionEventTriggerReason(enum.Enum):
AUTHORIZED = "Authorized"
CABLE_PLUGGED_IN = "CablePluggedIn"
CHARGING_RATE_CHANGED = "ChargingRateChanged"
CHARGING_STATE_CHANGED = "ChargingStateChanged"
DEAUTHORIZED = "Deauthorized"
ENERGY_LIMIT_REACHED = "EnergyLimitReached"
EV_COMMUNICATION_LOST = "EVCommunicationLost"
EV_CONNECT_TIMEOUT = "EVConnectTimeout"
METER_VALUE_CLOCK = "MeterValueClock"
METER_VALUE_PERIODIC = "MeterValuePeriodic"
TIME_LIMIT_REACHED = "TimeLimitReached"
TRIGGER = "Trigger"
UNLOCK_COMMAND = "UnlockCommand"
STOP_AUTHORIZED = "StopAuthorized"
EV_DEPARTED = "EVDeparted"
EV_DETECTED = "EVDetected"
REMOTE_STOP = "RemoteStop"
REMOTE_START = "RemoteStart"
ABNORMAL_CONDITION = "AbnormalCondition"
SIGNED_DATA_RECEIVED = "SignedDataReceived"
RESET_COMMAND = "ResetCommand"
class RemoteTransactionStartStopStatus(enum.Enum):
ACCEPTED = "Accepted"
REJECTED = "Rejected"
class Transaction(BaseModel):
id: str
status: TransactionStatus
started_at: datetime
ended_at: Optional[datetime] = None
meter_start: Decimal
meter_end: Optional[Decimal] = None
end_reason: Optional[TransactionEventTriggerReason] = None
price: Decimal
user_id: Optional[UUID] = None
chargepoint_id: UUID
class Config:
from_attributes = True
class RemoteTransactionStartStopResponse(BaseModel):
status: RemoteTransactionStartStopStatus

View file

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

View file

@ -0,0 +1,47 @@
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=403, detail="authentication_scheme_invalid"
)
try:
token = await token_service.verify_access_token(
credentials.credentials, self.__required_roles
)
if not token:
raise HTTPException(
status_code=403, 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=403, detail="authorization_code_invalid")

View file

@ -0,0 +1,33 @@
import base64
import binascii
from starlette.authentication import (
AuthCredentials, AuthenticationBackend, AuthenticationError
)
from app.database import SessionLocal
from app.models.chargepoint import ChargePoint
class BasicAuthBackend(AuthenticationBackend):
async def authenticate(self, conn):
if "Authorization" not in conn.headers:
raise AuthenticationError('No Authorization header provided')
auth = conn.headers["Authorization"]
try:
scheme, credentials = auth.split()
if scheme.lower() != 'basic':
raise AuthenticationError('Invalid authorization scheme')
decoded = base64.b64decode(credentials).decode("ascii")
except (ValueError, UnicodeDecodeError, binascii.Error) as exc:
raise AuthenticationError('Invalid basic auth credentials')
username, _, password = decoded.partition(":")
with SessionLocal() as db:
chargepoint = db.query(ChargePoint).filter(ChargePoint.identity == username).first()
if chargepoint is None:
raise AuthenticationError('Invalid basic auth credentials')
if chargepoint.password != password:
raise AuthenticationError('Invalid basic auth credentials')
return AuthCredentials(["authenticated"]), chargepoint

View file

View file

@ -0,0 +1,46 @@
from datetime import datetime, UTC
from app.database import SessionLocal
from app.models.chargepoint import ChargePoint
from app.models.connector import Connector
from app.schemas.connector import ConnectorStatus
async def update_last_seen(chargepoint_identity: str):
with SessionLocal() as db:
db_chargepoint = db.query(ChargePoint).filter(ChargePoint.identity == chargepoint_identity).first()
db_chargepoint.last_seen = datetime.now(UTC)
db.commit()
async def update_attributes(chargepoint_identity: str, charging_station):
with SessionLocal() as db:
db_chargepoint = db.query(ChargePoint).filter(ChargePoint.identity == chargepoint_identity).first()
for key in charging_station.keys():
if key in db_chargepoint.__dict__:
setattr(db_chargepoint, key, charging_station[key])
db.commit()
async def create_or_update_connector(
chargepoint_identity: str,
evse_id: int,
connector_id: int,
connector_status: str
):
with SessionLocal() as db:
db_chargepoint = db.query(ChargePoint).filter(ChargePoint.identity == chargepoint_identity).first()
db_connector = db.query(Connector).filter(
Connector.chargepoint_id == db_chargepoint.id,
Connector.evse == evse_id,
Connector.index == connector_id
).first()
if db_connector == None:
db_connector = Connector(
chargepoint_id = db_chargepoint.id,
evse = evse_id,
index = connector_id,
status = ConnectorStatus(connector_status)
)
db.add(db_connector)
else:
db_connector.status = ConnectorStatus(connector_status)
db.commit()

View file

@ -0,0 +1,51 @@
from datetime import datetime, UTC
from ocpp.v201.datatypes import IdTokenInfoType
from ocpp.v201.enums import AuthorizationStatusEnumType
from app.database import SessionLocal
from app.models.id_token import IdToken
from app.models.chargepoint import ChargePoint
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
), 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
)
db_chargepoint = db.query(ChargePoint).filter(ChargePoint.identity == chargepoint_id).first()
# Learn token if requested
if db_chargepoint.learn_user_id != None:
if db_chargepoint.learn_until.timestamp() > datetime.now(UTC).timestamp():
db_id_token = IdToken()
db_id_token.friendly_name = "New token learned by {}".format(chargepoint_id)
db_id_token.is_active = True
db_id_token.owner_id = db_chargepoint.learn_user_id
db_id_token.token = id_token["id_token"]
db.add(db_id_token)
id_token_info=IdTokenInfoType(
status=AuthorizationStatusEnumType.accepted
)
owner_id = db_id_token.owner_id
db_chargepoint.learn_user_id = None
db_chargepoint.learn_until = None
db.commit()
else:
owner_id = db_id_token.owner_id
if db_id_token.is_active == False:
id_token_info=IdTokenInfoType(
status=AuthorizationStatusEnumType.blocked
)
else:
id_token_info=IdTokenInfoType(
status=AuthorizationStatusEnumType.accepted
)
return id_token_info, owner_id

View file

@ -0,0 +1,30 @@
from datetime import datetime
from uuid import UUID
from app.database import SessionLocal
from app.models.meter_value import MeterValue
from app.schemas.meter_value import Measurand, PhaseType
async def create_meter_value(transaction_id: UUID, meter_value_data):
with SessionLocal() as db:
timestamp = datetime.fromisoformat(meter_value_data['timestamp'])
for sampled_value in meter_value_data['sampled_value']:
db_meter_value = MeterValue()
db_meter_value.transaction_id = transaction_id
db_meter_value.timestamp = timestamp
if "measurand" in sampled_value.keys():
db_meter_value.measurand = Measurand(sampled_value['measurand'])
else:
db_meter_value.measurand = Measurand.ENERGY_ACTIVE_IMPORT_REGISTER
if "phase" in sampled_value.keys():
db_meter_value.phase_type = PhaseType(sampled_value['phase'])
if "unit_of_measure" in sampled_value.keys():
if "unit" in sampled_value['unit_of_measure']:
db_meter_value.unit = sampled_value['unit_of_measure']['unit']
else:
db_meter_value.unit = "Wh"
db_meter_value.value = sampled_value['value']
db.add(db_meter_value)
db.commit()

View file

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

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

@ -0,0 +1,86 @@
from datetime import datetime
from typing import Optional
from uuid import UUID
from app.database import SessionLocal
from app.models.chargepoint import ChargePoint
from app.models.transaction import Transaction
from app.schemas.meter_value import Measurand
from app.schemas.transaction import TransactionEventTriggerReason, TransactionStatus
from app.services import meter_value_service
async def create_transaction(
chargepoint_identity: str,
user_id: UUID,
timestamp: datetime,
transaction_info,
transaction_data
):
with SessionLocal() as db:
chargepoint = db.query(ChargePoint).filter(ChargePoint.identity == chargepoint_identity).first()
meter_start=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']
else:
meter_start = sampled_value['value']
transaction = Transaction(
id=transaction_info["transaction_id"],
status=TransactionStatus.ONGOING,
started_at=timestamp,
meter_start=meter_start,
price=chargepoint.price,
chargepoint_id=chargepoint.id,
user_id=user_id
)
db.add(transaction)
db.commit()
async def update_transaction(
transaction_id: str,
transaction_data
):
with SessionLocal() as db:
transaction = db.get(Transaction, transaction_id)
if transaction != None:
if transaction.status == TransactionStatus.ONGOING:
if "meter_value" in transaction_data.keys():
for meter_value_entry in transaction_data['meter_value']:
await meter_value_service.create_meter_value(
transaction_id=transaction.id,
meter_value_data=meter_value_entry
)
async def end_transaction(
transaction_id: str,
timestamp: datetime,
trigger_reason: str,
transaction_data,
user_id: Optional[UUID]
):
with SessionLocal() as db:
transaction = db.get(Transaction, transaction_id)
if transaction != None:
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_end = sampled_value['value']
else:
meter_end = sampled_value['value']
transaction.status = TransactionStatus.ENDED
transaction.ended_at = timestamp
transaction.end_reason = TransactionEventTriggerReason(trigger_reason)
transaction.meter_end = meter_end
if user_id != None:
transaction.user_id = user_id
db.commit()

View file

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

@ -0,0 +1,66 @@
from decimal import Decimal
from app.database import SessionLocal
from app.models.chargepoint import ChargePoint
from app.models.chargepoint_variable import ChargepointVariable
from app.schemas.chargepoint_variable import AttributeType, DataType, MutabilityType
async def create_or_update_variable(chargepoint_identity: str, report_entry):
with SessionLocal() as db:
db_chargepoint = db.query(ChargePoint).filter(ChargePoint.identity == chargepoint_identity).first()
for variable_attribute in report_entry['variable_attribute']:
query = db.query(ChargepointVariable).filter(
ChargepointVariable.chargepoint_id == db_chargepoint.id,
ChargepointVariable.component_name == report_entry['component']['name'],
ChargepointVariable.name == report_entry['variable']['name']
)
if "instance" in report_entry['component'].keys():
query = query.filter(ChargepointVariable.component_instance == report_entry['component']['instance'])
if "evse" in report_entry['component'].keys():
query = query.filter(ChargepointVariable.evse == report_entry['component']['evse']['id'])
if "connectorId" in report_entry['component']['evse'].keys():
query = query.filter(ChargepointVariable.connector_id == report_entry['component']['evse']['connectorId'])
if "type" in variable_attribute.keys():
query = query.filter(ChargepointVariable.type == AttributeType(variable_attribute['type']))
else:
query = query.filter(ChargepointVariable.type == AttributeType.ACTUAL)
db_variable = query.first()
if db_variable == None:
db_variable = ChargepointVariable()
db_variable.chargepoint_id = db_chargepoint.id
db_variable.component_name = report_entry['component']['name']
db_variable.name = report_entry['variable']['name']
if "value" in variable_attribute.keys():
db_variable.value = variable_attribute['value']
if "instance" in report_entry['component'].keys():
db_variable.component_instance = report_entry['component']['instance']
if "evse" in report_entry['component'].keys():
db_variable.evse = report_entry['component']['evse']['id']
if "connector_id" in report_entry['component']['evse'].keys():
db_variable.connector_id = report_entry['component']['evse']['connector_id']
if "constant" in variable_attribute.keys():
db_variable.constant = variable_attribute['constant']
if "persistent" in variable_attribute.keys():
db_variable.constant = variable_attribute['persistent']
if "mutability" in variable_attribute.keys():
db_variable.mutability = MutabilityType(variable_attribute['mutability'])
if "type" in variable_attribute.keys():
db_variable.type = AttributeType(variable_attribute['type'])
if "variable_characteristics" in report_entry.keys():
db_variable.data_type = DataType(report_entry['variable_characteristics']['data_type'])
if "min_limit" in report_entry['variable_characteristics'].keys():
db_variable.min_limit = Decimal(report_entry['variable_characteristics']['min_limit'])
if "max_limit" in report_entry['variable_characteristics'].keys():
db_variable.max_limit = Decimal(report_entry['variable_characteristics']['max_limit'])
if "unit" in report_entry['variable_characteristics'].keys():
db_variable.unit = report_entry['variable_characteristics']['unit']
if "values_list" in report_entry['variable_characteristics'].keys():
db_variable.values_list = report_entry['variable_characteristics']['values_list']
db.add(db_variable)
else:
if "value" in variable_attribute.keys():
db_variable.value = variable_attribute['value']
db.commit()

View file

View file

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

View file

@ -0,0 +1,25 @@
import logging
from fastapi import WebSocket, WebSocketDisconnect
from websockets import ConnectionClosed
logger = logging.getLogger("gunicorn.error")
# Wrapper to transform a FastAPI websocket to a standard websocket
class WebSocketWrapper():
def __init__(self, websocket: WebSocket):
self._websocket = websocket
async def recv(self) -> str:
try:
text = await self._websocket.receive_text()
logger.info("Message received: %s", text)
return text
except WebSocketDisconnect as e:
raise ConnectionClosed(e.code, 'WebSocketWrapper')
async def send(self, msg: str) -> None:
logger.info("Message sent: %s", msg)
await self._websocket.send_text(msg)
async def close(self, code: int, reason: str) -> None:
await self._websocket.close(code)

1772
backend/poetry.lock generated Normal file

File diff suppressed because it is too large Load diff

24
backend/pyproject.toml Normal file
View file

@ -0,0 +1,24 @@
[tool.poetry]
name = "simple-ocpp-cs"
version = "0.2.0"
description = "A simple OCPP 2.0.1 compliant central system"
authors = ["Bluemedia <hi@bluemedia.dev>"]
readme = "README.md"
package-mode = false
[tool.poetry.dependencies]
python = "^3.12"
ocpp = "^2.0.0"
fastapi = {extras = ["standard"], version = "^0.115.11"}
uvicorn = {extras = ["standard"], version = "^0.34.0"}
websockets = "^15.0"
sqlalchemy = "^2.0.28"
alembic = "^1.15.1"
gunicorn = "^22.0.0"
psycopg2-binary = "^2.9.10"
argon2-cffi = "^23.1.0"
jwcrypto = "^1.5.6"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"