Compare commits

...

4 commits

25 changed files with 361 additions and 93 deletions

View file

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

View file

@ -1,5 +1,6 @@
import uuid
from sqlalchemy import Column, DateTime, Enum, ForeignKey, Integer, String, Text, Uuid
from sqlalchemy.orm import relationship
from app.database import Base
from app.schemas.firmware_update import FirmwareUpdateStatus
@ -20,3 +21,4 @@ class FirmwareUpdate(Base):
signature = Column(String, nullable=True)
chargepoint_id = Column(Uuid, ForeignKey("chargepoints.id"), index=True)
chargepoint = relationship("ChargePoint", back_populates="firmware_updates")

View file

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

View file

@ -1,5 +1,6 @@
import uuid
from sqlalchemy import Column, DateTime, ForeignKey, String, Uuid
from sqlalchemy.orm import relationship
from app.database import Base
@ -11,4 +12,5 @@ class Session(Base):
refresh_token = Column(String, nullable=False, unique=True, index=True)
last_used = Column(DateTime(timezone=True))
user_id = Column(Uuid, ForeignKey("users.id"), nullable=False, index=True)
user_id = Column(Uuid, ForeignKey("users.id"), nullable=False, index=True)
user = relationship("User", back_populates="sessions")

View file

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

View file

@ -17,3 +17,4 @@ class User(Base):
id_tokens = relationship("IdToken", back_populates="owner", cascade="delete, delete-orphan")
transactions = relationship("Transaction", cascade="delete, delete-orphan")
sessions = relationship("Session", cascade="delete, delete-orphan")

View file

@ -3,6 +3,7 @@ from datetime import datetime
from pydantic import BaseModel
from app.schemas.user import Role
from app.util.encoders import force_utc_datetime
@dataclass
@ -19,3 +20,6 @@ class TokenResponse(BaseModel):
access_token: str
refresh_token: str
not_after: datetime
class Config:
json_encoders = {datetime: force_utc_datetime}

View file

@ -8,7 +8,7 @@ from app.schemas.connector import Connector
from ocpp.v201.enums import ResetEnumType, ResetStatusEnumType
from app.util.encoders import decimal_encoder
from app.util.encoders import decimal_encoder, force_utc_datetime
class ChargePointBase(BaseModel):
identity: str
@ -32,6 +32,15 @@ class ChargePoint(ChargePointBase):
firmware_version: str | None
connectors: list[Connector] = []
class Config:
from_attributes = True
json_encoders = {Decimal: decimal_encoder, datetime: force_utc_datetime}
class ChargePointThumb(BaseModel):
id: UUID
identity: str
price: Decimal
class Config:
from_attributes = True
json_encoders = {Decimal: decimal_encoder}

View file

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

View file

@ -2,6 +2,8 @@ from datetime import datetime
from uuid import UUID
from pydantic import BaseModel
from app.util.encoders import force_utc_datetime
class Session(BaseModel):
id: UUID
@ -9,4 +11,5 @@ class Session(BaseModel):
last_used: datetime
class Config:
from_attributes = True
from_attributes = True
json_encoders = {datetime: force_utc_datetime}

View file

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

View file

@ -30,6 +30,13 @@ class User(UserBase):
class Config:
from_attributes = True
class UserThumb(BaseModel):
id: UUID
friendly_name: str
class Config:
from_attributes = True
class PasswordUpdate(BaseModel):
old_password: str = Field(max_length=100)
new_password: str = Field(max_length=100)

View file

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

View file

@ -1,3 +1,4 @@
from datetime import datetime, timezone
from decimal import Decimal
from typing import Union
@ -19,4 +20,8 @@ def decimal_encoder(dec_value: Decimal) -> Union[int, float]:
if isinstance(exponent, int) and exponent >= 0:
return int(dec_value)
else:
return float(dec_value)
return float(dec_value)
def force_utc_datetime(datetime_value: datetime) -> datetime:
"""Force a datetime to be in the UTC timzone"""
return datetime_value.replace(tzinfo=timezone.utc)

View file

