From d8a4cd97694a2e24cf3edbabccd2abb938d0850c Mon Sep 17 00:00:00 2001 From: Nicholas Jones Date: Tue, 17 Sep 2024 16:58:59 +0100 Subject: [PATCH] Add e-Totem model --- .../configurations/e-totem-ocpp-16.ts | 26 +++ src/lib/ChargeStation/configurations/index.js | 2 + .../ocpp-16/e-totem/handle-session-stopped.ts | 206 ++++++++++++++++++ .../ocpp-16/e-totem/override-session-uid.ts | 16 ++ .../ocpp-16/send-stop-transaction.ts | 10 +- .../ocpp-20/send-stop-transaction.ts | 10 +- src/lib/ChargeStation/index.ts | 14 ++ src/lib/settings.ts | 78 ++++++- src/screens/Dashboard/SettingsModal.js | 52 +++-- src/screens/Dashboard/index.js | 4 +- 10 files changed, 378 insertions(+), 40 deletions(-) create mode 100644 src/lib/ChargeStation/configurations/e-totem-ocpp-16.ts create mode 100644 src/lib/ChargeStation/eventHandlers/ocpp-16/e-totem/handle-session-stopped.ts create mode 100644 src/lib/ChargeStation/eventHandlers/ocpp-16/e-totem/override-session-uid.ts diff --git a/src/lib/ChargeStation/configurations/e-totem-ocpp-16.ts b/src/lib/ChargeStation/configurations/e-totem-ocpp-16.ts new file mode 100644 index 0000000..885b92e --- /dev/null +++ b/src/lib/ChargeStation/configurations/e-totem-ocpp-16.ts @@ -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, + ], +}; diff --git a/src/lib/ChargeStation/configurations/index.js b/src/lib/ChargeStation/configurations/index.js index 942d8ac..83ea879 100644 --- a/src/lib/ChargeStation/configurations/index.js +++ b/src/lib/ChargeStation/configurations/index.js @@ -3,6 +3,7 @@ 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, @@ -10,6 +11,7 @@ const options = { 'ccv-alpitronic-ocpp1.6': AlpitronicCCVOCPP16, 'sicharge-ocpp1.6': SichargeOCPP16, 'ads-tec-ocpp1.6': AdsTecOCPP16, + 'e-totem-ocpp1.6': ETotemOCPP16, }; export function getOCPPConfigurationOptions() { diff --git a/src/lib/ChargeStation/eventHandlers/ocpp-16/e-totem/handle-session-stopped.ts b/src/lib/ChargeStation/eventHandlers/ocpp-16/e-totem/handle-session-stopped.ts new file mode 100644 index 0000000..98d2c7a --- /dev/null +++ b/src/lib/ChargeStation/eventHandlers/ocpp-16/e-totem/handle-session-stopped.ts @@ -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(); + +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'); +}; diff --git a/src/lib/ChargeStation/eventHandlers/ocpp-16/e-totem/override-session-uid.ts b/src/lib/ChargeStation/eventHandlers/ocpp-16/e-totem/override-session-uid.ts new file mode 100644 index 0000000..6b732c6 --- /dev/null +++ b/src/lib/ChargeStation/eventHandlers/ocpp-16/e-totem/override-session-uid.ts @@ -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; diff --git a/src/lib/ChargeStation/eventHandlers/ocpp-16/send-stop-transaction.ts b/src/lib/ChargeStation/eventHandlers/ocpp-16/send-stop-transaction.ts index 01b27db..87aee0e 100644 --- a/src/lib/ChargeStation/eventHandlers/ocpp-16/send-stop-transaction.ts +++ b/src/lib/ChargeStation/eventHandlers/ocpp-16/send-stop-transaction.ts @@ -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( '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(), @@ -34,7 +38,7 @@ const sendStopTransaction: ChargeStationEventHandler = async ({ ], }, { - timestamp: session.now().toISOString(), + timestamp: session.stopTime.toISOString(), sampledValue: [ { value: session.stateOfCharge.toString(), diff --git a/src/lib/ChargeStation/eventHandlers/ocpp-20/send-stop-transaction.ts b/src/lib/ChargeStation/eventHandlers/ocpp-20/send-stop-transaction.ts index 06b447b..e774b25 100644 --- a/src/lib/ChargeStation/eventHandlers/ocpp-20/send-stop-transaction.ts +++ b/src/lib/ChargeStation/eventHandlers/ocpp-20/send-stop-transaction.ts @@ -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: { @@ -38,7 +40,7 @@ const sendStopTransaction: ChargeStationEventHandler = async ({ ], }, { - timestamp: now, + timestamp: session.stopTime.toISOString(), sampledValue: [ { value: session.kwhElapsed, @@ -48,7 +50,7 @@ const sendStopTransaction: ChargeStationEventHandler = async ({ ], }, { - timestamp: now, + timestamp: session.stopTime.toISOString(), sampledValue: [ { value: session.stateOfCharge, diff --git a/src/lib/ChargeStation/index.ts b/src/lib/ChargeStation/index.ts index 46f9011..5f94a6e 100644 --- a/src/lib/ChargeStation/index.ts +++ b/src/lib/ChargeStation/index.ts @@ -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 { @@ -357,6 +367,8 @@ export default class ChargeStation { // (it's not pretty...) session, }; + + return messageId; } sendStatusNotification(connectorId: number, status: string) { @@ -450,6 +462,7 @@ export class Session { isStartingSession = false; isStoppingSession = false; startTime: Date = clock.now(); + stopTime: Date | undefined; constructor( public connectorId: number, @@ -492,6 +505,7 @@ export class Session { } async stop() { + this.stopTime = this.now(); this.emitter.emitEvent(EventTypes.SessionStopInitiated, { session: this }); } diff --git a/src/lib/settings.ts b/src/lib/settings.ts index a391211..33c9b7c 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -11,6 +11,13 @@ export enum ChargeStationSetting { ChargePointSerialNumber = 'chargePointSerialNumber', ICCID = 'iccid', IMSI = 'imsi', + ETotemTerminalMode = 'eTotemTerminalMode', // szTPEMode + ETotemCostCalculationMode = 'eTotemCostCalculationMode', // szTPEModeFacturation + ETotemFlatRateAmount = 'eTotemFlatRateAmount', // nTPEForfaitCentimes + ETotemPerSessionAmount = 'eTotemPerSessionAmount', // nTPESessionCentimes + ETotemPeriodDuration = 'eTotemPeriodDuration', // nTPEPeriodeSecondes + ETotemPerPeriodAmount = 'eTotemPerPeriodAmount', // nTPEPeriodeCentimes + ETotemPerKWhAmount = 'eTotemPerKWhAmount', // nTPEConsoKWhCentimes } export enum SessionSetting { @@ -27,6 +34,7 @@ export interface SettingsListSetting { options?: undefined | string[]; description: string; defaultValue: string | number; + predicate?: (settings: Settings) => boolean; } export const AuthorizationType = { @@ -42,6 +50,13 @@ export const OCPPVersion = { } as const; export type OCPPVersion = (typeof OCPPVersion)[keyof typeof OCPPVersion]; +const isSicharge = (settings: Settings) => + settings.chargePointModel === 'sicharge'; +const isAdsTec = (settings: Settings) => + settings.chargePointModel === 'ads-tec'; +const isETotem = (settings: Settings) => + settings.chargePointModel === 'e-totem'; + export const settingsList: SettingsListSetting[] = [ { key: ChargeStationSetting.OCPPConfiguration, @@ -87,6 +102,64 @@ export const settingsList: SettingsListSetting[] = [ description: 'The imsi sent during BootNotification', defaultValue: '888888888888888', }, + { + key: ChargeStationSetting.ETotemTerminalMode, + type: 'dropdown', + options: ['etotem', 'etotem_offline'], + name: 'e-Totem Payment Terminal Mode', + description: 'Whether online or offline cost calculations should be used', + defaultValue: 'etotem', + predicate: isETotem, + }, + { + key: ChargeStationSetting.ETotemCostCalculationMode, + type: 'dropdown', + options: ['Legacy', 'DureeConsoReelleSession', 'DureeConsoSession'], + name: 'e-Totem Offline Cost Calculation', + description: 'The mode of calculating offline session costs', + defaultValue: 'DureeConsoReelleSession', + predicate: isETotem, + }, + { + key: ChargeStationSetting.ETotemFlatRateAmount, + name: 'e-Totem Flat Rate Price', + description: + 'The flat per-session amount in eurocents to be applied when "Legacy" cost calculation is used', + defaultValue: 2500, + predicate: isETotem, + }, + { + key: ChargeStationSetting.ETotemPerSessionAmount, + name: 'e-Totem Per Session Price', + description: + 'The per-session amount in eurocents to be applied for non "Legacy" cost calculations', + defaultValue: 100, + predicate: isETotem, + }, + { + key: ChargeStationSetting.ETotemPerPeriodAmount, + name: 'e-Totem Period Price', + description: + 'The per-session amount in eurocents to be applied for non "Legacy" cost calculations', + defaultValue: 200, + predicate: isETotem, + }, + { + key: ChargeStationSetting.ETotemPeriodDuration, + name: 'e-Totem Period Duration', + description: + 'The period in seconds to which the "Period Amount" cost should be applied to', + defaultValue: 3600, // 1 hour + predicate: isETotem, + }, + { + key: ChargeStationSetting.ETotemPerKWhAmount, + name: 'e-Totem kWh Price', + description: + 'The price per-kWh in eurocents to be applied for non "Legacy" cost calculations', + defaultValue: 30, + predicate: isETotem, + }, ]; export const sessionSettingsList: SettingsListSetting[] = [ @@ -126,11 +199,6 @@ export interface Variable16 { predicate?: (settings: Settings) => boolean; } -const isSicharge = (settings: Settings) => - settings.chargePointModel === 'sicharge'; -const isAdsTec = (settings: Settings) => - settings.chargePointModel === 'ads-tec'; - export const defaultVariableConfig16: Variable16[] = [ { key: 'Identity', diff --git a/src/screens/Dashboard/SettingsModal.js b/src/screens/Dashboard/SettingsModal.js index 44c01ff..30a627f 100644 --- a/src/screens/Dashboard/SettingsModal.js +++ b/src/screens/Dashboard/SettingsModal.js @@ -50,6 +50,7 @@ export default class SettingsModal extends React.Component { this.props.onSave(this.state); this.props.close(); }; + render() { const { config, settings, settingsList } = this.state; @@ -60,9 +61,9 @@ export default class SettingsModal extends React.Component {
{settingsList?.map((item) => { - if (item.key === ChargeStationSetting.OCPPConfiguration) { - return ( -
+ return ( +
+ {item.type === 'dropdown' ? ( { - if (this.state.settings.ocppConfiguration != value) { + if (name === 'ocppConfiguration') { this.props.onProtocolChange(value); - this.setSettingsField(e, { name, value }); } + this.setSettingsField(e, { name, value }); }} /> -
- ); - } - return ( -
- - {item.name} - {item.description && ( - - )} - - } - name={item.key} - value={settings[item.key]} - onChange={this.setSettingsField} - /> + ) : ( + + {item.name} + {item.description && ( + + )} + + } + name={item.key} + value={settings[item.key]} + onChange={this.setSettingsField} + /> + )}
); })} diff --git a/src/screens/Dashboard/index.js b/src/screens/Dashboard/index.js index 19deff6..a86c76f 100644 --- a/src/screens/Dashboard/index.js +++ b/src/screens/Dashboard/index.js @@ -323,7 +323,9 @@ export default class Home extends React.Component { trigger={