Compare commits

...

4 commits

Author SHA1 Message Date
34b12f6a44
Add axios as frontend dependency 2025-03-23 18:59:50 +00:00
4f5f2be68f
Add basic frontend structure 2025-03-23 18:59:43 +00:00
f0eff338ff
Add possibility for signed firmware updates
All checks were successful
ci/woodpecker/push/docker Pipeline was successful
ci/woodpecker/cron/docker Pipeline was successful
2025-03-23 18:54:48 +00:00
b59aeeb5e5
Add firmware update logic 2025-03-23 18:54:36 +00:00
38 changed files with 5228 additions and 3813 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

@ -34,7 +34,14 @@
}, },
"dependencies": { "dependencies": {
"@tailwindcss/vite": "^4.0.14", "@tailwindcss/vite": "^4.0.14",
"axios": "^1.8.4",
"bootstrap-icons": "^1.11.3",
"daisyui": "^5.0.3", "daisyui": "^5.0.3",
"i18next": "^24.2.3",
"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" "tailwindcss": "^4.0.14"
} }
} }

View file

@ -13,5 +13,17 @@
"editor.formatOnSave": true, "editor.formatOnSave": true,
"editor.tabSize": 2, "editor.tabSize": 2,
"editor.detectIndentation": false, "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",
}, },
} }

42
frontend/src/app.css Normal file
View file

@ -0,0 +1,42 @@
@import 'tailwindcss';
@import 'bootstrap-icons';
@plugin "daisyui" {
themes:
emerald --default;
}
@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;
}

View file

