ef16bfb8703302c74488af11ff635b8c6da4dd2d
[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 ?? OCPPVersion.VERSION_16) {
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 (!chargingStation.getLocalAuthListEnabled() && !chargingStation.getRemoteAuthorization()) {
237 logger.warn(
238 `${chargingStation.logPrefix()} The charging station expects to authorize RFID tags but nor local authorization nor remote authorization are enabled. Misbehavior may occur`,
239 );
240 }
241 if (
242 chargingStation.getLocalAuthListEnabled() === true &&
243 OCPPServiceUtils.isIdTagLocalAuthorized(chargingStation, idTag)
244 ) {
245 const connectorStatus: ConnectorStatus = chargingStation.getConnectorStatus(connectorId)!;
246 connectorStatus.localAuthorizeIdTag = idTag;
247 connectorStatus.idTagLocalAuthorized = true;
248 return true;
249 } else if (chargingStation.getRemoteAuthorization()) {
250 return await OCPPServiceUtils.isIdTagRemoteAuthorized(chargingStation, connectorId, idTag);
251 }
252 return false;
253 }
254
255 protected static checkConnectorStatusTransition(
256 chargingStation: ChargingStation,
257 connectorId: number,
258 status: ConnectorStatusEnum,
259 ): boolean {
260 const fromStatus = chargingStation.getConnectorStatus(connectorId)!.status;
261 let transitionAllowed = false;
262 switch (chargingStation.stationInfo.ocppVersion) {
263 case OCPPVersion.VERSION_16:
264 if (
265 (connectorId === 0 &&
266 OCPP16Constants.ChargePointStatusChargingStationTransitions.findIndex(
267 (transition) => transition.from === fromStatus && transition.to === status,
268 ) !== -1) ||
269 (connectorId > 0 &&
270 OCPP16Constants.ChargePointStatusConnectorTransitions.findIndex(
271 (transition) => transition.from === fromStatus && transition.to === status,
272 ) !== -1)
273 ) {
274 transitionAllowed = true;
275 }
276 break;
277 case OCPPVersion.VERSION_20:
278 case OCPPVersion.VERSION_201:
279 if (
280 (connectorId === 0 &&
281 OCPP20Constants.ChargingStationStatusTransitions.findIndex(
282 (transition) => transition.from === fromStatus && transition.to === status,
283 ) !== -1) ||
284 (connectorId > 0 &&
285 OCPP20Constants.ConnectorStatusTransitions.findIndex(
286 (transition) => transition.from === fromStatus && transition.to === status,
287 ) !== -1)
288 ) {
289 transitionAllowed = true;
290 }
291 break;
292 default:
293 throw new BaseError(
294 // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
295 `Cannot check connector status transition: OCPP version ${chargingStation.stationInfo.ocppVersion} not supported`,
296 );
297 }
298 if (transitionAllowed === false) {
299 logger.warn(
300 `${chargingStation.logPrefix()} OCPP ${
301 chargingStation.stationInfo.ocppVersion
302 } connector id ${connectorId} status transition from '${
303 chargingStation.getConnectorStatus(connectorId)!.status
304 }' to '${status}' is not allowed`,
305 );
306 }
307 return transitionAllowed;
308 }
309
310 protected static parseJsonSchemaFile<T extends JsonType>(
311 relativePath: string,
312 ocppVersion: OCPPVersion,
313 moduleName?: string,
314 methodName?: string,
315 ): JSONSchemaType<T> {
316 const filePath = join(dirname(fileURLToPath(import.meta.url)), relativePath);
317 try {
318 return JSON.parse(readFileSync(filePath, 'utf8')) as JSONSchemaType<T>;
319 } catch (error) {
320 handleFileException(
321 filePath,
322 FileType.JsonSchema,
323 error as NodeJS.ErrnoException,
324 OCPPServiceUtils.logPrefix(ocppVersion, moduleName, methodName),
325 { throwError: false },
326 );
327 return {} as JSONSchemaType<T>;
328 }
329 }
330
331 protected static getSampledValueTemplate(
332 chargingStation: ChargingStation,
333 connectorId: number,
334 measurand: MeterValueMeasurand = MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER,
335 phase?: MeterValuePhase,
336 ): SampledValueTemplate | undefined {
337 const onPhaseStr = phase ? `on phase ${phase} ` : '';
338 if (OCPPConstants.OCPP_MEASURANDS_SUPPORTED.includes(measurand) === false) {
339 logger.warn(
340 `${chargingStation.logPrefix()} Trying to get unsupported MeterValues measurand '${measurand}' ${onPhaseStr}in template on connector id ${connectorId}`,
341 );
342 return;
343 }
344 if (
345 measurand !== MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER &&
346 getConfigurationKey(
347 chargingStation,
348 StandardParametersKey.MeterValuesSampledData,
349 )?.value?.includes(measurand) === false
350 ) {
351 logger.debug(
352 `${chargingStation.logPrefix()} Trying to get MeterValues measurand '${measurand}' ${onPhaseStr}in template on connector id ${connectorId} not found in '${
353 StandardParametersKey.MeterValuesSampledData
354 }' OCPP parameter`,
355 );
356 return;
357 }
358 const sampledValueTemplates: SampledValueTemplate[] =
359 chargingStation.getConnectorStatus(connectorId)!.MeterValues;
360 for (
361 let index = 0;
362 isNotEmptyArray(sampledValueTemplates) === true && index < sampledValueTemplates.length;
363 index++
364 ) {
365 if (
366 OCPPConstants.OCPP_MEASURANDS_SUPPORTED.includes(
367 sampledValueTemplates[index]?.measurand ??
368 MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER,
369 ) === false
370 ) {
371 logger.warn(
372 `${chargingStation.logPrefix()} Unsupported MeterValues measurand '${measurand}' ${onPhaseStr}in template on connector id ${connectorId}`,
373 );
374 } else if (
375 phase &&
376 sampledValueTemplates[index]?.phase === phase &&
377 sampledValueTemplates[index]?.measurand === measurand &&
378 getConfigurationKey(
379 chargingStation,
380 StandardParametersKey.MeterValuesSampledData,
381 )?.value?.includes(measurand) === true
382 ) {
383 return sampledValueTemplates[index];
384 } else if (
385 !phase &&
386 !sampledValueTemplates[index]?.phase &&
387 sampledValueTemplates[index]?.measurand === measurand &&
388 getConfigurationKey(
389 chargingStation,
390 StandardParametersKey.MeterValuesSampledData,
391 )?.value?.includes(measurand) === true
392 ) {
393 return sampledValueTemplates[index];
394 } else if (
395 measurand === MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER &&
396 (!sampledValueTemplates[index]?.measurand ||
397 sampledValueTemplates[index]?.measurand === measurand)
398 ) {
399 return sampledValueTemplates[index];
400 }
401 }
402 if (measurand === MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER) {
403 const errorMsg = `Missing MeterValues for default measurand '${measurand}' in template on connector id ${connectorId}`;
404 logger.error(`${chargingStation.logPrefix()} ${errorMsg}`);
405 throw new BaseError(errorMsg);
406 }
407 logger.debug(
408 `${chargingStation.logPrefix()} No MeterValues for measurand '${measurand}' ${onPhaseStr}in template on connector id ${connectorId}`,
409 );
410 }
411
412 protected static getLimitFromSampledValueTemplateCustomValue(
413 value: string,
414 limit: number,
415 options?: { limitationEnabled?: boolean; unitMultiplier?: number },
416 ): number {
417 options = {
418 ...{
419 limitationEnabled: true,
420 unitMultiplier: 1,
421 },
422 ...options,
423 };
424 const parsedInt = parseInt(value);
425 const numberValue = isNaN(parsedInt) ? Infinity : parsedInt;
426 return options?.limitationEnabled
427 ? min(numberValue * options.unitMultiplier!, limit)
428 : numberValue * options.unitMultiplier!;
429 }
430
431 private static isIdTagLocalAuthorized(chargingStation: ChargingStation, idTag: string): boolean {
432 return (
433 chargingStation.hasIdTags() === true &&
434 isNotEmptyString(
435 chargingStation.idTagsCache
436 .getIdTags(getIdTagsFile(chargingStation.stationInfo)!)
437 ?.find((tag) => tag === idTag),
438 )
439 );
440 }
441
442 private static async isIdTagRemoteAuthorized(
443 chargingStation: ChargingStation,
444 connectorId: number,
445 idTag: string,
446 ): Promise<boolean> {
447 chargingStation.getConnectorStatus(connectorId)!.authorizeIdTag = idTag;
448 return (
449 (
450 await chargingStation.ocppRequestService.requestHandler<
451 AuthorizeRequest,
452 AuthorizeResponse
453 >(chargingStation, RequestCommand.AUTHORIZE, {
454 idTag,
455 })
456 )?.idTagInfo?.status === AuthorizationStatus.ACCEPTED
457 );
458 }
459
460 private static logPrefix = (
461 ocppVersion: OCPPVersion,
462 moduleName?: string,
463 methodName?: string,
464 ): string => {
465 const logMsg =
466 isNotEmptyString(moduleName) && isNotEmptyString(methodName)
467 ? ` OCPP ${ocppVersion} | ${moduleName}.${methodName}:`
468 : ` OCPP ${ocppVersion} |`;
469 return logPrefix(logMsg);
470 };
471 }