@zetra/citrineos-ocpprouter 1.8.3-fork.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +4 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/module/DataApi.d.ts +88 -0
- package/dist/module/DataApi.js +248 -0
- package/dist/module/DataApi.js.map +1 -0
- package/dist/module/interface.d.ts +6 -0
- package/dist/module/interface.js +5 -0
- package/dist/module/interface.js.map +1 -0
- package/dist/module/router.d.ts +140 -0
- package/dist/module/router.js +631 -0
- package/dist/module/router.js.map +1 -0
- package/dist/module/webhook.dispatcher.d.ts +56 -0
- package/dist/module/webhook.dispatcher.js +280 -0
- package/dist/module/webhook.dispatcher.js.map +1 -0
- package/package.json +27 -0
|
@@ -0,0 +1,631 @@
|
|
|
1
|
+
import { AbstractMessageRouter, AbstractModule, BOOT_STATUS, CacheNamespace, CircuitBreaker, createIdentifier, ErrorCode, EventGroup, getStationIdFromIdentifier, getTenantIdFromIdentifier, mapToCallAction, MessageOrigin, MessageState, MessageTypeId, NO_ACTION, OCPP2_0_1, OCPP2_0_1_CallAction, OcppError, OCPPValidator, OCPPVersion, RequestBuilder, RetryMessageError, } from '@citrineos/base';
|
|
2
|
+
import { sequelize } from '@citrineos/data';
|
|
3
|
+
import { OidcTokenProvider } from '@citrineos/util';
|
|
4
|
+
import { Logger } from 'tslog';
|
|
5
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
6
|
+
import { WebhookDispatcher } from './webhook.dispatcher.js';
|
|
7
|
+
/**
|
|
8
|
+
* Implementation of the ocpp router
|
|
9
|
+
*/
|
|
10
|
+
export class MessageRouterImpl extends AbstractMessageRouter {
|
|
11
|
+
/**
|
|
12
|
+
* Fields
|
|
13
|
+
*/
|
|
14
|
+
_webhookDispatcher;
|
|
15
|
+
_cache;
|
|
16
|
+
_sender;
|
|
17
|
+
_handler;
|
|
18
|
+
_networkHook;
|
|
19
|
+
_locationRepository;
|
|
20
|
+
_circuitBreaker;
|
|
21
|
+
_reconnectInterval;
|
|
22
|
+
static DEFAULT_MAX_RECONNECT_DELAY = 30; // seconds
|
|
23
|
+
_maxReconnectDelay;
|
|
24
|
+
_failingReconnectDelay = 1; // start with 1 second
|
|
25
|
+
_oidcTokenProvider;
|
|
26
|
+
/**
|
|
27
|
+
* Constructor for the class.
|
|
28
|
+
*
|
|
29
|
+
* @param {BootstrapConfig & SystemConfig} config - the system configuration
|
|
30
|
+
* @param {ICache} cache - the cache object
|
|
31
|
+
* @param {IMessageSender} [sender] - the message sender
|
|
32
|
+
* @param {IMessageHandler} [handler] - the message handler
|
|
33
|
+
* @param {WebhookDispatcher} [dispatcher] - the webhook dispatcher
|
|
34
|
+
* @param {Function} networkHook - the network hook needed to send messages to chargers
|
|
35
|
+
* @param {ILocationRepository} [locationRepository] - An optional parameter of type {@link ILocationRepository} which
|
|
36
|
+
* represents a repository for accessing and manipulating variable data.
|
|
37
|
+
* If no `locationRepository` is provided, a default {@link locationRepository} instance is created and used.
|
|
38
|
+
* @param {Logger<ILogObj>} [logger] - the logger object (optional)
|
|
39
|
+
* @param {OCPPValidator} [ocppValidator] - the OCPPValidator instance, for message validation (optional)
|
|
40
|
+
* @param {CircuitBreakerOptions} [circuitBreakerOptions] - options to configure the circuit breaker
|
|
41
|
+
*/
|
|
42
|
+
constructor(config, cache, sender, handler, dispatcher, networkHook, logger, ocppValidator, locationRepository, circuitBreakerOptions) {
|
|
43
|
+
super(config, cache, handler, sender, networkHook, logger, ocppValidator);
|
|
44
|
+
this._cache = cache;
|
|
45
|
+
this._sender = sender;
|
|
46
|
+
this._handler = handler;
|
|
47
|
+
this._webhookDispatcher = dispatcher;
|
|
48
|
+
this._networkHook = networkHook;
|
|
49
|
+
this._locationRepository =
|
|
50
|
+
locationRepository || new sequelize.SequelizeLocationRepository(config, logger);
|
|
51
|
+
this._handler.initConnection().catch((err) => {
|
|
52
|
+
this._logger.error('initConnection failed', err);
|
|
53
|
+
});
|
|
54
|
+
if (this._config.oidcClient) {
|
|
55
|
+
this._oidcTokenProvider = new OidcTokenProvider(this._config.oidcClient, this._logger);
|
|
56
|
+
}
|
|
57
|
+
if (circuitBreakerOptions) {
|
|
58
|
+
this._circuitBreaker = new CircuitBreaker({
|
|
59
|
+
...circuitBreakerOptions,
|
|
60
|
+
onStateChange: this.handleCircuitBreakerStateChange.bind(this),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
this._circuitBreaker = new CircuitBreaker({
|
|
65
|
+
onStateChange: this.handleCircuitBreakerStateChange.bind(this),
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
this._maxReconnectDelay =
|
|
69
|
+
config.maxReconnectDelay ?? MessageRouterImpl.DEFAULT_MAX_RECONNECT_DELAY;
|
|
70
|
+
}
|
|
71
|
+
async doesChargingStationExistByStationId(tenantId, stationId) {
|
|
72
|
+
return await this._locationRepository.doesChargingStationExistByStationId(tenantId, stationId);
|
|
73
|
+
}
|
|
74
|
+
// TODO: Below method should lock these tables so that a rapid connect-disconnect cannot result in race condition.
|
|
75
|
+
async registerConnection(tenantId, stationId, protocol) {
|
|
76
|
+
const dispatcherRegistration = this._webhookDispatcher.register(tenantId, stationId);
|
|
77
|
+
const connectionIdentifier = createIdentifier(tenantId, stationId);
|
|
78
|
+
const requestSubscription = this._handler.subscribe(connectionIdentifier, undefined, {
|
|
79
|
+
tenantId: tenantId.toString(),
|
|
80
|
+
stationId,
|
|
81
|
+
state: MessageState.Request.toString(),
|
|
82
|
+
origin: MessageOrigin.ChargingStationManagementSystem.toString(),
|
|
83
|
+
});
|
|
84
|
+
const responseSubscription = this._handler.subscribe(connectionIdentifier, undefined, {
|
|
85
|
+
tenantId: tenantId.toString(),
|
|
86
|
+
stationId,
|
|
87
|
+
state: MessageState.Response.toString(),
|
|
88
|
+
origin: MessageOrigin.ChargingStationManagementSystem.toString(),
|
|
89
|
+
});
|
|
90
|
+
const onlineCharger = this._locationRepository.setChargingStationIsOnlineAndOCPPVersion(tenantId, stationId, true, protocol);
|
|
91
|
+
return Promise.all([
|
|
92
|
+
dispatcherRegistration,
|
|
93
|
+
requestSubscription,
|
|
94
|
+
responseSubscription,
|
|
95
|
+
onlineCharger,
|
|
96
|
+
])
|
|
97
|
+
.then((resolvedArray) => resolvedArray[1] && resolvedArray[2])
|
|
98
|
+
.catch((error) => {
|
|
99
|
+
this._logger.error(`Error registering connection for ${connectionIdentifier}: ${error}`);
|
|
100
|
+
return false;
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
async deregisterConnection(tenantId, stationId) {
|
|
104
|
+
this._webhookDispatcher.deregister(tenantId, stationId).catch((err) => {
|
|
105
|
+
this._logger.error('_webhookDispatcher deregister failed', err);
|
|
106
|
+
});
|
|
107
|
+
let protocol = null;
|
|
108
|
+
try {
|
|
109
|
+
const chargingStation = await this._locationRepository.readChargingStationByStationId(tenantId, stationId);
|
|
110
|
+
if (chargingStation?.protocol) {
|
|
111
|
+
protocol = chargingStation.protocol;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
catch (e) {
|
|
115
|
+
this._logger?.warn?.(`Could not read charging station ${stationId} of tenant ${tenantId} to determine protocol: ${e.message}`);
|
|
116
|
+
}
|
|
117
|
+
await this._locationRepository.setChargingStationIsOnlineAndOCPPVersion(tenantId, stationId, false, protocol);
|
|
118
|
+
const connectionIdentifier = createIdentifier(tenantId, stationId);
|
|
119
|
+
// TODO: ensure that all queue implementations in 02_Util only unsubscribe 1 queue per call
|
|
120
|
+
// ...which will require refactoring this method to unsubscribe request and response queues separately
|
|
121
|
+
return await this._handler.unsubscribe(connectionIdentifier);
|
|
122
|
+
}
|
|
123
|
+
async onMessage(identifier, message, timestamp, protocol) {
|
|
124
|
+
const tenantId = getTenantIdFromIdentifier(identifier);
|
|
125
|
+
const stationId = getStationIdFromIdentifier(identifier);
|
|
126
|
+
let success = true;
|
|
127
|
+
let rpcMessage;
|
|
128
|
+
let messageTypeId = undefined;
|
|
129
|
+
let messageId = '-1'; // OCPP 2.0.1 part 4, section 4.2.3, When also the MessageId cannot be read, the CALLERROR SHALL contain "-1" as MessageId.
|
|
130
|
+
try {
|
|
131
|
+
try {
|
|
132
|
+
rpcMessage = JSON.parse(message);
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
this._logger.error(`Error parsing ${message} from websocket, unable to reply: ${JSON.stringify(error)}`);
|
|
136
|
+
throw error;
|
|
137
|
+
}
|
|
138
|
+
messageTypeId = rpcMessage[0];
|
|
139
|
+
messageId = rpcMessage[1];
|
|
140
|
+
switch (messageTypeId) {
|
|
141
|
+
case MessageTypeId.Call: {
|
|
142
|
+
await this._onCall(identifier, rpcMessage, timestamp, protocol);
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
case MessageTypeId.CallResult: {
|
|
146
|
+
await this._onCallResult(identifier, rpcMessage, timestamp, protocol);
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
case MessageTypeId.CallError: {
|
|
150
|
+
await this._onCallError(identifier, rpcMessage, timestamp, protocol);
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
default: {
|
|
154
|
+
let errorCode;
|
|
155
|
+
switch (protocol) {
|
|
156
|
+
case 'ocpp1.6': {
|
|
157
|
+
errorCode = ErrorCode.FormationViolation;
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
case 'ocpp2.0.1': {
|
|
161
|
+
errorCode = ErrorCode.FormatViolation;
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
default: {
|
|
165
|
+
throw new Error('Unknown protocol: ' + protocol);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
throw new OcppError(messageId, errorCode, 'Unknown message type id: ' + messageTypeId, {});
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
catch (error) {
|
|
173
|
+
success = false; // ensure we return false in case of an error
|
|
174
|
+
this._logger.error('Error processing message:', message, error);
|
|
175
|
+
const action = this.getActionFromIncompletelyParsedRpcMessage(rpcMessage, messageTypeId);
|
|
176
|
+
if (messageTypeId != MessageTypeId.CallResult && messageTypeId != MessageTypeId.CallError) {
|
|
177
|
+
const callError = error instanceof OcppError
|
|
178
|
+
? error.asCallError()
|
|
179
|
+
: [
|
|
180
|
+
MessageTypeId.CallError,
|
|
181
|
+
messageId,
|
|
182
|
+
ErrorCode.InternalError,
|
|
183
|
+
'Unable to process message',
|
|
184
|
+
{ error: error },
|
|
185
|
+
];
|
|
186
|
+
const rawMessage = JSON.stringify(callError);
|
|
187
|
+
await this._sendMessage(identifier, protocol, action, MessageState.Response, rawMessage, callError);
|
|
188
|
+
}
|
|
189
|
+
let state = MessageState.Unknown;
|
|
190
|
+
switch (messageTypeId) {
|
|
191
|
+
case MessageTypeId.Call:
|
|
192
|
+
state = MessageState.Request;
|
|
193
|
+
break;
|
|
194
|
+
case MessageTypeId.CallResult:
|
|
195
|
+
case MessageTypeId.CallError:
|
|
196
|
+
state = MessageState.Response;
|
|
197
|
+
break;
|
|
198
|
+
default: // keep as Unknown
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
await this._webhookDispatcher.dispatchMessageReceivedUnparsed(tenantId, stationId, message, timestamp.toISOString(), protocol, action, state);
|
|
202
|
+
}
|
|
203
|
+
// Update latestOcppMessageTimestamp for any incoming OCPP message (non-blocking, single query)
|
|
204
|
+
this._locationRepository
|
|
205
|
+
.updateChargingStationTimestamp(tenantId, stationId, timestamp.toISOString())
|
|
206
|
+
.catch((error) => {
|
|
207
|
+
this._logger.error(`Failed to update latestOcppMessageTimestamp for ${identifier}:`, error);
|
|
208
|
+
});
|
|
209
|
+
return success;
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Sends a Call message to a charging station with given identifier.
|
|
213
|
+
*
|
|
214
|
+
* @param {string} stationId - The identifier of the station.
|
|
215
|
+
* @param {number} tenantId - The identifier of the tenant.
|
|
216
|
+
* @param {OCPPVersionType} protocol The OCPP protocol version of the message.
|
|
217
|
+
* @param {CallAction} action - The action to be called.
|
|
218
|
+
* @param {OcppRequest} payload - The payload of the call.
|
|
219
|
+
* @param {string} correlationId - The correlation ID of the message.
|
|
220
|
+
* @param {MessageOrigin} _origin - The origin of the message.
|
|
221
|
+
* @return {Promise<boolean>} A promise that resolves to a boolean indicating if the call was sent successfully.
|
|
222
|
+
*/
|
|
223
|
+
async sendCall(stationId, tenantId, protocol, action, payload, correlationId = uuidv4(), _origin) {
|
|
224
|
+
const identifier = createIdentifier(tenantId, stationId);
|
|
225
|
+
const message = [MessageTypeId.Call, correlationId, action, payload];
|
|
226
|
+
if (await this._sendCallIsAllowed(identifier, protocol, message)) {
|
|
227
|
+
if (await this._cache.setIfNotExist(identifier, `${action}:${correlationId}`, CacheNamespace.Transactions, this._config.maxCallLengthSeconds)) {
|
|
228
|
+
const rawMessage = JSON.stringify(message);
|
|
229
|
+
const success = await this._sendMessage(identifier, protocol, action, MessageState.Request, rawMessage, message);
|
|
230
|
+
return { success };
|
|
231
|
+
}
|
|
232
|
+
else {
|
|
233
|
+
this._logger.info('Call already in progress, throwing retry exception', identifier, message);
|
|
234
|
+
throw new RetryMessageError('Call already in progress');
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
else {
|
|
238
|
+
this._logger.info('RegistrationStatus Rejected, unable to send', identifier, message);
|
|
239
|
+
return { success: false };
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Sends the CallResult to a charging station with given identifier.
|
|
244
|
+
*
|
|
245
|
+
* @param {string} correlationId - The correlation ID of the message.
|
|
246
|
+
* @param {string} stationId - The identifier of the charging station.
|
|
247
|
+
* @param {number} tenantId - The identifier of the tenant.
|
|
248
|
+
* @param {OCPPVersionType} protocol The OCPP protocol version of the message.
|
|
249
|
+
* @param {CallAction} action - The action to be called.
|
|
250
|
+
* @param {OcppRequest} payload - The payload of the call.
|
|
251
|
+
* @param {MessageOrigin} _origin - The origin of the message.
|
|
252
|
+
* @return {Promise<boolean>} A promise that resolves to true if the call result was sent successfully, or false otherwise.
|
|
253
|
+
*/
|
|
254
|
+
async sendCallResult(correlationId, stationId, tenantId, protocol, action, payload, _origin) {
|
|
255
|
+
const message = [MessageTypeId.CallResult, correlationId, payload];
|
|
256
|
+
const identifier = createIdentifier(tenantId, stationId);
|
|
257
|
+
const cachedActionMessageId = await this._cache.get(identifier, CacheNamespace.Transactions);
|
|
258
|
+
if (!cachedActionMessageId) {
|
|
259
|
+
this._logger.error('Failed to send callResult due to missing message id', identifier, message);
|
|
260
|
+
return { success: false };
|
|
261
|
+
}
|
|
262
|
+
const [cachedAction, cachedMessageId] = cachedActionMessageId?.split(/:(.*)/) ?? []; // Returns all characters after first ':' in case ':' is used in messageId
|
|
263
|
+
if (cachedAction === action && cachedMessageId === correlationId) {
|
|
264
|
+
const rawMessage = JSON.stringify(message);
|
|
265
|
+
const success = await Promise.all([
|
|
266
|
+
this._sendMessage(identifier, protocol, cachedAction, MessageState.Response, rawMessage, message),
|
|
267
|
+
this._cache.remove(identifier, CacheNamespace.Transactions),
|
|
268
|
+
]).then((successes) => successes.every(Boolean));
|
|
269
|
+
return { success };
|
|
270
|
+
}
|
|
271
|
+
else {
|
|
272
|
+
this._logger.error('Failed to send callResult due to mismatch in message id', identifier, cachedActionMessageId, message);
|
|
273
|
+
return { success: false };
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Sends a CallError message to a charging station with given identifier.
|
|
278
|
+
*
|
|
279
|
+
* @param {string} correlationId - The correlation ID of the message.
|
|
280
|
+
* @param {string} stationId - The identifier of the charging station.
|
|
281
|
+
* @param {number} tenantId - The identifier of the tenant.
|
|
282
|
+
* @param {OCPPVersionType} protocol The OCPP protocol version of the message.
|
|
283
|
+
* @param {CallAction} _action - The action to be called.
|
|
284
|
+
* @param {OcppError} error - The error of the call.
|
|
285
|
+
* @param {MessageOrigin} _origin - The origin of the message.
|
|
286
|
+
* @return {Promise<boolean>} - A promise that resolves to true if the message was sent successfully.
|
|
287
|
+
*/
|
|
288
|
+
async sendCallError(correlationId, stationId, tenantId, protocol, action, error, _origin) {
|
|
289
|
+
const message = error.asCallError();
|
|
290
|
+
const identifier = createIdentifier(tenantId, stationId);
|
|
291
|
+
const cachedActionMessageId = await this._cache.get(identifier, CacheNamespace.Transactions);
|
|
292
|
+
if (!cachedActionMessageId) {
|
|
293
|
+
this._logger.error('Failed to send callError due to missing message id', identifier, message);
|
|
294
|
+
return { success: false };
|
|
295
|
+
}
|
|
296
|
+
const [cachedAction, cachedMessageId] = cachedActionMessageId?.split(/:(.*)/) ?? []; // Returns all characters after first ':' in case ':' is used in messageId
|
|
297
|
+
if (cachedMessageId === correlationId && cachedAction === action) {
|
|
298
|
+
const rawMessage = JSON.stringify(message);
|
|
299
|
+
const success = await Promise.all([
|
|
300
|
+
this._sendMessage(identifier, protocol, cachedAction, MessageState.Response, rawMessage, message),
|
|
301
|
+
this._cache.remove(identifier, CacheNamespace.Transactions),
|
|
302
|
+
]).then((successes) => successes.every(Boolean));
|
|
303
|
+
return { success };
|
|
304
|
+
}
|
|
305
|
+
else {
|
|
306
|
+
this._logger.error('Failed to send callError due to mismatch in message id or action', identifier, cachedActionMessageId, cachedAction, message);
|
|
307
|
+
return { success: false };
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
async shutdown() {
|
|
311
|
+
await this._sender.shutdown();
|
|
312
|
+
await this._handler.shutdown();
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Private Methods
|
|
316
|
+
*/
|
|
317
|
+
/**
|
|
318
|
+
* Handles an incoming Call message from a client connection.
|
|
319
|
+
*
|
|
320
|
+
* @param {string} identifier - The client identifier.
|
|
321
|
+
* @param {Call} message - The Call message received.
|
|
322
|
+
* @param {Date} timestamp Time at which the message was received from the charger.
|
|
323
|
+
* @param {string} protocol The OCPP protocol version of the message
|
|
324
|
+
* @return {void}
|
|
325
|
+
*/
|
|
326
|
+
async _onCall(identifier, message, timestamp, protocol) {
|
|
327
|
+
const messageId = message[1];
|
|
328
|
+
const tenantId = getTenantIdFromIdentifier(identifier);
|
|
329
|
+
const stationId = getStationIdFromIdentifier(identifier);
|
|
330
|
+
let action = message[2];
|
|
331
|
+
action = mapToCallAction(protocol, action);
|
|
332
|
+
const isAllowed = await this._onCallIsAllowed(action, identifier);
|
|
333
|
+
if (!isAllowed) {
|
|
334
|
+
throw new OcppError(messageId, ErrorCode.SecurityError, `Action ${action} not allowed`);
|
|
335
|
+
}
|
|
336
|
+
// Run schema validation for incoming Call message
|
|
337
|
+
const { isValid, errors } = this._validateCall(identifier, message, protocol);
|
|
338
|
+
if (!isValid || errors) {
|
|
339
|
+
throw new OcppError(messageId, ErrorCode.FormatViolation, 'Invalid message format', {
|
|
340
|
+
errors: errors,
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
// Ensure only one call is processed at a time
|
|
344
|
+
const callOngoing = this._cache.onChange(identifier, this.config.maxCallLengthSeconds, CacheNamespace.Transactions);
|
|
345
|
+
let successfullySet = await this._cache.setIfNotExist(identifier, `${action}:${messageId}`, CacheNamespace.Transactions, this._config.maxCallLengthSeconds);
|
|
346
|
+
if (!successfullySet) {
|
|
347
|
+
this._logger.debug('Ongoing Call already in progress, waiting for ongoing call before handling', identifier, message);
|
|
348
|
+
await callOngoing; // Wait for ongoing call to finish
|
|
349
|
+
this._logger.debug('Ongoing Call finished, proceeding with call', identifier, message);
|
|
350
|
+
successfullySet = await this._cache.setIfNotExist(identifier, `${action}:${messageId}`, CacheNamespace.Transactions, this._config.maxCallLengthSeconds);
|
|
351
|
+
if (!successfullySet) {
|
|
352
|
+
throw new OcppError(messageId, ErrorCode.RpcFrameworkError, 'Call already in progress', {});
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
try {
|
|
356
|
+
// Route call
|
|
357
|
+
const confirmation = await this._routeCall(identifier, message, timestamp, protocol);
|
|
358
|
+
if (!confirmation.success) {
|
|
359
|
+
throw new OcppError(messageId, ErrorCode.InternalError, 'Call failed', {
|
|
360
|
+
details: confirmation.payload,
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
catch (error) {
|
|
365
|
+
const callError = error instanceof OcppError
|
|
366
|
+
? error
|
|
367
|
+
: new OcppError(messageId, ErrorCode.InternalError, 'Call failed', {
|
|
368
|
+
details: error,
|
|
369
|
+
});
|
|
370
|
+
this.sendCallError(messageId, stationId, tenantId, protocol, action, callError)
|
|
371
|
+
.catch((err) => {
|
|
372
|
+
this._logger.error('sendCallError failed', err);
|
|
373
|
+
})
|
|
374
|
+
.finally(() => {
|
|
375
|
+
this._cache.remove(identifier, CacheNamespace.Transactions).catch((err) => {
|
|
376
|
+
this._logger.error('cache remove failed', err);
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Handles a CallResult made by the client.
|
|
383
|
+
*
|
|
384
|
+
* @param {string} identifier - The client identifier that made the call.
|
|
385
|
+
* @param {CallResult} message - The OCPP CallResult message.
|
|
386
|
+
* @param {Date} timestamp Time at which the message was received from the charger.
|
|
387
|
+
* @param {OCPPVersionType} protocol The OCPP protocol version of the message
|
|
388
|
+
*/
|
|
389
|
+
async _onCallResult(identifier, message, timestamp, protocol) {
|
|
390
|
+
const messageId = message[1];
|
|
391
|
+
const payload = message[2];
|
|
392
|
+
this._logger.debug('Process CallResult', identifier, messageId, payload);
|
|
393
|
+
const cachedActionMessageId = await this._cache.get(identifier, CacheNamespace.Transactions);
|
|
394
|
+
await this._cache.remove(identifier, CacheNamespace.Transactions).catch((err) => {
|
|
395
|
+
this._logger.error('_onCallResult cache remove failed', err);
|
|
396
|
+
});
|
|
397
|
+
if (!cachedActionMessageId) {
|
|
398
|
+
throw new OcppError(messageId, ErrorCode.InternalError, 'MessageId not found, call may have timed out', { maxCallLengthSeconds: this._config.maxCallLengthSeconds });
|
|
399
|
+
}
|
|
400
|
+
const [action, cachedMessageId] = cachedActionMessageId.split(/:(.*)/); // Returns all characters after first ':' in case ':' is used in messageId
|
|
401
|
+
if (messageId !== cachedMessageId) {
|
|
402
|
+
throw new OcppError(messageId, ErrorCode.InternalError, "MessageId doesn't match", {
|
|
403
|
+
expectedMessageId: cachedMessageId,
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
// Run schema validation for incoming CallResult message
|
|
407
|
+
const { isValid, errors } = this._validateCallResult(identifier, mapToCallAction(protocol, action), message, protocol);
|
|
408
|
+
if (!isValid || errors) {
|
|
409
|
+
throw new OcppError(messageId, ErrorCode.FormatViolation, 'Invalid message format', {
|
|
410
|
+
errors: errors,
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
// Route call result
|
|
414
|
+
const confirmation = await this._routeCallResult(identifier, message, mapToCallAction(protocol, action), timestamp, protocol);
|
|
415
|
+
if (!confirmation.success) {
|
|
416
|
+
throw new OcppError(messageId, ErrorCode.InternalError, 'CallResult failed', {
|
|
417
|
+
details: confirmation.payload,
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* Handles the CallError that may have occured during a Call exchange.
|
|
423
|
+
*
|
|
424
|
+
* @param {string} identifier - The client identifier.
|
|
425
|
+
* @param {CallError} message - The error message.
|
|
426
|
+
* @param {Date} timestamp Time at which the message was received from the charger.
|
|
427
|
+
* @param {OCPPVersionType} protocol The OCPP protocol version of the message
|
|
428
|
+
*/
|
|
429
|
+
async _onCallError(identifier, message, timestamp, protocol) {
|
|
430
|
+
const messageId = message[1];
|
|
431
|
+
this._logger.debug('Process CallError', identifier, message);
|
|
432
|
+
const cachedActionMessageId = await this._cache.get(identifier, CacheNamespace.Transactions);
|
|
433
|
+
// Always remove pending call transaction
|
|
434
|
+
await this._cache.remove(identifier, CacheNamespace.Transactions).catch((err) => {
|
|
435
|
+
this._logger.error('_onCallError cache remove failed', err);
|
|
436
|
+
});
|
|
437
|
+
if (!cachedActionMessageId) {
|
|
438
|
+
throw new OcppError(messageId, ErrorCode.InternalError, 'MessageId not found, call may have timed out', { maxCallLengthSeconds: this._config.maxCallLengthSeconds });
|
|
439
|
+
}
|
|
440
|
+
const [action, cachedMessageId] = cachedActionMessageId.split(/:(.*)/); // Returns all characters after first ':' in case ':' is used in messageId
|
|
441
|
+
if (messageId !== cachedMessageId) {
|
|
442
|
+
throw new OcppError(messageId, ErrorCode.InternalError, "MessageId doesn't match", {
|
|
443
|
+
expectedMessageId: cachedMessageId,
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
const confirmation = await this._routeCallError(identifier, message, mapToCallAction(protocol, action), timestamp, protocol);
|
|
447
|
+
if (!confirmation.success) {
|
|
448
|
+
// Below code commented out with debug log because currently there is no error routing implemented, so this block will always be reached for CallErrors.
|
|
449
|
+
// Once error routing is implemented, this block can be uncommented to throw an error if the CallError routing fails.
|
|
450
|
+
this._logger.debug('Unable to route call error: ', confirmation);
|
|
451
|
+
// throw new OcppError(messageId, ErrorCode.InternalError, 'CallError failed', {
|
|
452
|
+
// details: confirmation.payload,
|
|
453
|
+
// });
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* Determine if the given action for identifier is allowed.
|
|
458
|
+
*
|
|
459
|
+
* @param {CallAction} action - The action to be checked.
|
|
460
|
+
* @param {string} identifier - The identifier to be checked.
|
|
461
|
+
* @return {Promise<boolean>} A promise that resolves to a boolean indicating if the action and identifier are allowed.
|
|
462
|
+
*/
|
|
463
|
+
_onCallIsAllowed(action, identifier) {
|
|
464
|
+
return this._cache.exists(action, identifier).then((blacklisted) => !blacklisted);
|
|
465
|
+
}
|
|
466
|
+
async _sendMessage(identifier, protocol, action, state, rawMessage, rpcMessage) {
|
|
467
|
+
try {
|
|
468
|
+
await this._networkHook(identifier, rawMessage); // Throws an error if the message is not sent, or returns void
|
|
469
|
+
}
|
|
470
|
+
catch (error) {
|
|
471
|
+
this._logger.error('Failed to send message:', identifier, rawMessage, error);
|
|
472
|
+
// Don't dispatch if the message was not sent
|
|
473
|
+
return false;
|
|
474
|
+
}
|
|
475
|
+
this._webhookDispatcher
|
|
476
|
+
.dispatchMessageSent(identifier, action, state, new Date().toISOString(), protocol, rpcMessage)
|
|
477
|
+
.catch((err) => {
|
|
478
|
+
this._logger.error('dispatchMessageSent failed', err);
|
|
479
|
+
});
|
|
480
|
+
return true;
|
|
481
|
+
}
|
|
482
|
+
async _sendCallIsAllowed(identifier, protocol, message) {
|
|
483
|
+
const status = await this._cache.get(BOOT_STATUS, identifier);
|
|
484
|
+
if (status === OCPP2_0_1.RegistrationStatusEnumType.Rejected &&
|
|
485
|
+
// TriggerMessage<BootNotification> is the only message allowed to be sent during Rejected BootStatus B03.FR.08
|
|
486
|
+
!(mapToCallAction(protocol, message[2]) === OCPP2_0_1_CallAction.TriggerMessage &&
|
|
487
|
+
message[3].requestedMessage ==
|
|
488
|
+
OCPP2_0_1.MessageTriggerEnumType.BootNotification)) {
|
|
489
|
+
return false;
|
|
490
|
+
}
|
|
491
|
+
return true;
|
|
492
|
+
}
|
|
493
|
+
async _routeCall(connectionIdentifier, message, timestamp, protocol) {
|
|
494
|
+
const messageId = message[1];
|
|
495
|
+
const action = mapToCallAction(protocol, message[2]);
|
|
496
|
+
const payload = message[3];
|
|
497
|
+
const tenantId = getTenantIdFromIdentifier(connectionIdentifier);
|
|
498
|
+
const stationId = getStationIdFromIdentifier(connectionIdentifier);
|
|
499
|
+
const _message = RequestBuilder.buildCall(stationId, messageId, tenantId, action, payload, EventGroup.Router, MessageOrigin.ChargingStation, protocol, timestamp);
|
|
500
|
+
return this.emitMessage(_message, message);
|
|
501
|
+
}
|
|
502
|
+
async _routeCallResult(connectionIdentifier, message, action, timestamp, protocol) {
|
|
503
|
+
const messageId = message[1];
|
|
504
|
+
const payload = message[2];
|
|
505
|
+
const tenantId = getTenantIdFromIdentifier(connectionIdentifier);
|
|
506
|
+
const stationId = getStationIdFromIdentifier(connectionIdentifier);
|
|
507
|
+
const _message = RequestBuilder.buildCallResult(stationId, messageId, tenantId, action, payload, EventGroup.Router, MessageOrigin.ChargingStation, protocol, timestamp);
|
|
508
|
+
return this.emitMessage(_message, message);
|
|
509
|
+
}
|
|
510
|
+
async _routeCallError(connectionIdentifier, message, action, timestamp, protocol) {
|
|
511
|
+
const messageId = message[1];
|
|
512
|
+
const payload = new OcppError(messageId, message[2], message[3], message[4]);
|
|
513
|
+
const tenantId = getTenantIdFromIdentifier(connectionIdentifier);
|
|
514
|
+
const stationId = getStationIdFromIdentifier(connectionIdentifier);
|
|
515
|
+
const _message = RequestBuilder.buildCallError(stationId, messageId, tenantId, action, payload, EventGroup.Router, MessageOrigin.ChargingStation, protocol, timestamp);
|
|
516
|
+
// Fulfill callback for api, if needed
|
|
517
|
+
this._handleMessageApiCallback(_message).catch((err) => {
|
|
518
|
+
this._logger.error('_handleMessageApiCallback failed', err);
|
|
519
|
+
});
|
|
520
|
+
return this.emitMessage(_message, message);
|
|
521
|
+
}
|
|
522
|
+
async emitMessage(message, rpcMessage) {
|
|
523
|
+
let confirmation;
|
|
524
|
+
if (message.payload instanceof OcppError) {
|
|
525
|
+
// No error routing currently done
|
|
526
|
+
this._logger.warn('OCPP Error routing not implemented');
|
|
527
|
+
confirmation = { success: false };
|
|
528
|
+
}
|
|
529
|
+
else {
|
|
530
|
+
confirmation = await this._sender.send(message);
|
|
531
|
+
}
|
|
532
|
+
await this._webhookDispatcher.dispatchMessageReceived(message.context.tenantId, message.context.stationId, message.context.timestamp, message.protocol, message.action, message.state, rpcMessage);
|
|
533
|
+
return confirmation;
|
|
534
|
+
}
|
|
535
|
+
async _handleMessageApiCallback(message) {
|
|
536
|
+
const url = await this._cache.get(message.context.correlationId, AbstractModule.CALLBACK_URL_CACHE_PREFIX + message.context.stationId);
|
|
537
|
+
if (url) {
|
|
538
|
+
const headers = {
|
|
539
|
+
'Content-Type': 'application/json',
|
|
540
|
+
};
|
|
541
|
+
if (this._oidcTokenProvider) {
|
|
542
|
+
try {
|
|
543
|
+
const token = await this._oidcTokenProvider.getToken();
|
|
544
|
+
headers['Authorization'] = `Bearer ${token}`;
|
|
545
|
+
}
|
|
546
|
+
catch (error) {
|
|
547
|
+
this._logger.error('Failed to get OIDC token for callback:', error);
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
await fetch(url, {
|
|
552
|
+
method: 'POST',
|
|
553
|
+
headers,
|
|
554
|
+
body: JSON.stringify(message.payload),
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
handleCircuitBreakerStateChange(state, reason) {
|
|
559
|
+
switch (state) {
|
|
560
|
+
case 'CLOSED':
|
|
561
|
+
this._logger.warn('Circuit breaker closed', reason);
|
|
562
|
+
this._failingReconnectDelay = 1; // reset backoff
|
|
563
|
+
this.onCircuitBreakerClosed(reason);
|
|
564
|
+
break;
|
|
565
|
+
case 'OPEN':
|
|
566
|
+
this._logger.info('Circuit breaker opened', reason);
|
|
567
|
+
this._failingReconnectDelay = 1; // reset backoff
|
|
568
|
+
this.onCircuitBreakerOpen();
|
|
569
|
+
break;
|
|
570
|
+
case 'FAILING':
|
|
571
|
+
this._logger.warn('Circuit breaker failing', reason);
|
|
572
|
+
this._attemptExponentialReconnect();
|
|
573
|
+
break;
|
|
574
|
+
default:
|
|
575
|
+
this._logger.error('Unknown circuit breaker state', state, reason);
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
_attemptExponentialReconnect() {
|
|
580
|
+
if (this._failingReconnectDelay > this._maxReconnectDelay) {
|
|
581
|
+
this._logger.warn('Max reconnect delay reached, moving to CLOSED state.');
|
|
582
|
+
this._circuitBreaker.close('Max reconnect delay reached');
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
this._logger.info(`Attempting reconnect in ${this._failingReconnectDelay} seconds (exponential backoff)...`);
|
|
586
|
+
setTimeout(() => {
|
|
587
|
+
this.onBrokerReconnect();
|
|
588
|
+
this._failingReconnectDelay = Math.min(this._failingReconnectDelay * 2, this._maxReconnectDelay);
|
|
589
|
+
}, this._failingReconnectDelay * 1000);
|
|
590
|
+
}
|
|
591
|
+
onCircuitBreakerClosed(reason) {
|
|
592
|
+
this._logger.warn('Circuit breaker CLOSED. Will attempt to reconnect every', this._maxReconnectDelay, 'seconds.', reason);
|
|
593
|
+
if (this._reconnectInterval) {
|
|
594
|
+
this._logger.info('A reconnect interval was already running. Clearing it before starting a new one.');
|
|
595
|
+
clearInterval(this._reconnectInterval);
|
|
596
|
+
}
|
|
597
|
+
this._reconnectInterval = setInterval(() => {
|
|
598
|
+
this._logger.info('Attempting broker reconnection due to circuit breaker CLOSED...');
|
|
599
|
+
this.onBrokerReconnect();
|
|
600
|
+
}, this._maxReconnectDelay * 1000);
|
|
601
|
+
}
|
|
602
|
+
onCircuitBreakerOpen() {
|
|
603
|
+
this._logger.info('Circuit breaker OPEN. Stopping reconnection attempts.');
|
|
604
|
+
if (this._reconnectInterval) {
|
|
605
|
+
this._logger.info('Clearing reconnect interval as circuit breaker is now OPEN.');
|
|
606
|
+
clearInterval(this._reconnectInterval);
|
|
607
|
+
this._reconnectInterval = undefined;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
onBrokerDisconnect(reason) {
|
|
611
|
+
this._circuitBreaker?.triggerFailure(reason);
|
|
612
|
+
}
|
|
613
|
+
onBrokerReconnect() {
|
|
614
|
+
this._circuitBreaker?.triggerSuccess();
|
|
615
|
+
}
|
|
616
|
+
getActionFromIncompletelyParsedRpcMessage(rpcMessage, messageTypeId) {
|
|
617
|
+
let action;
|
|
618
|
+
switch (messageTypeId) {
|
|
619
|
+
case MessageTypeId.Call:
|
|
620
|
+
action = rpcMessage && rpcMessage.length > 2 ? rpcMessage[2] : NO_ACTION;
|
|
621
|
+
break;
|
|
622
|
+
case MessageTypeId.CallResult:
|
|
623
|
+
case MessageTypeId.CallError:
|
|
624
|
+
default:
|
|
625
|
+
action = NO_ACTION;
|
|
626
|
+
break;
|
|
627
|
+
}
|
|
628
|
+
return action;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
//# sourceMappingURL=router.js.map
|