Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add e-Totem model #101

Merged
merged 1 commit into from
Sep 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions src/lib/ChargeStation/configurations/e-totem-ocpp-16.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import DefaultOCPP16 from 'lib/ChargeStation/configurations/default-ocpp-16';
import sendAuthorize from 'lib/ChargeStation/eventHandlers/ocpp-16/send-authorize';
import {
EventTypes as e,
EventTypes16 as e16,
} from 'lib/ChargeStation/eventHandlers/event-types';
import overrideSessionUid from 'lib/ChargeStation/eventHandlers/ocpp-16/e-totem/override-session-uid';
import {
calculateCostsAndSendReceipt,
processDataTransferResult,
} from 'lib/ChargeStation/eventHandlers/ocpp-16/e-totem/handle-session-stopped';
import sendStatusNotificationFinishing from 'lib/ChargeStation/eventHandlers/ocpp-16/send-status-notification-finishing';
import sendStatusNotificationAvailable from 'lib/ChargeStation/eventHandlers/ocpp-16/send-status-notification-available';
import handleTransactionStoppedUI from 'lib/ChargeStation/eventHandlers/ocpp-16/handle-transaction-stopped-ui';

export default {
...DefaultOCPP16,
[e.SessionStartInitiated]: [overrideSessionUid, sendAuthorize],
[e.DataTransferCallResultReceived]: [processDataTransferResult],
[e16.StopTransactionAccepted]: [
sendStatusNotificationFinishing,
sendStatusNotificationAvailable,
calculateCostsAndSendReceipt,
handleTransactionStoppedUI,
],
};
2 changes: 2 additions & 0 deletions src/lib/ChargeStation/configurations/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@ import DefaultOCPP20 from './default-ocpp-20';
import AlpitronicCCVOCPP16 from './alpitronic-ccv-ocpp-16';
import SichargeOCPP16 from './sicharge-ocpp-16';
import AdsTecOCPP16 from './ads-tec-ocpp-16';
import ETotemOCPP16 from './e-totem-ocpp-16';

const options = {
'default-ocpp1.6': DefaultOCPP16,
'default-ocpp2.0.1': DefaultOCPP20,
'ccv-alpitronic-ocpp1.6': AlpitronicCCVOCPP16,
'sicharge-ocpp1.6': SichargeOCPP16,
'ads-tec-ocpp1.6': AdsTecOCPP16,
'e-totem-ocpp1.6': ETotemOCPP16,
};

export function getOCPPConfigurationOptions() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
import { DataTransferResponse } from 'schemas/ocpp/1.6/DataTransferResponse';
import { ChargeStationEventHandler } from 'lib/ChargeStation/eventHandlers';
import { DataTransferRequest } from 'schemas/ocpp/1.6/DataTransfer';
import ChargeStation, { Session } from 'lib/ChargeStation';
import { AuthorizationType } from 'lib/settings';

interface CostCalculationRequest {
transactionId: number;
meterStop: number;
timestamp: string;
}

interface CostCalculationResponse {
prixCents: number;
ticketId: string;
}

const vendorId = 'fr.e-totem';
const messageIdPriceRequest = 'EtotemTpeDemandePrix';
const messageIdOnlineReceipt = 'EtotemTpeCRonline';
const messageIdOfflineReceipt = 'EtotemTpeCRoffline';

const stoppedSessions = new Map<string, Session>();

export const calculateCostsAndSendReceipt: ChargeStationEventHandler = async (
params
) => {
const { session, chargepoint } = params;

if (session.options.authorizationType !== AuthorizationType.CreditCard) {
return; // session will stop the normal route
}

if (!session.stopTime) {
throw new Error('stopTime must be set');
}

if (chargepoint.settings.eTotemTerminalMode === 'etotem') {
// Request online price, receipt will be sent once we get a result
stoppedSessions.set(session.transactionId.toString(), session);
const request: CostCalculationRequest = {
transactionId: Number(session.transactionId),
meterStop: Math.round(session.kwhElapsed * 1000),
timestamp: (session.stopTime as Date).toISOString(),
};
chargepoint.writeCall('DataTransfer', {
vendorId,
messageId: messageIdPriceRequest,
data: JSON.stringify(request),
});
} else {
// Otherwise the station is configured to always use offline
const price = calculatePriceOffline(chargepoint, session);
sendOfflineReceipt(chargepoint, session, price);
}
};

