Skip to content

Commit

Permalink
Merge pull request #101 from e-flux-platform/etotem-vendor
Browse files Browse the repository at this point in the history
Add e-Totem model
  • Loading branch information
nick-jones authored Sep 18, 2024
2 parents a47566a + d8a4cd9 commit c8eb4d5
Show file tree
Hide file tree
Showing 10 changed files with 378 additions and 40 deletions.
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

0 comments on commit c8eb4d5

Please sign in to comment.