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