e378fdec87bd26832ce1abf4ebe2cddd53b8a1b9
[e-mobility-charging-stations-simulator.git] / src / charging-station / ChargingStationUtils.ts
1 import crypto from 'crypto';
2 import path from 'path';
3 import { fileURLToPath } from 'url';
4
5 import moment from 'moment';
6
7 import BaseError from '../exception/BaseError';
8 import type { ChargingStationInfo } from '../types/ChargingStationInfo';
9 import {
10 AmpereUnits,
11 type ChargingStationTemplate,
12 CurrentType,
13 Voltage,
14 } from '../types/ChargingStationTemplate';
15 import type { SampledValueTemplate } from '../types/MeasurandPerPhaseSampledValueTemplates';
16 import { ChargingProfileKindType, RecurrencyKindType } from '../types/ocpp/1.6/ChargingProfile';
17 import type { ChargingProfile, ChargingSchedulePeriod } from '../types/ocpp/ChargingProfile';
18 import { StandardParametersKey } from '../types/ocpp/Configuration';
19 import { MeterValueMeasurand, MeterValuePhase } from '../types/ocpp/MeterValues';
20 import {
21 type BootNotificationRequest,
22 IncomingRequestCommand,
23 RequestCommand,
24 } from '../types/ocpp/Requests';
25 import { WorkerProcessType } from '../types/Worker';
26 import Configuration from '../utils/Configuration';
27 import Constants from '../utils/Constants';
28 import logger from '../utils/Logger';
29 import Utils from '../utils/Utils';
30 import type ChargingStation from './ChargingStation';
31 import { ChargingStationConfigurationUtils } from './ChargingStationConfigurationUtils';
32
33 const moduleName = 'ChargingStationUtils';
34
35 export class ChargingStationUtils {
36 private constructor() {
37 // This is intentional
38 }
39
40 public static getChargingStationId(
41 index: number,
42 stationTemplate: ChargingStationTemplate
43 ): string {
44 // In case of multiple instances: add instance index to charging station id
45 const instanceIndex = process.env.CF_INSTANCE_INDEX ?? 0;
46 const idSuffix = stationTemplate.nameSuffix ?? '';
47 const idStr = '000000000' + index.toString();
48 return stationTemplate?.fixedName
49 ? stationTemplate.baseName
50 : stationTemplate.baseName +
51 '-' +
52 instanceIndex.toString() +
53 idStr.substring(idStr.length - 4) +
54 idSuffix;
55 }
56
57 public static getHashId(index: number, stationTemplate: ChargingStationTemplate): string {
58 const hashBootNotificationRequest = {
59 chargePointModel: stationTemplate.chargePointModel,
60 chargePointVendor: stationTemplate.chargePointVendor,
61 ...(!Utils.isUndefined(stationTemplate.chargeBoxSerialNumberPrefix) && {
62 chargeBoxSerialNumber: stationTemplate.chargeBoxSerialNumberPrefix,
63 }),
64 ...(!Utils.isUndefined(stationTemplate.chargePointSerialNumberPrefix) && {
65 chargePointSerialNumber: stationTemplate.chargePointSerialNumberPrefix,
66 }),
67 ...(!Utils.isUndefined(stationTemplate.firmwareVersion) && {
68 firmwareVersion: stationTemplate.firmwareVersion,
69 }),
70 ...(!Utils.isUndefined(stationTemplate.iccid) && { iccid: stationTemplate.iccid }),
71 ...(!Utils.isUndefined(stationTemplate.imsi) && { imsi: stationTemplate.imsi }),
72 ...(!Utils.isUndefined(stationTemplate.meterSerialNumberPrefix) && {
73 meterSerialNumber: stationTemplate.meterSerialNumberPrefix,
74 }),
75 ...(!Utils.isUndefined(stationTemplate.meterType) && {
76 meterType: stationTemplate.meterType,
77 }),
78 };
79 return crypto
80 .createHash(Constants.DEFAULT_HASH_ALGORITHM)
81 .update(
82 JSON.stringify(hashBootNotificationRequest) +
83 ChargingStationUtils.getChargingStationId(index, stationTemplate)
84 )
85 .digest('hex');
86 }
87
88 public static getTemplateMaxNumberOfConnectors(stationTemplate: ChargingStationTemplate): number {
89 const templateConnectors = stationTemplate?.Connectors;
90 if (!templateConnectors) {
91 return -1;
92 }
93 return Object.keys(templateConnectors).length;
94 }
95
96 public static checkTemplateMaxConnectors(
97 templateMaxConnectors: number,
98 templateFile: string,
99 logPrefix: string
100 ): void {
101 if (templateMaxConnectors === 0) {
102 logger.warn(
103 `${logPrefix} Charging station information from template ${templateFile} with empty connectors configuration`
104 );
105 } else if (templateMaxConnectors < 0) {
106 logger.error(
107 `${logPrefix} Charging station information from template ${templateFile} with no connectors configuration defined`
108 );
109 }
110 }
111
112 public static getConfiguredNumberOfConnectors(stationTemplate: ChargingStationTemplate): number {
113 let configuredMaxConnectors: number;
114 if (Utils.isEmptyArray(stationTemplate.numberOfConnectors) === false) {
115 const numberOfConnectors = stationTemplate.numberOfConnectors as number[];
116 configuredMaxConnectors =
117 numberOfConnectors[Math.floor(Utils.secureRandom() * numberOfConnectors.length)];
118 } else if (Utils.isUndefined(stationTemplate.numberOfConnectors) === false) {
119 configuredMaxConnectors = stationTemplate.numberOfConnectors as number;
120 } else {
121 configuredMaxConnectors = stationTemplate?.Connectors[0]
122 ? ChargingStationUtils.getTemplateMaxNumberOfConnectors(stationTemplate) - 1
123 : ChargingStationUtils.getTemplateMaxNumberOfConnectors(stationTemplate);
124 }
125 return configuredMaxConnectors;
126 }
127
128 public static checkConfiguredMaxConnectors(
129 configuredMaxConnectors: number,
130 templateFile: string,
131 logPrefix: string
132 ): void {
133 if (configuredMaxConnectors <= 0) {
134 logger.warn(
135 `${logPrefix} Charging station information from template ${templateFile} with ${configuredMaxConnectors} connectors`
136 );
137 }
138 }
139
140 public static createBootNotificationRequest(
141 stationInfo: ChargingStationInfo
142 ): BootNotificationRequest {
143 return {
144 chargePointModel: stationInfo.chargePointModel,
145 chargePointVendor: stationInfo.chargePointVendor,
146 ...(!Utils.isUndefined(stationInfo.chargeBoxSerialNumber) && {
147 chargeBoxSerialNumber: stationInfo.chargeBoxSerialNumber,
148 }),
149 ...(!Utils.isUndefined(stationInfo.chargePointSerialNumber) && {
150 chargePointSerialNumber: stationInfo.chargePointSerialNumber,
151 }),
152 ...(!Utils.isUndefined(stationInfo.firmwareVersion) && {
153 firmwareVersion: stationInfo.firmwareVersion,
154 }),
155 ...(!Utils.isUndefined(stationInfo.iccid) && { iccid: stationInfo.iccid }),
156 ...(!Utils.isUndefined(stationInfo.imsi) && { imsi: stationInfo.imsi }),
157 ...(!Utils.isUndefined(stationInfo.meterSerialNumber) && {
158 meterSerialNumber: stationInfo.meterSerialNumber,
159 }),
160 ...(!Utils.isUndefined(stationInfo.meterType) && {
161 meterType: stationInfo.meterType,
162 }),
163 };
164 }
165
166 public static workerPoolInUse(): boolean {
167 return [WorkerProcessType.DYNAMIC_POOL, WorkerProcessType.STATIC_POOL].includes(
168 Configuration.getWorker().processType
169 );
170 }
171
172 public static workerDynamicPoolInUse(): boolean {
173 return Configuration.getWorker().processType === WorkerProcessType.DYNAMIC_POOL;
174 }
175
176 public static warnDeprecatedTemplateKey(
177 template: ChargingStationTemplate,
178 key: string,
179 templateFile: string,
180 logPrefix: string,
181 logMsgToAppend = ''
182 ): void {
183 if (!Utils.isUndefined(template[key])) {
184 logger.warn(
185 `${logPrefix} Deprecated template key '${key}' usage in file '${templateFile}'${
186 logMsgToAppend && '. ' + logMsgToAppend
187 }`
188 );
189 }
190 }
191
192 public static convertDeprecatedTemplateKey(
193 template: ChargingStationTemplate,
194 deprecatedKey: string,
195 key: string
196 ): void {
197 if (!Utils.isUndefined(template[deprecatedKey])) {
198 template[key] = template[deprecatedKey] as unknown;
199 delete template[deprecatedKey];
200 }
201 }
202
203 public static stationTemplateToStationInfo(
204 stationTemplate: ChargingStationTemplate
205 ): ChargingStationInfo {
206 stationTemplate = Utils.cloneObject(stationTemplate);
207 delete stationTemplate.power;
208 delete stationTemplate.powerUnit;
209 delete stationTemplate.Configuration;
210 delete stationTemplate.AutomaticTransactionGenerator;
211 delete stationTemplate.chargeBoxSerialNumberPrefix;
212 delete stationTemplate.chargePointSerialNumberPrefix;
213 delete stationTemplate.meterSerialNumberPrefix;
214 return stationTemplate as unknown as ChargingStationInfo;
215 }
216
217 public static createStationInfoHash(stationInfo: ChargingStationInfo): void {
218 delete stationInfo.infoHash;
219 stationInfo.infoHash = crypto
220 .createHash(Constants.DEFAULT_HASH_ALGORITHM)
221 .update(JSON.stringify(stationInfo))
222 .digest('hex');
223 }
224
225 public static createSerialNumber(
226 stationTemplate: ChargingStationTemplate,
227 stationInfo: ChargingStationInfo = {} as ChargingStationInfo,
228 params: {
229 randomSerialNumberUpperCase?: boolean;
230 randomSerialNumber?: boolean;
231 } = {
232 randomSerialNumberUpperCase: true,
233 randomSerialNumber: true,
234 }
235 ): void {
236 params = params ?? {};
237 params.randomSerialNumberUpperCase = params?.randomSerialNumberUpperCase ?? true;
238 params.randomSerialNumber = params?.randomSerialNumber ?? true;
239 const serialNumberSuffix = params?.randomSerialNumber
240 ? ChargingStationUtils.getRandomSerialNumberSuffix({
241 upperCase: params.randomSerialNumberUpperCase,
242 })
243 : '';
244 stationInfo.chargePointSerialNumber =
245 stationTemplate?.chargePointSerialNumberPrefix &&
246 stationTemplate.chargePointSerialNumberPrefix + serialNumberSuffix;
247 stationInfo.chargeBoxSerialNumber =
248 stationTemplate?.chargeBoxSerialNumberPrefix &&
249 stationTemplate.chargeBoxSerialNumberPrefix + serialNumberSuffix;
250 stationInfo.meterSerialNumber =
251 stationTemplate?.meterSerialNumberPrefix &&
252 stationTemplate.meterSerialNumberPrefix + serialNumberSuffix;
253 }
254
255 public static propagateSerialNumber(
256 stationTemplate: ChargingStationTemplate,
257 stationInfoSrc: ChargingStationInfo,
258 stationInfoDst: ChargingStationInfo = {} as ChargingStationInfo
259 ) {
260 if (!stationInfoSrc || !stationTemplate) {
261 throw new BaseError(
262 'Missing charging station template or existing configuration to propagate serial number'
263 );
264 }
265 stationTemplate?.chargePointSerialNumberPrefix && stationInfoSrc?.chargePointSerialNumber
266 ? (stationInfoDst.chargePointSerialNumber = stationInfoSrc.chargePointSerialNumber)
267 : stationInfoDst?.chargePointSerialNumber && delete stationInfoDst.chargePointSerialNumber;
268 stationTemplate?.chargeBoxSerialNumberPrefix && stationInfoSrc?.chargeBoxSerialNumber
269 ? (stationInfoDst.chargeBoxSerialNumber = stationInfoSrc.chargeBoxSerialNumber)
270 : stationInfoDst?.chargeBoxSerialNumber && delete stationInfoDst.chargeBoxSerialNumber;
271 stationTemplate?.meterSerialNumberPrefix && stationInfoSrc?.meterSerialNumber
272 ? (stationInfoDst.meterSerialNumber = stationInfoSrc.meterSerialNumber)
273 : stationInfoDst?.meterSerialNumber && delete stationInfoDst.meterSerialNumber;
274 }
275
276 public static getAmperageLimitationUnitDivider(stationInfo: ChargingStationInfo): number {
277 let unitDivider = 1;
278 switch (stationInfo.amperageLimitationUnit) {
279 case AmpereUnits.DECI_AMPERE:
280 unitDivider = 10;
281 break;
282 case AmpereUnits.CENTI_AMPERE:
283 unitDivider = 100;
284 break;
285 case AmpereUnits.MILLI_AMPERE:
286 unitDivider = 1000;
287 break;
288 }
289 return unitDivider;
290 }
291
292 public static setChargingProfile(
293 chargingStation: ChargingStation,
294 connectorId: number,
295 cp: ChargingProfile
296 ): void {
297 if (Utils.isNullOrUndefined(chargingStation.getConnectorStatus(connectorId).chargingProfiles)) {
298 logger.error(
299 `${chargingStation.logPrefix()} Trying to set a charging profile on connectorId ${connectorId} with an uninitialized charging profiles array attribute, applying deferred initialization`
300 );
301 chargingStation.getConnectorStatus(connectorId).chargingProfiles = [];
302 }
303 if (Array.isArray(chargingStation.getConnectorStatus(connectorId).chargingProfiles) === false) {
304 logger.error(
305 `${chargingStation.logPrefix()} Trying to set a charging profile on connectorId ${connectorId} with an improper attribute type for the charging profiles array, applying proper type initialization`
306 );
307 chargingStation.getConnectorStatus(connectorId).chargingProfiles = [];
308 }
309 let cpReplaced = false;
310 if (!Utils.isEmptyArray(chargingStation.getConnectorStatus(connectorId).chargingProfiles)) {
311 chargingStation
312 .getConnectorStatus(connectorId)
313 .chargingProfiles?.forEach((chargingProfile: ChargingProfile, index: number) => {
314 if (
315 chargingProfile.chargingProfileId === cp.chargingProfileId ||
316 (chargingProfile.stackLevel === cp.stackLevel &&
317 chargingProfile.chargingProfilePurpose === cp.chargingProfilePurpose)
318 ) {
319 chargingStation.getConnectorStatus(connectorId).chargingProfiles[index] = cp;
320 cpReplaced = true;
321 }
322 });
323 }
324 !cpReplaced && chargingStation.getConnectorStatus(connectorId).chargingProfiles?.push(cp);
325 }
326
327 /**
328 * Charging profiles should already be sorted by connectorId and stack level (highest stack level has priority)
329 *
330 * @param {ChargingProfile[]} chargingProfiles
331 * @param {string} logPrefix
332 * @returns {{ limit, matchingChargingProfile }}
333 */
334 public static getLimitFromChargingProfiles(
335 chargingProfiles: ChargingProfile[],
336 logPrefix: string
337 ): {
338 limit: number;
339 matchingChargingProfile: ChargingProfile;
340 } | null {
341 const debugLogMsg = `${logPrefix} ${moduleName}.getLimitFromChargingProfiles: Matching charging profile found for power limitation: %j`;
342 for (const chargingProfile of chargingProfiles) {
343 // Set helpers
344 const currentMoment = moment();
345 const chargingSchedule = chargingProfile.chargingSchedule;
346 // Check type (recurring) and if it is already active
347 // Adjust the daily recurring schedule to today
348 if (
349 chargingProfile.chargingProfileKind === ChargingProfileKindType.RECURRING &&
350 chargingProfile.recurrencyKind === RecurrencyKindType.DAILY &&
351 currentMoment.isAfter(chargingSchedule.startSchedule)
352 ) {
353 const currentDate = new Date();
354 chargingSchedule.startSchedule = new Date(chargingSchedule.startSchedule);
355 chargingSchedule.startSchedule.setFullYear(
356 currentDate.getFullYear(),
357 currentDate.getMonth(),
358 currentDate.getDate()
359 );
360 // Check if the start of the schedule is yesterday
361 if (moment(chargingSchedule.startSchedule).isAfter(currentMoment)) {
362 chargingSchedule.startSchedule.setDate(currentDate.getDate() - 1);
363 }
364 } else if (moment(chargingSchedule.startSchedule).isAfter(currentMoment)) {
365 return null;
366 }
367 // Check if the charging profile is active
368 if (
369 moment(chargingSchedule.startSchedule)
370 .add(chargingSchedule.duration, 's')
371 .isAfter(currentMoment)
372 ) {
373 let lastButOneSchedule: ChargingSchedulePeriod;
374 // Search the right schedule period
375 for (const schedulePeriod of chargingSchedule.chargingSchedulePeriod) {
376 // Handling of only one period
377 if (
378 chargingSchedule.chargingSchedulePeriod.length === 1 &&
379 schedulePeriod.startPeriod === 0
380 ) {
381 const result = {
382 limit: schedulePeriod.limit,
383 matchingChargingProfile: chargingProfile,
384 };
385 logger.debug(debugLogMsg, result);
386 return result;
387 }
388 // Find the right schedule period
389 if (
390 moment(chargingSchedule.startSchedule)
391 .add(schedulePeriod.startPeriod, 's')
392 .isAfter(currentMoment)
393 ) {
394 // Found the schedule: last but one is the correct one
395 const result = {
396 limit: lastButOneSchedule.limit,
397 matchingChargingProfile: chargingProfile,
398 };
399 logger.debug(debugLogMsg, result);
400 return result;
401 }
402 // Keep it
403 lastButOneSchedule = schedulePeriod;
404 // Handle the last schedule period
405 if (
406 schedulePeriod.startPeriod ===
407 chargingSchedule.chargingSchedulePeriod[
408 chargingSchedule.chargingSchedulePeriod.length - 1
409 ].startPeriod
410 ) {
411 const result = {
412 limit: lastButOneSchedule.limit,
413 matchingChargingProfile: chargingProfile,
414 };
415 logger.debug(debugLogMsg, result);
416 return result;
417 }
418 }
419 }
420 }
421 return null;
422 }
423
424 public static getDefaultVoltageOut(
425 currentType: CurrentType,
426 templateFile: string,
427 logPrefix: string
428 ): Voltage {
429 const errMsg = `Unknown ${currentType} currentOutType in template file ${templateFile}, cannot define default voltage out`;
430 let defaultVoltageOut: number;
431 switch (currentType) {
432 case CurrentType.AC:
433 defaultVoltageOut = Voltage.VOLTAGE_230;
434 break;
435 case CurrentType.DC:
436 defaultVoltageOut = Voltage.VOLTAGE_400;
437 break;
438 default:
439 logger.error(`${logPrefix} ${errMsg}`);
440 throw new BaseError(errMsg);
441 }
442 return defaultVoltageOut;
443 }
444
445 public static getSampledValueTemplate(
446 chargingStation: ChargingStation,
447 connectorId: number,
448 measurand: MeterValueMeasurand = MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER,
449 phase?: MeterValuePhase
450 ): SampledValueTemplate | undefined {
451 const onPhaseStr = phase ? `on phase ${phase} ` : '';
452 if (Constants.SUPPORTED_MEASURANDS.includes(measurand) === false) {
453 logger.warn(
454 `${chargingStation.logPrefix()} Trying to get unsupported MeterValues measurand '${measurand}' ${onPhaseStr}in template on connectorId ${connectorId}`
455 );
456 return;
457 }
458 if (
459 measurand !== MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER &&
460 !ChargingStationConfigurationUtils.getConfigurationKey(
461 chargingStation,
462 StandardParametersKey.MeterValuesSampledData
463 )?.value.includes(measurand)
464 ) {
465 logger.debug(
466 `${chargingStation.logPrefix()} Trying to get MeterValues measurand '${measurand}' ${onPhaseStr}in template on connectorId ${connectorId} not found in '${
467 StandardParametersKey.MeterValuesSampledData
468 }' OCPP parameter`
469 );
470 return;
471 }
472 const sampledValueTemplates: SampledValueTemplate[] =
473 chargingStation.getConnectorStatus(connectorId).MeterValues;
474 for (
475 let index = 0;
476 !Utils.isEmptyArray(sampledValueTemplates) && index < sampledValueTemplates.length;
477 index++
478 ) {
479 if (
480 Constants.SUPPORTED_MEASURANDS.includes(
481 sampledValueTemplates[index]?.measurand ??
482 MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER
483 ) === false
484 ) {
485 logger.warn(
486 `${chargingStation.logPrefix()} Unsupported MeterValues measurand '${measurand}' ${onPhaseStr}in template on connectorId ${connectorId}`
487 );
488 } else if (
489 phase &&
490 sampledValueTemplates[index]?.phase === phase &&
491 sampledValueTemplates[index]?.measurand === measurand &&
492 ChargingStationConfigurationUtils.getConfigurationKey(
493 chargingStation,
494 StandardParametersKey.MeterValuesSampledData
495 )?.value.includes(measurand) === true
496 ) {
497 return sampledValueTemplates[index];
498 } else if (
499 !phase &&
500 !sampledValueTemplates[index].phase &&
501 sampledValueTemplates[index]?.measurand === measurand &&
502 ChargingStationConfigurationUtils.getConfigurationKey(
503 chargingStation,
504 StandardParametersKey.MeterValuesSampledData
505 )?.value.includes(measurand) === true
506 ) {
507 return sampledValueTemplates[index];
508 } else if (
509 measurand === MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER &&
510 (!sampledValueTemplates[index].measurand ||
511 sampledValueTemplates[index].measurand === measurand)
512 ) {
513 return sampledValueTemplates[index];
514 }
515 }
516 if (measurand === MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER) {
517 const errorMsg = `Missing MeterValues for default measurand '${measurand}' in template on connectorId ${connectorId}`;
518 logger.error(`${chargingStation.logPrefix()} ${errorMsg}`);
519 throw new BaseError(errorMsg);
520 }
521 logger.debug(
522 `${chargingStation.logPrefix()} No MeterValues for measurand '${measurand}' ${onPhaseStr}in template on connectorId ${connectorId}`
523 );
524 }
525
526 public static getAuthorizationFile(stationInfo: ChargingStationInfo): string | undefined {
527 return (
528 stationInfo.authorizationFile &&
529 path.join(
530 path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../'),
531 'assets',
532 path.basename(stationInfo.authorizationFile)
533 )
534 );
535 }
536
537 public static isRequestCommandSupported(
538 command: RequestCommand,
539 chargingStation: ChargingStation
540 ): boolean {
541 const isRequestCommand = Object.values(RequestCommand).includes(command);
542 if (
543 isRequestCommand === true &&
544 !chargingStation.stationInfo?.commandsSupport?.outgoingCommands
545 ) {
546 return true;
547 } else if (
548 isRequestCommand === true &&
549 chargingStation.stationInfo?.commandsSupport?.outgoingCommands
550 ) {
551 return chargingStation.stationInfo?.commandsSupport?.outgoingCommands[command] ?? false;
552 }
553 logger.error(`${chargingStation.logPrefix()} Unknown outgoing OCPP command '${command}'`);
554 return false;
555 }
556
557 public static isIncomingRequestCommandSupported(
558 command: IncomingRequestCommand,
559 chargingStation: ChargingStation
560 ): boolean {
561 const isIncomingRequestCommand = Object.values(IncomingRequestCommand).includes(command);
562 if (
563 isIncomingRequestCommand === true &&
564 !chargingStation.stationInfo?.commandsSupport?.incomingCommands
565 ) {
566 return true;
567 } else if (
568 isIncomingRequestCommand === true &&
569 chargingStation.stationInfo?.commandsSupport?.incomingCommands
570 ) {
571 return chargingStation.stationInfo?.commandsSupport?.incomingCommands[command] ?? false;
572 }
573 logger.error(`${chargingStation.logPrefix()} Unknown incoming OCPP command '${command}'`);
574 return false;
575 }
576
577 private static getRandomSerialNumberSuffix(params?: {
578 randomBytesLength?: number;
579 upperCase?: boolean;
580 }): string {
581 const randomSerialNumberSuffix = crypto
582 .randomBytes(params?.randomBytesLength ?? 16)
583 .toString('hex');
584 if (params?.upperCase) {
585 return randomSerialNumberSuffix.toUpperCase();
586 }
587 return randomSerialNumberSuffix;
588 }
589 }