ee4134bb0a192f8db9f27075557f1e176ad4d58c
[e-mobility-charging-stations-simulator.git] / src / charging-station / ocpp / OCPPServiceUtils.ts
1 import { readFileSync } from 'node:fs';
2 import { dirname, join } from 'node:path';
3 import { fileURLToPath } from 'node:url';
4
5 import type { DefinedError, ErrorObject, JSONSchemaType } from 'ajv';
6 import { isDate } from 'date-fns';
7
8 import { OCPP16Constants } from './1.6/OCPP16Constants';
9 import { OCPP20Constants } from './2.0/OCPP20Constants';
10 import { OCPPConstants } from './OCPPConstants';
11 import { type ChargingStation, getConfigurationKey, getIdTagsFile } from '../../charging-station';
12 import { BaseError } from '../../exception';
13 import {
14 AuthorizationStatus,
15 type AuthorizeRequest,
16 type AuthorizeResponse,
17 ChargePointErrorCode,
18 type ConnectorStatus,
19 type ConnectorStatusEnum,
20 ErrorType,
21 FileType,
22 IncomingRequestCommand,
23 type JsonType,
24 MessageTrigger,
25 MessageType,
26 MeterValueMeasurand,
27 type MeterValuePhase,
28 type OCPP16StatusNotificationRequest,
29 type OCPP20StatusNotificationRequest,
30 OCPPVersion,
31 RequestCommand,
32 type SampledValueTemplate,
33 StandardParametersKey,
34 type StatusNotificationRequest,
35 type StatusNotificationResponse,
36 } from '../../types';
37 import {
38 handleFileException,
39 isNotEmptyArray,
40 isNotEmptyString,
41 logPrefix,
42 logger,
43 min,
44 } from '../../utils';
45
46 export class OCPPServiceUtils {
47 protected constructor() {
48 // This is intentional
49 }
50
51 public static ajvErrorsToErrorType(errors: ErrorObject[] | null | undefined): ErrorType {
52 if (isNotEmptyArray(errors) === true) {
53 for (const error of errors as DefinedError[]) {
54 switch (error.keyword) {
55 case 'type':
56 return ErrorType.TYPE_CONSTRAINT_VIOLATION;
57 case 'dependencies':
58 case 'required':
59 return ErrorType.OCCURRENCE_CONSTRAINT_VIOLATION;
60 case 'pattern':
61 case 'format':
62 return ErrorType.PROPERTY_CONSTRAINT_VIOLATION;
63 }
64 }
65 }
66 return ErrorType.FORMAT_VIOLATION;
67 }
68
69 public static getMessageTypeString(messageType: MessageType): string {
70 switch (messageType) {
71 case MessageType.CALL_MESSAGE:
72 return 'request';
73 case MessageType.CALL_RESULT_MESSAGE:
74 return 'response';
75 case MessageType.CALL_ERROR_MESSAGE:
76 return 'error';
77 default:
78 return 'unknown';
79 }
80 }
81
82 public static isRequestCommandSupported(
83 chargingStation: ChargingStation,
84 command: RequestCommand,
85 ): boolean {
86 const isRequestCommand = Object.values<RequestCommand>(RequestCommand).includes(command);
87 if (
88 isRequestCommand === true &&
89 !chargingStation.stationInfo?.commandsSupport?.outgoingCommands
90 ) {
91 return true;
92 } else if (
93 isRequestCommand === true &&
94 chargingStation.stationInfo?.commandsSupport?.outgoingCommands?.[command]
95 ) {
96 return chargingStation.stationInfo?.commandsSupport?.outgoingCommands[command];
97 }
98 logger.error(`${chargingStation.logPrefix()} Unknown outgoing OCPP command '${command}'`);
99 return false;
100 }
101
102 public static isIncomingRequestCommandSupported(
103 chargingStation: ChargingStation,
104 command: IncomingRequestCommand,
105 ): boolean {
106 const isIncomingRequestCommand =
107 Object.values<IncomingRequestCommand>(IncomingRequestCommand).includes(command);
108 if (
109 isIncomingRequestCommand === true &&
110 !chargingStation.stationInfo?.commandsSupport?.incomingCommands
111 ) {
112 return true;
113 } else if (
114 isIncomingRequestCommand === true &&
115 chargingStation.stationInfo?.commandsSupport?.incomingCommands?.[command]
116 ) {
117 return chargingStation.stationInfo?.commandsSupport?.incomingCommands[command];
118 }
119 logger.error(`${chargingStation.logPrefix()} Unknown incoming OCPP command '${command}'`);
120 return false;
121 }
122
123 public static isMessageTriggerSupported(
124 chargingStation: ChargingStation,
125 messageTrigger: MessageTrigger,
126 ): boolean {
127 const isMessageTrigger = Object.values(MessageTrigger).includes(messageTrigger);
128 if (isMessageTrigger === true && !chargingStation.stationInfo?.messageTriggerSupport) {
129 return true;
130 } else if (
131 isMessageTrigger === true &&
132 chargingStation.stationInfo?.messageTriggerSupport?.[messageTrigger]
133 ) {
134 return chargingStation.stationInfo?.messageTriggerSupport[messageTrigger];
135 }
136 logger.error(
137 `${chargingStation.logPrefix()} Unknown incoming OCPP message trigger '${messageTrigger}'`,
138 );
139 return false;
140 }
141
142 public static isConnectorIdValid(
143 chargingStation: ChargingStation,
144 ocppCommand: IncomingRequestCommand,
145 connectorId: number,
146 ): boolean {
147 if (connectorId < 0) {
148 logger.error(
149 `${chargingStation.logPrefix()} ${ocppCommand} incoming request received with invalid connector id ${connectorId}`,
150 );
151 return false;
152 }
153 return true;
154 }
155
156 public static convertDateToISOString<T extends JsonType>(obj: T): void {
157 for (const key in obj) {
158 // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
159 if (isDate(obj![key])) {
160 // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
161 (obj![key] as string) = (obj![key] as Date).toISOString();
162 // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
163 } else if (obj![key] !== null && typeof obj![key] === 'object') {
164 // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
165 OCPPServiceUtils.convertDateToISOString<T>(obj![key] as T);
166 }
167 }
168 }
169
170 public static buildStatusNotificationRequest(
171 chargingStation: ChargingStation,
172 connectorId: number,
173 status: ConnectorStatusEnum,
174 evseId?: number,
175 ): StatusNotificationRequest {
176 switch (chargingStation.stationInfo?.ocppVersion) {
177 case OCPPVersion.VERSION_16:
178 return {
179 connectorId,
180 status,
181 errorCode: ChargePointErrorCode.NO_ERROR,
182 } as OCPP16StatusNotificationRequest;
183 case OCPPVersion.VERSION_20:
184 case OCPPVersion.VERSION_201:
185 return {
186 timestamp: new Date(),
187 connectorStatus: status,
188 connectorId,
189 evseId,
190 } as OCPP20StatusNotificationRequest;
191 default:
192 throw new BaseError('Cannot build status notification payload: OCPP version not supported');
193 }
194 }
195
196 public static startHeartbeatInterval(chargingStation: ChargingStation, interval: number): void {
197 if (!chargingStation.heartbeatSetInterval) {
198 chargingStation.startHeartbeat();
199 } else if (chargingStation.getHeartbeatInterval() !== interval) {
200 chargingStation.restartHeartbeat();
201 }
202 }
203
204 public static async sendAndSetConnectorStatus(
205 chargingStation: ChargingStation,
206 connectorId: number,
207 status: ConnectorStatusEnum,
208 evseId?: number,
209 options?: { send: boolean },
210 ) {
211 options = { send: true, ...options };
212 if (options.send) {
213 OCPPServiceUtils.checkConnectorStatusTransition(chargingStation, connectorId, status);
214 await chargingStation.ocppRequestService.requestHandler<
215 StatusNotificationRequest,
216 StatusNotificationResponse
217 >(
218 chargingStation,
219 RequestCommand.STATUS_NOTIFICATION,
220 OCPPServiceUtils.buildStatusNotificationRequest(
221 chargingStation,
222 connectorId,
223 status,
224 evseId,
225 ),
226 );
227 }
228 chargingStation.getConnectorStatus(connectorId)!.status = status;
229 }
230
231 public static async isIdTagAuthorized(
232 chargingStation: ChargingStation,
233 connectorId: number,
234 idTag: string,
235 ): Promise<boolean> {
236 if (
237 !chargingStation.getLocalAuthListEnabled() &&
238 !chargingStation.stationInfo?.remoteAuthorization
239 ) {
240 logger.warn(
241 `${chargingStation.logPrefix()} The charging station expects to authorize RFID tags but nor local authorization nor remote authorization are enabled. Misbehavior may occur`,
242 );
243 }
244 if (
245 chargingStation.getLocalAuthListEnabled() === true &&
246 OCPPServiceUtils.isIdTagLocalAuthorized(chargingStation, idTag)
247 ) {
248 const connectorStatus: ConnectorStatus = chargingStation.getConnectorStatus(connectorId)!;
249 connectorStatus.localAuthorizeIdTag = idTag;
250 connectorStatus.idTagLocalAuthorized = true;
251 return true;
252 } else if (chargingStation.stationInfo?.remoteAuthorization) {
253 return await OCPPServiceUtils.isIdTagRemoteAuthorized(chargingStation, connectorId, idTag);
254 }
255 return false;
256 }
257
258 protected static checkConnectorStatusTransition(
259 chargingStation: ChargingStation,
260 connectorId: number,
261 status: ConnectorStatusEnum,
262 ): boolean {
263 const fromStatus = chargingStation.getConnectorStatus(connectorId)!.status;
264 let transitionAllowed = false;
265 switch (chargingStation.stationInfo?.ocppVersion) {
266 case OCPPVersion.VERSION_16:
267 if (
268 (connectorId === 0 &&
269 OCPP16Constants.ChargePointStatusChargingStationTransitions.findIndex(
270 (transition) => transition.from === fromStatus && transition.to === status,
271 ) !== -1) ||
272 (connectorId > 0 &&
273 OCPP16Constants.ChargePointStatusConnectorTransitions.findIndex(
274 (transition) => transition.from === fromStatus && transition.to === status,
275 ) !== -1)
276 ) {
277 transitionAllowed = true;
278 }
279 break;
280 case OCPPVersion.VERSION_20:
281 case OCPPVersion.VERSION_201:
282 if (
283 (connectorId === 0 &&
284 OCPP20Constants.ChargingStationStatusTransitions.findIndex(
285 (transition) => transition.from === fromStatus && transition.to === status,
286 ) !== -1) ||
287 (connectorId > 0 &&
288 OCPP20Constants.ConnectorStatusTransitions.findIndex(
289 (transition) => transition.from === fromStatus && transition.to === status,
290 ) !== -1)
291 ) {
292 transitionAllowed = true;
293 }
294 break;
295 default:
296 throw new BaseError(
297 // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
298 `Cannot check connector status transition: OCPP version ${chargingStation.stationInfo?.ocppVersion} not supported`,
299 );
300 }
301 if (transitionAllowed === false) {
302 logger.warn(
303 `${chargingStation.logPrefix()} OCPP ${chargingStation.stationInfo
304 ?.ocppVersion} connector id ${connectorId} status transition from '${
305 chargingStation.getConnectorStatus(connectorId)!.status
306 }' to '${status}' is not allowed`,
307 );
308 }
309 return transitionAllowed;
310 }
311
312 protected static parseJsonSchemaFile<T extends JsonType>(
313 relativePath: string,
314 ocppVersion: OCPPVersion,
315 moduleName?: string,
316 methodName?: string,
317 ): JSONSchemaType<T> {
318 const filePath = join(dirname(fileURLToPath(import.meta.url)), relativePath);
319 try {
320 return JSON.parse(readFileSync(filePath, 'utf8')) as JSONSchemaType<T>;
321 } catch (error) {
322 handleFileException(
323 filePath,
324 FileType.JsonSchema,
325 error as NodeJS.ErrnoException,
326 OCPPServiceUtils.logPrefix(ocppVersion, moduleName, methodName),
327 { throwError: false },
328 );
329 return {} as JSONSchemaType<T>;
330 }
331 }
332
333 protected static getSampledValueTemplate(
334 chargingStation: ChargingStation,
335 connectorId: number,
336 measurand: MeterValueMeasurand = MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER,
337 phase?: MeterValuePhase,
338 ): SampledValueTemplate | undefined {
339 const onPhaseStr = phase ? `on phase ${phase} ` : '';
340 if (OCPPConstants.OCPP_MEASURANDS_SUPPORTED.includes(measurand) === false) {
341 logger.warn(
342 `${chargingStation.logPrefix()} Trying to get unsupported MeterValues measurand '${measurand}' ${onPhaseStr}in template on connector id ${connectorId}`,
343 );
344 return;
345 }
346 if (
347 measurand !== MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER &&
348 getConfigurationKey(
349 chargingStation,
350 StandardParametersKey.MeterValuesSampledData,
351 )?.value?.includes(measurand) === false
352 ) {
353 logger.debug(
354 `${chargingStation.logPrefix()} Trying to get MeterValues measurand '${measurand}' ${onPhaseStr}in template on connector id ${connectorId} not found in '${
355 StandardParametersKey.MeterValuesSampledData
356 }' OCPP parameter`,
357 );
358 return;
359 }
360 const sampledValueTemplates: SampledValueTemplate[] =
361 chargingStation.getConnectorStatus(connectorId)!.MeterValues;
362 for (
363 let index = 0;
364 isNotEmptyArray(sampledValueTemplates) === true && index < sampledValueTemplates.length;
365 index++
366 ) {
367 if (
368 OCPPConstants.OCPP_MEASURANDS_SUPPORTED.includes(
369 sampledValueTemplates[index]?.measurand ??
370 MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER,
371 ) === false
372 ) {
373 logger.warn(
374 `${chargingStation.logPrefix()} Unsupported MeterValues measurand '${measurand}' ${onPhaseStr}in template on connector id ${connectorId}`,
375 );
376 } else if (
377 phase &&
378 sampledValueTemplates[index]?.phase === phase &&
379 sampledValueTemplates[index]?.measurand === measurand &&
380 getConfigurationKey(
381 chargingStation,
382 StandardParametersKey.MeterValuesSampledData,
383 )?.value?.includes(measurand) === true
384 ) {
385 return sampledValueTemplates[index];
386 } else if (
387 !phase &&
388 !sampledValueTemplates[index]?.phase &&
389 sampledValueTemplates[index]?.measurand === measurand &&
390 getConfigurationKey(
391 chargingStation,
392 StandardParametersKey.MeterValuesSampledData,
393 )?.value?.includes(measurand) === true
394 ) {
395 return sampledValueTemplates[index];
396 } else if (
397 measurand === MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER &&
398 (!sampledValueTemplates[index]?.measurand ||
399 sampledValueTemplates[index]?.measurand === measurand)
400 ) {
401 return sampledValueTemplates[index];
402 }
403 }
404 if (measurand === MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER) {
405 const errorMsg = `Missing MeterValues for default measurand '${measurand}' in template on connector id ${connectorId}`;
406 logger.error(`${chargingStation.logPrefix()} ${errorMsg}`);
407 throw new BaseError(errorMsg);
408 }
409 logger.debug(
410 `${chargingStation.logPrefix()} No MeterValues for measurand '${measurand}' ${onPhaseStr}in template on connector id ${connectorId}`,
411 );
412 }
413
414 protected static getLimitFromSampledValueTemplateCustomValue(
415 value: string,
416 limit: number,
417 options?: { limitationEnabled?: boolean; unitMultiplier?: number },
418 ): number {
419 options = {
420 ...{
421 limitationEnabled: true,
422 unitMultiplier: 1,
423 },
424 ...options,
425 };
426 const parsedInt = parseInt(value);
427 const numberValue = isNaN(parsedInt) ? Infinity : parsedInt;
428 return options?.limitationEnabled
429 ? min(numberValue * options.unitMultiplier!, limit)
430 : numberValue * options.unitMultiplier!;
431 }
432
433 private static isIdTagLocalAuthorized(chargingStation: ChargingStation, idTag: string): boolean {
434 return (
435 chargingStation.hasIdTags() === true &&
436 isNotEmptyString(
437 chargingStation.idTagsCache
438 .getIdTags(getIdTagsFile(chargingStation.stationInfo)!)
439 ?.find((tag) => tag === idTag),
440 )
441 );
442 }
443
444 private static async isIdTagRemoteAuthorized(
445 chargingStation: ChargingStation,
446 connectorId: number,
447 idTag: string,
448 ): Promise<boolean> {
449 chargingStation.getConnectorStatus(connectorId)!.authorizeIdTag = idTag;
450 return (
451 (
452 await chargingStation.ocppRequestService.requestHandler<
453 AuthorizeRequest,
454 AuthorizeResponse
455 >(chargingStation, RequestCommand.AUTHORIZE, {
456 idTag,
457 })
458 )?.idTagInfo?.status === AuthorizationStatus.ACCEPTED
459 );
460 }
461
462 private static logPrefix = (
463 ocppVersion: OCPPVersion,
464 moduleName?: string,
465 methodName?: string,
466 ): string => {
467 const logMsg =
468 isNotEmptyString(moduleName) && isNotEmptyString(methodName)
469 ? ` OCPP ${ocppVersion} | ${moduleName}.${methodName}:`
470 : ` OCPP ${ocppVersion} |`;
471 return logPrefix(logMsg);
472 };
473 }