@ -25,18 +25,20 @@
<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 !Number.isNaN(diff)}
{#if diff > 0}
<div class="badge badge-outline badge-success badge-sm gap-0.5 px-1 font-medium">
<i class="bi bi-arrow-up-right"></i>{diff}%
</div>
{:else if diff < 0}
<div class="badge badge-outline badge-error badge-sm gap-0.5 px-1 font-medium">
<i class="bi bi-arrow-down-right"></i>{diff}%
</div>
{:else}
<div class="badge badge-outline badge-warning badge-sm gap-0.5 px-1 font-medium">
<i class="bi bi-arrow-right"></i>{diff}%
</div>
{/if}
{/if}
</div>
</div>

View file

@ -1,18 +1,9 @@
<script lang="ts">
import i18n from '$lib/i18n'
import type { Transaction } from '$lib/types/transaction'
let props: {
transactions: {
id: string
begin: Date
end: Date
chargepoint: {
name: string
id: string
}
energyAmmount: number
cost: number
}[]
transactions: Transaction[]
} = $props()
</script>
@ -36,7 +27,7 @@
<div>
<p>
{$i18n.t('common:transactionTable.startTime', {
time: transaction.begin,
time: new Date(transaction.started_at),
formatParams: {
time: {
year: 'numeric',
@ -51,7 +42,7 @@
</p>
<p>
{$i18n.t('common:transactionTable.endTime', {
time: transaction.end,
time: new Date(transaction.ended_at!),
formatParams: {
time: {
year: 'numeric',
@ -70,11 +61,15 @@
<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}
{transaction.chargepoint.identity}
</a>
</td>
<td class="font-bold">{transaction.energyAmmount} kWh</td>
<td class="font-bold">{transaction.cost}</td>
<td class="font-bold"
>{(transaction.meter_end - transaction.meter_start).toFixed(2)} kWh</td
>
<td class="font-bold"
>{((transaction.meter_end - transaction.meter_start) * transaction.price).toFixed(2)}</td
>
<th>
<a href="/transaction/{transaction.id}" class="btn btn-sm btn-primary">
{$i18n.t('common:transactionTable.detailButton')}

View file

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

View file

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

View file

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

View file

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

View file

@ -7,4 +7,23 @@ export const currentDaytime = function(): string {
return 'day'
}
return 'evening'
}
export function relativeTimeFromDate(date: Date): {val: number, range: Intl.RelativeTimeFormatUnit} | undefined {
const units: {unit: Intl.RelativeTimeFormatUnit; ms: number}[] = [
{unit: "year", ms: 31536000000},
{unit: "month", ms: 2628000000},
{unit: "day", ms: 86400000},
{unit: "hour", ms: 3600000},
{unit: "minute", ms: 60000},
{unit: "second", ms: 1000},
];
const elapsed = date.getTime() - new Date().getTime();
for (const {unit, ms} of units) {
if (Math.abs(elapsed) >= ms || unit === "second") {
return {val: Math.round(elapsed / ms), range: unit};
}
}
return undefined;
}

View file

@ -4,10 +4,39 @@
import i18n from '$lib/i18n'
import DashboardCard from '$lib/component/DashboardCard.svelte'
import TransactionTable from '$lib/component/TransactionTable.svelte'
import type { Dashboard } from '$lib/types/dashboard'
import axios from '$lib/axios.svelte'
import { onMount } from 'svelte'
$i18n.loadNamespaces('dashboard')
let hasActiveTransaction = $state(true)
let dashboardData: Dashboard = $state({
stats: {
transaction_count: 0,
transaction_count_previous: 0,
transaction_energy_total: 0,
transaction_energy_total_previous: 0,
transaction_cost_total: 0,
transaction_cost_total_previous: 0,
},
current_transaction: undefined,
recent_transactions: [],
})
function refreshDashboard() {
axios.get('/me/dashboard').then((res) => {
dashboardData = res.data
})
}
onMount(() => {
refreshDashboard()
const interval = setInterval(() => {
refreshDashboard()
}, 15000)
return () => clearInterval(interval)
})
</script>
<div class="w-full h-full mt-10 flex flex-col">
@ -23,18 +52,21 @@
{$i18n.t('dashboard:cards.currentTransaction')}
</p>
<div class="mt-3 flex items-center gap-2">
{#if hasActiveTransaction}
{#if dashboardData.current_transaction}
<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}
{#if dashboardData.current_transaction}{dashboardData.current_transaction
.meter_end - dashboardData.current_transaction.meter_start} kWh{:else}-{/if}
</p>
{#if hasActiveTransaction}
{#if dashboardData.current_transaction}
<div class="badge badge-soft badge-success badge-sm gap-0.5 px-1 font-medium">
2,30 €
{(dashboardData.current_transaction.meter_end -
dashboardData.current_transaction.meter_start) *
dashboardData.current_transaction.price} €
</div>
{/if}
</div>
@ -43,13 +75,15 @@
</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' })}
{#if dashboardData.current_transaction}
{$i18n.t('dashboard:cards.chargepoint', {
name: dashboardData.current_transaction.chargepoint.identity,
})}
{:else}
{$i18n.t('dashboard:cards.noCurrentTransaction')}
{/if}
</p>
{#if hasActiveTransaction}
{#if dashboardData.current_transaction}
<button class="btn btn-xs btn-primary">
{$i18n.t('dashboard:cards.toCurrentTransactionButton')}
<i class="bi bi-arrow-right"></i>
@ -61,61 +95,26 @@
<DashboardCard
title={$i18n.t('dashboard:cards.transactionCount')}
icon={'bi-battery-charging'}
value={3}
previousValue={1}
value={dashboardData.stats.transaction_count}
previousValue={dashboardData.stats.transaction_count_previous}
/>
<DashboardCard
title={$i18n.t('dashboard:cards.transactionEnergyTotal')}
icon={'bi-lightning-charge-fill'}
value={180}
previousValue={50}
value={+dashboardData.stats.transaction_energy_total.toFixed(2)}
previousValue={+dashboardData.stats.transaction_energy_total_previous.toFixed(2)}
unit={'kWh'}
/>
<DashboardCard
title={$i18n.t('dashboard:cards.transactionCostTotal')}
icon={'bi-currency-exchange'}
value={30.56}
previousValue={30.56}
value={+dashboardData.stats.transaction_cost_total.toFixed(2)}
previousValue={+dashboardData.stats.transaction_cost_total_previous.toFixed(2)}
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,
},
]}
/>
<TransactionTable transactions={dashboardData.recent_transactions} />
</div>
</div>

View file

@ -0,0 +1,106 @@
<script lang="ts">
import axios from '$lib/axios.svelte'
import i18n from '$lib/i18n'
import type { ChargePoint } from '$lib/types/chargepoint'
import { relativeTimeFromDate } from '$lib/util'
import { onMount } from 'svelte'
$i18n.loadNamespaces('chargepoint')
let chargepoints: ChargePoint[] = $state([])
const pageSize = 25
let page: number = $state(1)
const urlParams = new URLSearchParams(window.location.search)
if (urlParams.has('page')) {
page = Number.parseInt(urlParams.get('page')!)
}
function load() {
axios
.get('/chargepoints', { params: { skip: (page - 1) * pageSize, limit: pageSize } })
.then((res) => {
chargepoints = res.data
})
}
function nextPage() {
++page
load()
}
function previousPage() {
--page
load()
}
onMount(() => {
load()
})
</script>
<div class="w-full h-full mt-4 flex flex-col">
<p class="w-full text-2xl font-bold">
{$i18n.t('chargepoint:header')}
</p>
<div class="overflow-x-auto mt-8 bg-base-100 rounded-md">
<table class="table">
<thead>
<tr>
<th>{$i18n.t('chargepoint:tableHeader.name')}</th>
<th>{$i18n.t('chargepoint:tableHeader.active')}</th>
<th>{$i18n.t('chargepoint:tableHeader.lastSeen')}</th>
<th>{$i18n.t('chargepoint:tableHeader.price')}</th>
<th></th>
</tr>
</thead>
<tbody>
{#if chargepoints.length > 0}
{#each chargepoints as chargepoint}
<tr>
<td>
{chargepoint.identity}
</td>
<td>
{#if chargepoint.is_active}
{$i18n.t('chargepoint:state.enabled')}
{:else}
{$i18n.t('chargepoint:state.disabled')}
{/if}
</td>
<td>
{$i18n.t(
'chargepoint:tableFormatting.lastSeen',
relativeTimeFromDate(new Date(chargepoint.last_seen))!
)}
</td>
<td>{chargepoint.price}</td>
<th>
<a href="/chargepoint/{chargepoint.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 chargepoints.length === 0}
<p class="w-full mt-15 mb-15 text-center text-xl font-bold">
{$i18n.t('chargepoint:noChargepoints')}
</p>
{/if}
</div>
<div class="join mt-4">
<button onclick={previousPage} disabled={page === 1 || null} class="join-item btn bg-base-100"
</button
>
<button class="join-item btn bg-base-100">Page {page}</button>
<button
onclick={nextPage}
disabled={chargepoints.length < pageSize || null}
class="join-item btn bg-base-100">»</button
>
</div>
</div>

View file

@ -0,0 +1,17 @@
{
"header": "Ladepunkte",
"tableHeader": {
"name": "Name",
"active": "Aktiv",
"price": "Preis",
"lastSeen": "Zuletzt gesehen"
},
"tableFormatting": {
"lastSeen": "{{val, relativetime}}"
},
"state": {
"enabled": "Aktiv",
"disabled": "Deaktiviert"
},
"noChargepoints": "Noch keine Ladepunkte vorhanden."
}

View file

@ -0,0 +1,17 @@
{
"header": "Chargepoints",
"tableHeader": {
"name": "Name",
"active": "Active",
"price": "Price",
"lastSeen": "Last seen"
},
"tableFormatting": {
"lastSeen": "{{val, relativetime}}"
},
"state": {
"enabled": "Active",
"disabled": "Disabled"
},
"noChargepoints": "There are no chargepoints yet."
}