Compare commits

..

1 commit

Author SHA1 Message Date
255895bcb8
WIP: Add chargepoint list 2025-04-28 18:22:41 +00:00
25 changed files with 136 additions and 261 deletions

View file

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

View file

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

View file

@ -1,6 +1,5 @@
import uuid
from sqlalchemy import Column, DateTime, ForeignKey, String, Uuid
from sqlalchemy.orm import relationship
from app.database import Base
@ -12,5 +11,4 @@ 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 = relationship("User", back_populates="sessions")
user_id = Column(Uuid, ForeignKey("users.id"), nullable=False, index=True)

View file

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

View file

@ -17,4 +17,3 @@ 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,7 +3,6 @@ from datetime import datetime
from pydantic import BaseModel
from app.schemas.user import Role
from app.util.encoders import force_utc_datetime
@dataclass
@ -20,6 +19,3 @@ 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, force_utc_datetime
from app.util.encoders import decimal_encoder
class ChargePointBase(BaseModel):
identity: str
@ -32,15 +32,6 @@ 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, force_utc_datetime
from app.util.encoders import decimal_encoder
class PhaseType(enum.Enum):
L1 = "L1"
@ -57,4 +57,4 @@ class MeterValue(BaseModel):
class Config:
from_attributes = True
json_encoders = {Decimal: decimal_encoder, datetime: force_utc_datetime}
json_encoders = {Decimal: decimal_encoder}

View file

@ -2,8 +2,6 @@ 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
@ -11,5 +9,4 @@ class Session(BaseModel):
last_used: datetime
class Config:
from_attributes = True
json_encoders = {datetime: force_utc_datetime}
from_attributes = True

View file

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

View file

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

View file

@ -1,4 +1,3 @@
from datetime import datetime, timezone
from decimal import Decimal
from typing import Union
@ -20,8 +19,4 @@ 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)
def force_utc_datetime(datetime_value: datetime) -> datetime:
"""Force a datetime to be in the UTC timzone"""
return datetime_value.replace(tzinfo=timezone.utc)
return float(dec_value)

View file

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

View file

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

View file

@ -16,10 +16,4 @@ export type ChargePoint = {
status: 'Available' | 'Occupied' | 'Reserved' | 'Unavailable' | 'Faulted'
}
]
}
export type ChargePointThumb = {
id: string;
identity: string;
price: number;
}

View file

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

View file

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

View file

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

View file

@ -7,23 +7,4 @@ 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,39 +4,10 @@
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 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)
})
let hasActiveTransaction = $state(true)
</script>
<div class="w-full h-full mt-10 flex flex-col">
@ -52,21 +23,18 @@
{$i18n.t('dashboard:cards.currentTransaction')}
</p>
<div class="mt-3 flex items-center gap-2">
{#if dashboardData.current_transaction}
{#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 dashboardData.current_transaction}{dashboardData.current_transaction
.meter_end - dashboardData.current_transaction.meter_start} kWh{:else}-{/if}
{#if hasActiveTransaction}0,25 kWh{:else}-{/if}
</p>
{#if dashboardData.current_transaction}
{#if hasActiveTransaction}
<div class="badge badge-soft badge-success badge-sm gap-0.5 px-1 font-medium">
{(dashboardData.current_transaction.meter_end -
dashboardData.current_transaction.meter_start) *
dashboardData.current_transaction.price} €
2,30 €
</div>
{/if}
</div>
@ -75,15 +43,13 @@
</div>
<div class="w-full flex flex-row items-center">
<p class="text-base-content/60 text-sm">
{#if dashboardData.current_transaction}
{$i18n.t('dashboard:cards.chargepoint', {
name: dashboardData.current_transaction.chargepoint.identity,
})}
{#if hasActiveTransaction}
{$i18n.t('dashboard:cards.chargepoint', { name: 'DE-EXMPL-0001' })}
{:else}
{$i18n.t('dashboard:cards.noCurrentTransaction')}
{/if}
</p>
{#if dashboardData.current_transaction}
{#if hasActiveTransaction}
<button class="btn btn-xs btn-primary">
{$i18n.t('dashboard:cards.toCurrentTransactionButton')}
<i class="bi bi-arrow-right"></i>
@ -95,26 +61,61 @@
<DashboardCard
title={$i18n.t('dashboard:cards.transactionCount')}
icon={'bi-battery-charging'}
value={dashboardData.stats.transaction_count}
previousValue={dashboardData.stats.transaction_count_previous}
value={3}
previousValue={1}
/>
<DashboardCard
title={$i18n.t('dashboard:cards.transactionEnergyTotal')}
icon={'bi-lightning-charge-fill'}
value={+dashboardData.stats.transaction_energy_total.toFixed(2)}
previousValue={+dashboardData.stats.transaction_energy_total_previous.toFixed(2)}
value={180}
previousValue={50}
unit={'kWh'}
/>
<DashboardCard
title={$i18n.t('dashboard:cards.transactionCostTotal')}
icon={'bi-currency-exchange'}
value={+dashboardData.stats.transaction_cost_total.toFixed(2)}
previousValue={+dashboardData.stats.transaction_cost_total_previous.toFixed(2)}
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={dashboardData.recent_transactions} />
<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

@ -1,42 +1,13 @@
<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">
@ -59,22 +30,49 @@
{#each chargepoints as chargepoint}
<tr>
<td>
{chargepoint.identity}
<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>
{#if chargepoint.is_active}
{$i18n.t('chargepoint:state.enabled')}
{:else}
{$i18n.t('chargepoint:state.disabled')}
{/if}
<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>
{$i18n.t(
'chargepoint:tableFormatting.lastSeen',
relativeTimeFromDate(new Date(chargepoint.last_seen))!
)}
</td>
<td>{chargepoint.price}</td>
<td class="font-bold">{transaction.energyAmmount} kWh</td>
<td class="font-bold">{transaction.cost}</td>
<th>
<a href="/chargepoint/{chargepoint.id}" class="btn btn-sm btn-primary">
{$i18n.t('common:transactionTable.detailButton')}
@ -93,14 +91,8 @@
{/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">«</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
>
<button class="join-item btn bg-base-100">»</button>
</div>
</div>

View file

@ -9,9 +9,5 @@
"tableFormatting": {
"lastSeen": "{{val, relativetime}}"
},
"state": {
"enabled": "Aktiv",
"disabled": "Deaktiviert"
},
"noChargepoints": "Noch keine Ladepunkte vorhanden."
}

View file

@ -9,9 +9,5 @@
"tableFormatting": {
"lastSeen": "{{val, relativetime}}"
},
"state": {
"enabled": "Active",
"disabled": "Disabled"
},
"noChargepoints": "There are no chargepoints yet."
}