export const processDataTransferResult: ChargeStationEventHandler<
DataTransferRequest,
DataTransferResponse
> = async (params) => {
const { callMessageBody, callResultMessageBody, chargepoint } = params;

if (callMessageBody.vendorId !== vendorId) {
return;
}

if (callMessageBody.messageId === messageIdPriceRequest) {
if (callMessageBody.data === undefined) {
throw new Error(`Call message body must be defined`);
}

// Retrieve the stopped session, as it is no longer available in the chargepoint..
// TODO: should we keep hold of historical sessions on the chargepoint to make this less clunky?
const request = JSON.parse(callMessageBody.data) as CostCalculationRequest;
const transactionId = request.transactionId.toString();
const session = stoppedSessions.get(transactionId);
if (!session) {
throw new Error(`Failed to locate session: ${request.transactionId}`);
}
stoppedSessions.delete(transactionId);

const { status, data } = callResultMessageBody;
if (status === 'Accepted' && data !== undefined) {
const response = JSON.parse(data) as CostCalculationResponse;
sendOnlineReceipt(chargepoint, session, response);
} else {
console.warn('e-Totem: online cost calculation failed, using offline');
const price = calculatePriceOffline(chargepoint, session);
sendOfflineReceipt(chargepoint, session, price);
}
}
};

const calculatePriceOffline = (
chargepoint: ChargeStation,
session: Session
): number => {
const calculationMode = chargepoint.settings.eTotemCostCalculationMode;
const startTime = session.startTime;
const stopTime = session.stopTime as Date;

if (calculationMode === 'Legacy') {
return chargepoint.settings.eTotemFlatRateAmount;
}

const sessionCosts = chargepoint.settings.eTotemPerSessionAmount;

const periods =
(stopTime.getTime() - startTime.getTime()) /
1000 /
chargepoint.settings.eTotemPeriodDuration;
const timeCosts = periods * chargepoint.settings.eTotemPerPeriodAmount;

const kWhCosts =
(calculationMode === 'DureeConsoSession'
? Math.ceil(session.kwhElapsed)
: session.kwhElapsed) * chargepoint.settings.eTotemPerKWhAmount;

return Math.round(sessionCosts + timeCosts + kWhCosts);
};

const sendOfflineReceipt = (
chargepoint: ChargeStation,
session: Session,
price: number
) => {
chargepoint.writeCall('DataTransfer', {
vendorId,
messageId: messageIdOfflineReceipt,
data: JSON.stringify({
transactionId: Number(session.transactionId),
meterStop: Math.round(session.kwhElapsed * 1000),
timestamp: session.stopTime?.toISOString(),
prixCents: price,
ticketId: `ABC${Math.floor(Math.random() * 999999)}`,
numTpe: '123456',
status: 'Succeed',
ticketCaisse: buildReceipt(session, price),
}),
});
};

const sendOnlineReceipt = (
chargepoint: ChargeStation,
session: Session,
costCalculation: CostCalculationResponse
) => {
chargepoint.writeCall('DataTransfer', {
vendorId,
messageId: messageIdOnlineReceipt,
data: JSON.stringify({
ticketId: costCalculation.ticketId,
numTpe: '123456',
status: 'Succeed',
ticketCaisse: buildReceipt(session, costCalculation.prixCents),
}),
});
};

