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