@ -0,0 +1,49 @@
<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 diff > 0}
<div class="badge badge-soft 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-soft 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-soft badge-warning badge-sm gap-0.5 px-1 font-medium">
<i class="bi bi-arrow-right"></i>{diff}%
</div>
{/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

@ -0,0 +1,94 @@
<script lang="ts">
import i18n from '$lib/i18n'
let props: {
transactions: {
id: string
begin: Date
end: Date
chargepoint: {
name: string
id: string
}
energyAmmount: number
cost: number
}[]
} = $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: transaction.begin,
formatParams: {
time: {
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
second: 'numeric',
},
},
})}
</p>
<p>
{$i18n.t('common:transactionTable.endTime', {
time: transaction.end,
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.name}
</a>
</td>
<td class="font-bold">{transaction.energyAmmount} kWh</td>
<td class="font-bold">{transaction.cost}</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

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

32
frontend/src/lib/i18n.ts Normal file
View file

@ -0,0 +1,32 @@
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 +0,0 @@
// place files you want to import through the `$lib` alias in this folder.

View file

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

10
frontend/src/lib/util.ts Normal file
View file

@ -0,0 +1,10 @@
export const currentDaytime = function(): string {
const currentHour = new Date().getHours()
if (currentHour >= 0 && currentHour < 12) {
return 'morning'
} else if (currentHour >= 12 && currentHour < 18) {
return 'day'
}
return 'evening'
}

View file

@ -0,0 +1,97 @@
<script lang="ts">
import { goto } from '$app/navigation'
import { persistentSettings } from '$lib/persistent-store'
import i18n from '$lib/i18n'
let { children } = $props()
if (!$persistentSettings.loggedIn) {
goto('/login')
}
let drawerOpen = $state(false)
</script>
<div class="w-full h-full p-4 flex flex-col bg-base-200">
<div class="navbar w-auto mb-4 pr-4 bg-base-100 shadow-md rounded-md">
<div class="drawer navbar-start">
<input bind:checked={drawerOpen} id="nav-drawer" type="checkbox" class="drawer-toggle" />
<div class="drawer-content">
<label for="nav-drawer" class="btn btn-ghost btn-circle drawer-button">
<i class="bi bi-list text-2xl"></i>
</label>
</div>
<div class="drawer-side z-10">
<label for="nav-drawer" aria-label="close sidebar" class="drawer-overlay"></label>
<ul class="menu bg-base-100 text-base min-h-full w-80 p-4">
<li class="mb-4">
<a class="btn btn-ghost text-xl" href="/">LibreCharge</a>
</li>
<li>
<a
onclick={() => {
drawerOpen = !drawerOpen
}}
href="/"
>
<i class="bi bi-graph-up text-xl"></i>
<span>Dashboard</span>
</a>
</li>
<li>
<a
onclick={() => {
drawerOpen = !drawerOpen
}}
href="/idtoken"
>
<i class="bi bi-credit-card-fill text-xl"></i>
<span>{$i18n.t('common:navbar.link.idtoken')}</span>
</a>
</li>
<li>
<a
onclick={() => {
drawerOpen = !drawerOpen
}}
href="/transaction"
>
<i class="bi bi-battery-charging text-xl"></i>
<span>{$i18n.t('common:navbar.link.transaction')}</span>
</a>
</li>
<li>
<a
onclick={() => {
drawerOpen = !drawerOpen
}}
href="/chargepoint"
>
<i class="bi bi-plug-fill text-xl"></i>
<span>{$i18n.t('common:navbar.link.chargepoint')}</span>
</a>
</li>
</ul>
</div>
</div>
<div class="navbar-center">
<a class="btn btn-ghost text-xl" href="/">LibreCharge</a>
</div>
<div class="navbar-end">
<div class="dropdown dropdown-end">
<div tabindex="0" role="button" class="btn btn-ghost btn-circle">
<i class="bi bi-person-circle text-2xl"></i>
</div>
<ul
tabindex="-1"
class="menu menu-sm dropdown-content bg-base-100 rounded-box z-1 mt-3 w-52 p-2 shadow"
>
<li><a href="/profile">{$i18n.t('common:navbar.link.profile')}</a></li>
<li><a href="/login?logout">{$i18n.t('common:navbar.link.logout')}</a></li>
</ul>
</div>
</div>
</div>
<div class="w-full h-full flex flex-col">
{@render children()}
</div>
</div>

View file

@ -0,0 +1,121 @@
<script lang="ts">
import { persistentSettings } from '$lib/persistent-store'
import { currentDaytime } from '$lib/util'
import i18n from '$lib/i18n'
import DashboardCard from '$lib/component/DashboardCard.svelte'
import TransactionTable from '$lib/component/TransactionTable.svelte'
$i18n.loadNamespaces('dashboard')
let hasActiveTransaction = $state(true)
</script>
<div class="w-full h-full mt-10 flex flex-col">
<p class="w-full text-2xl font-bold">
{$i18n.t('dashboard:greeting.' + currentDaytime(), { name: $persistentSettings.friendlyName })}
</p>
<div class="w-full mt-5 flex gap-x-4">
<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">
{$i18n.t('dashboard:cards.currentTransaction')}
</p>
<div class="mt-3 flex items-center gap-2">
{#if hasActiveTransaction}
<div class="inline-grid *:[grid-area:1/1]">
<div class="status status-success animate-ping"></div>
<div class="status status-success"></div>
</div>
{/if}
<p class="text-2xl font-semibold">
{#if hasActiveTransaction}0,25 kWh{:else}-{/if}
</p>
{#if hasActiveTransaction}
<div class="badge badge-soft badge-success badge-sm gap-0.5 px-1 font-medium">
2,30 €
</div>
{/if}
</div>
</div>
<i class="bi bi-plug-fill text-primary text-4xl"></i>
</div>
<div class="w-full flex flex-row items-center">
<p class="text-base-content/60 text-sm">
{#if hasActiveTransaction}
{$i18n.t('dashboard:cards.chargepoint', { name: 'DE-EXMPL-0001' })}
{:else}
{$i18n.t('dashboard:cards.noCurrentTransaction')}
{/if}
</p>
{#if hasActiveTransaction}
<button class="btn btn-xs btn-primary">
{$i18n.t('dashboard:cards.toCurrentTransactionButton')}
<i class="bi bi-arrow-right"></i>
</button>
{/if}
</div>
</div>
</div>
<DashboardCard
title={$i18n.t('dashboard:cards.transactionCount')}
icon={'bi-battery-charging'}
value={3}
previousValue={1}
/>
<DashboardCard
title={$i18n.t('dashboard:cards.transactionEnergyTotal')}
icon={'bi-lightning-charge-fill'}
value={180}
previousValue={50}
unit={'kWh'}
/>
<DashboardCard
title={$i18n.t('dashboard:cards.transactionCostTotal')}
icon={'bi-currency-exchange'}
value={30.56}
previousValue={30.56}
unit={'€'}
/>
</div>
<div class="w-full flex flex-col mt-10">
<p class="text-xl font-bold">{$i18n.t('dashboard:table.title')}</p>
<TransactionTable
transactions={[
{
id: '1',
begin: new Date(Date.UTC(2025, 3, 15, 14, 12, 23)),
end: new Date(Date.UTC(2025, 3, 15, 16, 18, 46)),
chargepoint: { name: 'DE-EXMPL-0001', id: '1' },
energyAmmount: 58.65,
cost: 36.45,
},
{
id: '2',
begin: new Date(Date.UTC(2025, 3, 15, 14, 12, 23)),
end: new Date(Date.UTC(2025, 3, 15, 16, 18, 46)),
chargepoint: { name: 'DE-EXMPL-0001', id: '1' },
energyAmmount: 58.65,
cost: 36.45,
},
{
id: '3',
begin: new Date(Date.UTC(2025, 3, 15, 14, 12, 23)),
end: new Date(Date.UTC(2025, 3, 15, 16, 18, 46)),
chargepoint: { name: 'DE-EXMPL-0001', id: '1' },
energyAmmount: 58.65,
cost: 36.45,
},
{
id: '4',
begin: new Date(Date.UTC(2025, 3, 15, 14, 12, 23)),
end: new Date(Date.UTC(2025, 3, 15, 16, 18, 46)),
chargepoint: { name: 'DE-EXMPL-0001', id: '1' },
energyAmmount: 58.65,
cost: 36.45,
},
]}
/>
</div>
</div>

View file

@ -0,0 +1,38 @@
<script>
import { persistentSettings } from '$lib/persistent-store'
let { children } = $props()
import '../app.css'
</script>
<div
class="w-screen h-screen flex flex-col"
data-theme={$persistentSettings.darkmode ? 'darkgray' : 'emerald'}
>
{@render children()}
<footer
class="footer sm:footer-horizontal bg-neutral text-neutral-content items-center pt-3 pb-3 pl-6 pr-6"
>
<aside class="grid-flow-col">
<p>Powered by LibreCharge - Licensed under GNU AGPL v3</p>
</aside>
<nav class="grid-flow-col gap-4 md:place-self-center md:justify-self-end text-3xl">
<label class="swap swap-rotate">
<!-- this hidden checkbox controls the state -->
<input type="checkbox" bind:checked={$persistentSettings.darkmode} />
<!-- sun icon -->
<i class="bi bi-sun swap-off"></i>
<!-- moon icon -->
<i class="bi bi-moon swap-on"></i>
</label>
<a
aria-label="Source Code"
href="https://git.bluemedia.dev/Bluemedia/simple-ocpp-cs"
target="_blank"
>
<i class="bi bi-git"></i>
</a>
</nav>
</footer>
</div>

View file

@ -1,6 +0,0 @@
<script>
import '../style.css'
</script>
<h1>Welcome to SvelteKit</h1>
<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p>

View file

@ -0,0 +1,57 @@
<script lang="ts">
import { fly } from 'svelte/transition'
import i18n from '$lib/i18n'
$i18n.loadNamespaces('login')
const urlParams = new URLSearchParams(window.location.search)
const isReauth = urlParams.has('reauth')
const isLogout = urlParams.has('logout')
let showToast = $state(true)
if (isReauth || isLogout) {
setTimeout(() => {
showToast = false
}, 6000)
}
</script>
<div class="w-full h-full flex flex-col justify-center items-center bg-base-200">
<div class="basis-0 grow flex justify-center items-center">
<div class="flex flex-col items-center justify-center">
<i class="bi bi-ev-front-fill text-8xl text-primary"></i>
<p class="text-2xl font-bold">LibreCharge</p>
</div>
</div>
<div class="basis-0 flex justify-center items-center">
<div class="card bg-base-100 w-full max-w-sm h-fit shrink-0 shadow-2xl">
<div class="card-body">
<h1 class="text-2xl font-bold">{$i18n.t('login:title')}</h1>
<p class="py-2 text-base">{$i18n.t('login:text')}</p>
<fieldset class="fieldset">
<label class="fieldset-label input text-base-content">
<i class="bi bi-envelope-at"></i>
<input type="email" placeholder="me@example.com" required />
</label>
<label class="fieldset-label input text-base-content">
<i class="bi bi-key"></i>
<input type="password" placeholder={$i18n.t('login:passwordPlaceholder')} required />
</label>
<button class="btn btn-primary mt-4">{$i18n.t('login:button')}</button>
</fieldset>
</div>
</div>
</div>
<span class="basis-0 grow"></span>
{#if (isReauth || isLogout) && showToast}
<div class="toast toast-top" out:fly={{ x: 200 }}>
<div class="alert text-base {isReauth ? 'alert-warning' : 'alert-success'}">
{#if isReauth}
<i class="bi bi-exclamation-diamond text-xl"></i>
{:else}
<i class="bi bi-check-circle text-xl"></i>
{/if}
<span>{$i18n.t(isReauth ? 'login:toast.reauth' : 'login:toast.logoutSuccess')}</span>
</div>
</div>
{/if}
</div>

View file

@ -1,6 +0,0 @@
@import 'tailwindcss';
@plugin "daisyui" {
themes:
light --default,
dark --prefersdark;
}

View file

@ -0,0 +1,21 @@
{
"navbar": {
"link": {
"idtoken": "Ladekarten",
"transaction": "Ladevorgänge",
"chargepoint": "Ladestationen",
"profile": "Profil",
"logout": "Abmelden"
}
},
"transactionTable": {
"noPreviousTransactions": "Du hast noch keine abgeschlossenen Ladevorgänge.",
"headerDate": "Zeitpunkt",
"headerChargepoint": "Ladestation",
"headerEnergyTotal": "Ladevolumen",
"headerCost": "Kosten",
"startTime": "Beginn: {{ time, datetime }} Uhr",
"endTime": "Ende: {{ time, datetime }} Uhr",
"detailButton": "Details"
}
}

View file

@ -0,0 +1,28 @@
{
"greeting": {
"morning": "Guten Morgen {{name}}!",
"day": "Guten Tag {{name}}!",
"evening": "Guten Abend {{name}}!"
},
"cards": {
"currentTransaction": "Aktueller Ladevorgang",
"chargepoint": "Ladestation: {{name}}",
"toCurrentTransactionButton": "Zum Ladevorgang",
"noCurrentTransaction": "Kein Ladevorgang aktiv",
"transactionCount": "Ladevorgänge",
"transactionEnergyTotal": "Ladevolumen",
"transactionCostTotal": "Kosten",
"lastMonth": "{{val}} {{unit}} im letzten Monat"
},
"table": {
"title": "Deine letzten Ladevorgänge",
"noPreviousTransactions": "Du hast noch keine abgeschlossenen Ladevorgänge.",
"headerDate": "Zeitpunkt",
"headerChargepoint": "Ladestation",
"headerEnergyTotal": "Ladevolumen",
"headerCost": "Kosten",
"startTime": "Beginn: {{ time, datetime }} Uhr",
"endTime": "Ende: {{ time, datetime }} Uhr",
"detailButton": "Details"
}
}

View file

@ -0,0 +1,10 @@
{
"title": "Anmelden",
"text": "Bitte melde dich mit deinem Account an:",
"passwordPlaceholder": "Passwort",
"button": "Anmelden",
"toast": {
"reauth": "Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.",
"logoutSuccess": "Abmeldung erfolgreich."
}
}

View file

@ -0,0 +1,21 @@
{
"navbar": {
"link": {
"idtoken": "Charging Cards",
"transaction": "Transactions",
"chargepoint": "Charging Stations",
"profile": "Profile",
"logout": "Logout"
}
},
"transactionTable": {
"noPreviousTransactions": "You don't have any completed transactions yet.",
"headerDate": "Date",
"headerChargepoint": "Chargepoint",
"headerEnergyTotal": "Energy charged",
"headerCost": "Cost",
"startTime": "Start: {{ time, datetime }}",
"endTime": "End: {{ time, datetime }}",
"detailButton": "Details"
}
}

View file

@ -0,0 +1,28 @@
{
"greeting": {
"morning": "Good morning {{name}}!",
"day": "Hello {{name}}!",
"evening": "Good evening {{name}}!"
},
"cards": {
"currentTransaction": "Current Transaction",
"chargepoint": "Chargepoint: {{name}}",
"toCurrentTransactionButton": "More",
"noCurrentTransaction": "No active transaction",
"transactionCount": "Transactions",
"transactionEnergyTotal": "Energy Ammount",
"transactionCostTotal": "Cost",
"lastMonth": "{{val}} {{unit}} last month"
},
"table": {
"title": "Your recent transactions",
"noPreviousTransactions": "You don't have any completed transactions yet.",
"headerDate": "Date",
"headerChargepoint": "Chargepoint",
"headerEnergyTotal": "Energy charged",
"headerCost": "Cost",
"startTime": "Start: {{ time, datetime }}",
"endTime": "End: {{ time, datetime }}",
"detailButton": "Details"
}
}

View file

@ -0,0 +1,10 @@
{
"title": "Login",
"text": "Please login to your account:",
"passwordPlaceholder": "Password",
"button": "Login",
"toast": {
"reauth": "Your session expired. Please log in again.",
"logoutSuccess": "Successfully logged out."
}
}