const buildReceipt = (session: Session, price: number): string => {
const now = session.now();

const formattedDate = [
now.getDate().toString().padStart(2, '0'),
now.getMonth().toString().padStart(2, '0'),
now.getFullYear().toString().substring(2, 4),
].join('/');

const formattedTime = [
now.getHours().toString().padStart(2, '0'),
now.getMinutes().toString().padStart(2, '0'),
now.getSeconds().toString().padStart(2, '0'),
].join(':');

const formattedPrice = (price / 100).toLocaleString('fr-FR', {
maximumFractionDigits: 2,
});

return [
' CARTE BANCAIRE',
' SANS CONTACT',
'CREDIT AGRICOLE',
'A0000000111111',
'CB CLEO',
`le ${formattedDate} a ${formattedTime}`,
'E TOTEM',
'SAINT-ETIENNE',
'40000',
'2413823',
'14123',
'51234500000000',
'************1234',
'C1230F00AB1AB1A1',
'123 001 123123',
'C @',
'No AUTO : 123123',
'MONTANT REEL=',
` ${formattedPrice} EUR`,
'DEBIT',
' TICKET CLIENT',
' A CONSERVER',
'MERCI AU REVOIR',
'GAA1AAA8XA',
].join('\n');
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { ChargeStationEventHandler } from 'lib/ChargeStation/eventHandlers';
import { AuthorizationType } from 'lib/settings';

const overrideSessionUid: ChargeStationEventHandler = async (params) => {
const { session, chargepoint } = params;
if (session.options.authorizationType !== AuthorizationType.CreditCard) {
return; // retain current idTag
}

const paddedSerialNumber =
chargepoint.settings.chargePointSerialNumber.padStart(14, '0');
const paddedConnectorId = session.connectorId.toString().padStart(2, '0');
session.options.uid = `FF${paddedSerialNumber}${paddedConnectorId}`;
};

export default overrideSessionUid;
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,21 @@ const sendStopTransaction: ChargeStationEventHandler = async ({
chargepoint.sessions[session.connectorId].tickInterval?.stop();
await sleep(1000);

if (!session.stopTime) {
throw new Error('stopTime must be set');
}

chargepoint.writeCall<StopTransactionRequest>(
'StopTransaction',
{
idTag: session.options.uid,
meterStop: Math.round(session.kwhElapsed * 1000),
timestamp: session.now().toISOString(),
timestamp: session.stopTime.toISOString(),
reason: 'EVDisconnected',
transactionId: Number(session.transactionId),
transactionData: [
{
timestamp: session.now().toISOString(),
timestamp: session.stopTime.toISOString(),
sampledValue: [
{
value: session.kwhElapsed.toString(),
Expand All @@ -34,7 +38,7 @@ const sendStopTransaction: ChargeStationEventHandler = async ({
],
},
{
timestamp: session.now().toISOString(),
timestamp: session.stopTime.toISOString(),
sampledValue: [
{
value: session.stateOfCharge.toString(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,15 @@ const sendStopTransaction: ChargeStationEventHandler = async ({

await sleep(1000);

const now = clock.now().toISOString();
if (!session.stopTime) {
throw new Error('stopTime must be set');
}

chargepoint.writeCall(
'TransactionEvent',
{
eventType: 'Ended',
timestamp: now,
timestamp: session.stopTime.toISOString(),
triggerReason: 'StopAuthorized',
seqNo: session.seqNo,
transactionInfo: {
Expand All @@ -38,7 +40,7 @@ const sendStopTransaction: ChargeStationEventHandler = async ({
],
},
{
timestamp: now,
timestamp: session.stopTime.toISOString(),
sampledValue: [
{
value: session.kwhElapsed,
Expand All @@ -48,7 +50,7 @@ const sendStopTransaction: ChargeStationEventHandler = async ({
],
},
{
timestamp: now,
timestamp: session.stopTime.toISOString(),
sampledValue: [
{
value: session.stateOfCharge,
Expand Down
14 changes: 14 additions & 0 deletions src/lib/ChargeStation/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,16 @@ export interface Settings {
iccid: string;
imsi: string;
Identity: string;
eTotemTerminalMode: 'etotem' | 'etotem_offline';
eTotemCostCalculationMode:
| 'Legacy'
| 'DureeConsoReelleSession'
| 'DureeConsoSession';
eTotemFlatRateAmount: number;
eTotemPerSessionAmount: number;
eTotemPeriodDuration: number;
eTotemPerPeriodAmount: number;
eTotemPerKWhAmount: number;
}

interface CallLogItem {
Expand Down Expand Up @@ -357,6 +367,8 @@ export default class ChargeStation {
// (it's not pretty...)
session,
};

return messageId;
}

sendStatusNotification(connectorId: number, status: string) {
Expand Down Expand Up @@ -450,6 +462,7 @@ export class Session {
isStartingSession = false;
isStoppingSession = false;
startTime: Date = clock.now();
stopTime: Date | undefined;

constructor(
public connectorId: number,
Expand Down Expand Up @@ -492,6 +505,7 @@ export class Session {
}

async stop() {
this.stopTime = this.now();
this.emitter.emitEvent(EventTypes.SessionStopInitiated, { session: this });
}

Expand Down
Loading