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",
"connector",
"firmware_update",
"id_token",
"meter_value",
"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)
name = Column(String)
refresh_token = Column(String, unique=True, index=True)
refresh_token = Column(String, nullable=False, unique=True, index=True)
last_used = Column(DateTime(timezone=True))
user_id = Column(Uuid, ForeignKey("users.id"), index=True)
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 app.services import (
firmware_service,
variable_service,
id_token_service,
chargepoint_service,
@ -108,6 +109,11 @@ class ChargePoint(cp):
return call_result.TransactionEvent()
else:
return call_result.TransactionEvent(id_token_info=id_token_info)
@on(Action.firmware_status_notification)
async def on_firmware_status_notification(self, status, request_id, **kwargs):
await firmware_service.update_firmware_status(self.id, request_id, status)
return call_result.FirmwareStatusNotification()
@on(Action.meter_values)
async def on_meter_values(self, **kwargs):

View file

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

View file

@ -13,5 +13,17 @@
"editor.formatOnSave": true,
"editor.tabSize": 2,
"editor.detectIndentation": false,
"i18n-ally.enabledFrameworks": ["i18next", "svelte"],
"i18n-ally.localesPaths": ["static/locales"],
"i18n-ally.keystyle": "nested",
"i18n-ally.namespace": true,
"i18n-ally.editor.preferEditor": true,
"i18n-ally.refactor.templates": [
{
"templates": ["{{ t('{key}'{args}) }}"],
},
],
"i18n-ally.sourceLanguage": "en",
"i18n-ally.displayLanguage": "en",
},
}

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."
}
}