UNPKG

@citrineos/ocpprouter

Version:

The ocpprouter module for OCPP v2.0.1. This module is not intended to be used directly, but rather as a dependency for other modules.

578 lines 31.4 kB
import { AbstractMessageRouter, AbstractModule, BOOT_STATUS, CacheNamespace, 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'; import { sequelize } from '@citrineos/data'; import { OidcTokenProvider } from '@citrineos/util'; import { Logger } from 'tslog'; import { v4 as uuidv4 } from 'uuid'; import { WebhookDispatcher } from './webhook.dispatcher.js'; /** * Implementation of the ocpp router */ export class MessageRouterImpl extends AbstractMessageRouter { /** * Fields */ _webhookDispatcher; _cache; _sender; _handler; _networkHook; _locationRepository; _oidcTokenProvider; /** * Constructor for the class. * * @param {BootstrapConfig & SystemConfig} config - the system configuration * @param {ICache} cache - the cache object * @param {IMessageSender} [sender] - the message sender * @param {IMessageHandler} [handler] - the message handler * @param {WebhookDispatcher} [dispatcher] - the webhook dispatcher * @param {Function} networkHook - the network hook needed to send messages to chargers * @param {ILocationRepository} [locationRepository] - An optional parameter of type {@link ILocationRepository} which * represents a repository for accessing and manipulating variable data. * If no `locationRepository` is provided, a default {@link locationRepository} instance is created and used. * @param {Logger<ILogObj>} [logger] - the logger object (optional) * @param {OCPPValidator} [ocppValidator] - the OCPPValidator instance, for message validation (optional) */ constructor(config, cache, sender, handler, dispatcher, networkHook, logger, ocppValidator, locationRepository) { super(config, cache, handler, sender, networkHook, logger, ocppValidator); this._cache = cache; this._sender = sender; this._handler = handler; this._webhookDispatcher = dispatcher; this._networkHook = networkHook; this._locationRepository = locationRepository || new sequelize.SequelizeLocationRepository(config, logger); if (this._config.oidcClient) { this._oidcTokenProvider = new OidcTokenProvider(this._config.oidcClient, this._logger); } } async doesChargingStationExistByStationId(tenantId, stationId) { return await this._locationRepository.doesChargingStationExistByStationId(tenantId, stationId); } // TODO: Below method should lock these tables so that a rapid connect-disconnect cannot result in race condition. async registerConnection(tenantId, stationId, protocol) { const dispatcherRegistration = this._webhookDispatcher.register(tenantId, stationId); const connectionIdentifier = createIdentifier(tenantId, stationId); const requestSubscription = this._handler.subscribe(connectionIdentifier, undefined, { tenantId: tenantId.toString(), stationId, state: MessageState.Request.toString(), origin: MessageOrigin.ChargingStationManagementSystem.toString(), }); const responseSubscription = this._handler.subscribe(connectionIdentifier, undefined, { tenantId: tenantId.toString(), stationId, state: MessageState.Response.toString(), origin: MessageOrigin.ChargingStationManagementSystem.toString(), }); const onlineCharger = this._locationRepository.setChargingStationIsOnlineAndOCPPVersion(tenantId, stationId, true, protocol); return Promise.all([ dispatcherRegistration, requestSubscription, responseSubscription, onlineCharger, ]) .then((resolvedArray) => resolvedArray[1] && resolvedArray[2]) .catch((error) => { this._logger.error(`Error registering connection for ${connectionIdentifier}: ${error}`); return false; }); } async deregisterConnection(tenantId, stationId) { this._webhookDispatcher.deregister(tenantId, stationId).catch((err) => { this._logger.error('_webhookDispatcher deregister failed', err); }); let protocol = null; try { const chargingStation = await this._locationRepository.readChargingStationByStationId(tenantId, stationId); if (chargingStation?.protocol) { protocol = chargingStation.protocol; } } catch (e) { this._logger?.warn?.(`Could not read charging station ${stationId} of tenant ${tenantId} to determine protocol: ${e.message}`); } await this._locationRepository.setChargingStationIsOnlineAndOCPPVersion(tenantId, stationId, false, protocol); const connectionIdentifier = createIdentifier(tenantId, stationId); // TODO: ensure that all queue implementations in 02_Util only unsubscribe 1 queue per call // ...which will require refactoring this method to unsubscribe request and response queues separately return await this._handler.unsubscribe(connectionIdentifier); } async onMessage(identifier, message, timestamp, protocol) { const tenantId = getTenantIdFromIdentifier(identifier); const stationId = getStationIdFromIdentifier(identifier); let success = true; let rpcMessage; let messageTypeId = undefined; 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. try { try { rpcMessage = JSON.parse(message); } catch (error) { this._logger.error(`Error parsing ${message} from websocket, unable to reply: ${JSON.stringify(error)}`); throw error; } messageTypeId = rpcMessage[0]; messageId = rpcMessage[1]; switch (messageTypeId) { case MessageTypeId.Call: { await this._onCall(identifier, rpcMessage, timestamp, protocol); break; } case MessageTypeId.CallResult: { await this._onCallResult(identifier, rpcMessage, timestamp, protocol); break; } case MessageTypeId.CallError: { await this._onCallError(identifier, rpcMessage, timestamp, protocol); break; } default: { let errorCode; switch (protocol) { case 'ocpp1.6': { errorCode = ErrorCode.FormationViolation; break; } case 'ocpp2.0.1': { errorCode = ErrorCode.FormatViolation; break; } default: { throw new Error('Unknown protocol: ' + protocol); } } throw new OcppError(messageId, errorCode, 'Unknown message type id: ' + messageTypeId, {}); } } } catch (error) { success = false; // ensure we return false in case of an error this._logger.error('Error processing message:', message, error); const action = this.getActionFromIncompletelyParsedRpcMessage(rpcMessage, messageTypeId); if (messageTypeId != MessageTypeId.CallResult && messageTypeId != MessageTypeId.CallError) { const callError = error instanceof OcppError ? error.asCallError() : [ MessageTypeId.CallError, messageId, ErrorCode.InternalError, 'Unable to process message', { error: error }, ]; const rawMessage = JSON.stringify(callError); await this._sendMessage(identifier, protocol, action, MessageState.Response, rawMessage, callError); } let state = MessageState.Unknown; switch (messageTypeId) { case MessageTypeId.Call: state = MessageState.Request; break; case MessageTypeId.CallResult: case MessageTypeId.CallError: state = MessageState.Response; break; default: // keep as Unknown break; } await this._webhookDispatcher.dispatchMessageReceivedUnparsed(tenantId, stationId, message, timestamp.toISOString(), protocol, action, state); } // Update latestOcppMessageTimestamp for any incoming OCPP message (non-blocking, single query) this._locationRepository .updateChargingStationTimestamp(tenantId, stationId, timestamp.toISOString()) .catch((error) => { this._logger.error(`Failed to update latestOcppMessageTimestamp for ${identifier}:`, error); }); return success; } /** * Sends a Call message to a charging station with given identifier. * * @param {string} stationId - The identifier of the station. * @param {number} tenantId - The identifier of the tenant. * @param {OCPPVersionType} protocol The OCPP protocol version of the message. * @param {CallAction} action - The action to be called. * @param {OcppRequest} payload - The payload of the call. * @param {string} correlationId - The correlation ID of the message. * @param {MessageOrigin} _origin - The origin of the message. * @return {Promise<boolean>} A promise that resolves to a boolean indicating if the call was sent successfully. */ async sendCall(stationId, tenantId, protocol, action, payload, correlationId = uuidv4(), _origin) { const identifier = createIdentifier(tenantId, stationId); const transactionNamespace = CacheNamespace.Transactions + identifier; const message = [MessageTypeId.Call, correlationId, action, payload]; if (await this._sendCallIsAllowed(identifier, protocol, message)) { if (!(await this._cache.existsAnyInNamespace(transactionNamespace))) { const cacheTimestamp = new Date(); await this._cache.set(correlationId, `${action}@${cacheTimestamp.toISOString()}`, transactionNamespace, this._config.maxCallLengthSeconds); const rawMessage = JSON.stringify(message); const successTimestamp = await this._sendMessage(identifier, protocol, action, MessageState.Request, rawMessage, message); if (successTimestamp != undefined) { this._logger.debug(`Call sent successfully with ${successTimestamp.getTime() - cacheTimestamp.getTime()} ms of lag between cache and send ${correlationId}`, identifier, message); } else { const removed = await this._cache.remove(correlationId, transactionNamespace); this._logger.warn(`Failed to send call, removed from cache: ${removed}`, identifier, message); } return { success: !!successTimestamp }; } else { this._logger.info('Call already in progress, throwing retry exception', identifier, message); throw new RetryMessageError('Call already in progress'); } } else { this._logger.info('RegistrationStatus Rejected, unable to send', identifier, message); return { success: false }; } } /** * Sends the CallResult to a charging station with given identifier. * * @param {string} correlationId - The correlation ID of the message. * @param {string} stationId - The identifier of the charging station. * @param {number} tenantId - The identifier of the tenant. * @param {OCPPVersionType} protocol The OCPP protocol version of the message. * @param {CallAction} action - The action to be called. * @param {OcppRequest} payload - The payload of the call. * @param {MessageOrigin} _origin - The origin of the message. * @return {Promise<boolean>} A promise that resolves to true if the call result was sent successfully, or false otherwise. */ async sendCallResult(correlationId, stationId, tenantId, protocol, action, payload, _origin) { const message = [MessageTypeId.CallResult, correlationId, payload]; const identifier = createIdentifier(tenantId, stationId); const cachedActionTimestamp = await this._cache.get(correlationId, CacheNamespace.Transactions + identifier); if (!cachedActionTimestamp) { this._logger.error('Failed to send callResult due to missing message id', identifier, message); return { success: false }; } const [cachedAction, cachedTimestamp] = cachedActionTimestamp?.split(/@(.*)/) ?? []; // Returns all characters after first '@' if (cachedAction === action) { const rawMessage = JSON.stringify(message); const success = await Promise.all([ this._sendMessage(identifier, protocol, cachedAction, MessageState.Response, rawMessage, message, cachedTimestamp), this._cache.remove(correlationId, CacheNamespace.Transactions + identifier), ]).then((successes) => successes.every(Boolean)); this._logger.debug(`CallResult sent successfully ${correlationId}`, identifier, message); return { success }; } else { this._logger.error('Failed to send callResult due to mismatched action', identifier, cachedActionTimestamp, message); return { success: false }; } } /** * Sends a CallError message to a charging station with given identifier. * * @param {string} correlationId - The correlation ID of the message. * @param {string} stationId - The identifier of the charging station. * @param {number} tenantId - The identifier of the tenant. * @param {OCPPVersionType} protocol The OCPP protocol version of the message. * @param {CallAction} _action - The action to be called. * @param {OcppError} error - The error of the call. * @param {MessageOrigin} _origin - The origin of the message. * @return {Promise<boolean>} - A promise that resolves to true if the message was sent successfully. */ async sendCallError(correlationId, stationId, tenantId, protocol, action, error, _origin) { const message = error.asCallError(); const identifier = createIdentifier(tenantId, stationId); const cachedActionTimestamp = await this._cache.get(correlationId, CacheNamespace.Transactions + identifier); if (!cachedActionTimestamp) { this._logger.error('Failed to send callError due to missing message id', identifier, message); return { success: false }; } const [cachedAction, cachedTimestamp] = cachedActionTimestamp?.split(/@(.*)/) ?? []; // Returns all characters after first '@' if (cachedAction === action) { const rawMessage = JSON.stringify(message); const success = await Promise.all([ this._sendMessage(identifier, protocol, cachedAction, MessageState.Response, rawMessage, message, cachedTimestamp), this._cache.remove(correlationId, CacheNamespace.Transactions + identifier), ]).then((successes) => successes.every(Boolean)); return { success }; } else { this._logger.error('Failed to send callError due to mismatched action', identifier, cachedActionTimestamp, cachedAction, message); return { success: false }; } } async shutdown() { await this._sender.shutdown(); await this._handler.shutdown(); } /** * Private Methods */ /** * Handles an incoming Call message from a client connection. * * @param {string} identifier - The client identifier. * @param {Call} message - The Call message received. * @param {Date} timestamp Time at which the message was received from the charger. * @param {string} protocol The OCPP protocol version of the message * @return {void} */ async _onCall(identifier, message, timestamp, protocol) { const messageId = message[1]; const tenantId = getTenantIdFromIdentifier(identifier); const stationId = getStationIdFromIdentifier(identifier); let action = message[2]; this._logger.debug('_onCall:', identifier, message, timestamp.toISOString(), protocol); action = mapToCallAction(protocol, action); const isAllowed = await this._onCallIsAllowed(action, identifier); if (!isAllowed) { throw new OcppError(messageId, ErrorCode.SecurityError, `Action ${action} not allowed`); } // Run schema validation for incoming Call message const { isValid, errors } = this._validateCall(identifier, message, protocol); if (!isValid || errors) { throw new OcppError(messageId, ErrorCode.FormatViolation, 'Invalid message format', { errors: errors, }); } this._cache .existsAnyInNamespace(CacheNamespace.Transactions + identifier) .then((exists) => { if (exists) { this._logger.debug('Another call is already in progress, processing call anyways', identifier, message); } }) .catch((error) => { this._logger.error('Failed to check if another call is in progress:', identifier, message, error); }); this._cache .setIfNotExist(messageId, `${action}@${timestamp.toISOString()}`, CacheNamespace.Transactions + identifier, this._config.maxCallLengthSeconds) .then((success) => { if (!success) { this._logger.debug('Another call with same messageId is already in progress, processing call anyways', identifier, message); } }) .catch((error) => { this._logger.error('Failed to set call in cache:', identifier, message, error); }); try { // Route call const confirmation = await this._routeCall(identifier, message, timestamp, protocol); if (!confirmation.success) { throw new OcppError(messageId, ErrorCode.InternalError, 'Call failed', { details: confirmation.payload, }); } } catch (error) { const callError = error instanceof OcppError ? error : new OcppError(messageId, ErrorCode.InternalError, 'Call failed', { details: error, }); this.sendCallError(messageId, stationId, tenantId, protocol, action, callError) .catch((err) => { this._logger.error('sendCallError failed', err); }) .finally(() => { this._cache.remove(messageId, CacheNamespace.Transactions + identifier).catch((err) => { this._logger.error('cache remove failed', err); }); }); } } /** * Handles a CallResult made by the client. * * @param {string} identifier - The client identifier that made the call. * @param {CallResult} message - The OCPP CallResult message. * @param {Date} timestamp Time at which the message was received from the charger. * @param {OCPPVersionType} protocol The OCPP protocol version of the message */ async _onCallResult(identifier, message, timestamp, protocol) { const messageId = message[1]; this._logger.debug('_onCallResult:', identifier, message, timestamp.toISOString(), protocol); const cachedActionTimestamp = await this._cache.get(messageId, CacheNamespace.Transactions + identifier); await this._cache.remove(messageId, CacheNamespace.Transactions + identifier).catch((err) => { this._logger.error('_onCallResult cache remove failed', err); }); if (!cachedActionTimestamp) { throw new OcppError(messageId, ErrorCode.InternalError, 'MessageId not found, call may have timed out', { maxCallLengthSeconds: this._config.maxCallLengthSeconds }); } const [action, cachedTimestamp] = cachedActionTimestamp.split(/@(.*)/); // Returns all characters after first '@' this._logger.debug(`Message received. Time taken since sent: ${timestamp.getTime() - new Date(cachedTimestamp).getTime()} ms`, identifier, message); // Run schema validation for incoming CallResult message const { isValid, errors } = this._validateCallResult(identifier, mapToCallAction(protocol, action), message, protocol); if (!isValid || errors) { throw new OcppError(messageId, ErrorCode.FormatViolation, 'Invalid message format', { errors: errors, }); } // Route call result const confirmation = await this._routeCallResult(identifier, message, mapToCallAction(protocol, action), timestamp, protocol); if (!confirmation.success) { throw new OcppError(messageId, ErrorCode.InternalError, 'CallResult failed', { details: confirmation.payload, }); } } /** * Handles the CallError that may have occured during a Call exchange. * * @param {string} identifier - The client identifier. * @param {CallError} message - The error message. * @param {Date} timestamp Time at which the message was received from the charger. * @param {OCPPVersionType} protocol The OCPP protocol version of the message */ async _onCallError(identifier, message, timestamp, protocol) { const messageId = message[1]; this._logger.debug('_onCallError:', identifier, message, timestamp.toISOString(), protocol); const cachedActionTimestamp = await this._cache.get(messageId, CacheNamespace.Transactions + identifier); // Always remove pending call transaction await this._cache.remove(messageId, CacheNamespace.Transactions + identifier).catch((err) => { this._logger.error('_onCallError cache remove failed', err); }); if (!cachedActionTimestamp) { throw new OcppError(messageId, ErrorCode.InternalError, 'MessageId not found, call may have timed out', { maxCallLengthSeconds: this._config.maxCallLengthSeconds }); } const [action, cachedTimestamp] = cachedActionTimestamp.split(/@(.*)/); // Returns all characters after first '@' this._logger.debug(`Message received. Time taken since sent: ${timestamp.getTime() - new Date(cachedTimestamp).getTime()} ms`, identifier, message); const confirmation = await this._routeCallError(identifier, message, mapToCallAction(protocol, action), timestamp, protocol); if (!confirmation.success) { // Below code commented out with debug log because currently there is no error routing implemented, so this block will always be reached for CallErrors. // Once error routing is implemented, this block can be uncommented to throw an error if the CallError routing fails. this._logger.debug('Unable to route call error: ', confirmation); // throw new OcppError(messageId, ErrorCode.InternalError, 'CallError failed', { // details: confirmation.payload, // }); } } /** * Determine if the given action for identifier is allowed. * * @param {CallAction} action - The action to be checked. * @param {string} identifier - The identifier to be checked. * @return {Promise<boolean>} A promise that resolves to a boolean indicating if the action and identifier are allowed. */ _onCallIsAllowed(action, identifier) { return this._cache.exists(action, identifier).then((blacklisted) => !blacklisted); } /** * * @param {string} identifier - The identifier of the client, e.g. "tenantId:stationId". * @param {OCPPVersionType} protocol - The OCPP protocol version. * @param {string} action - The OCPP CallAction to be sent. See {@link CallAction}. * @param {MessageState} state - The state of the message. Used for dispatching in webhook. * @param {string} rawMessage - The raw message string to be sent, i.e. the stringified version of the rpc message. Used for sending in webhook and logging. * @param {any} rpcMessage - the rpc message json object, i.e. [MessageTypeId, messageId, action, payload] for Call or [MessageTypeId, messageId, payload] for CallResult. Used for logging and dispatching in webhook. * @param {string} receivedIsoTimestamp - The ISO timestamp of when the Call was received, if this is a response to a Call. Used for logging the time taken for the message to be sent since it was received. * @returns {Promise<Date | undefined>} A promise that resolves to the timestamp of when the message was sent or undefined if the message failed to send. */ async _sendMessage(identifier, protocol, action, state, rawMessage, rpcMessage, receivedIsoTimestamp) { try { await this._networkHook(identifier, rawMessage); // Throws an error if the message is not sent, or returns void } catch (error) { this._logger.error('Failed to send message:', identifier, rawMessage, error); // Don't dispatch if the message was not sent return undefined; } const sentTimestamp = new Date(); if (receivedIsoTimestamp) { const receivedTimestamp = new Date(receivedIsoTimestamp); this._logger.debug(`Message sent successfully. Time taken since received: ${sentTimestamp.getTime() - receivedTimestamp.getTime()} ms`, identifier, rpcMessage); } this._webhookDispatcher .dispatchMessageSent(identifier, action, state, sentTimestamp.toISOString(), protocol, rpcMessage) .catch((err) => { this._logger.error('dispatchMessageSent failed', err); }); return sentTimestamp; } async _sendCallIsAllowed(identifier, protocol, message) { const status = await this._cache.get(BOOT_STATUS, identifier); if (status === OCPP2_0_1.RegistrationStatusEnumType.Rejected && // TriggerMessage<BootNotification> is the only message allowed to be sent during Rejected BootStatus B03.FR.08 !(mapToCallAction(protocol, message[2]) === OCPP2_0_1_CallAction.TriggerMessage && message[3].requestedMessage == OCPP2_0_1.MessageTriggerEnumType.BootNotification)) { return false; } return true; } async _routeCall(connectionIdentifier, message, timestamp, protocol) { const messageId = message[1]; const action = mapToCallAction(protocol, message[2]); const payload = message[3]; const tenantId = getTenantIdFromIdentifier(connectionIdentifier); const stationId = getStationIdFromIdentifier(connectionIdentifier); const _message = RequestBuilder.buildCall(stationId, messageId, tenantId, action, payload, EventGroup.Router, MessageOrigin.ChargingStation, protocol, timestamp); return this.emitMessage(_message, message); } async _routeCallResult(connectionIdentifier, message, action, timestamp, protocol) { const messageId = message[1]; const payload = message[2]; const tenantId = getTenantIdFromIdentifier(connectionIdentifier); const stationId = getStationIdFromIdentifier(connectionIdentifier); const _message = RequestBuilder.buildCallResult(stationId, messageId, tenantId, action, payload, EventGroup.Router, MessageOrigin.ChargingStation, protocol, timestamp); return this.emitMessage(_message, message); } async _routeCallError(connectionIdentifier, message, action, timestamp, protocol) { const messageId = message[1]; const payload = new OcppError(messageId, message[2], message[3], message[4]); const tenantId = getTenantIdFromIdentifier(connectionIdentifier); const stationId = getStationIdFromIdentifier(connectionIdentifier); const _message = RequestBuilder.buildCallError(stationId, messageId, tenantId, action, payload, EventGroup.Router, MessageOrigin.ChargingStation, protocol, timestamp); // Fulfill callback for api, if needed this._handleMessageApiCallback(_message).catch((err) => { this._logger.error('_handleMessageApiCallback failed', err); }); return this.emitMessage(_message, message); } async emitMessage(message, rpcMessage) { let confirmation; if (message.payload instanceof OcppError) { // No error routing currently done this._logger.warn('OCPP Error routing not implemented'); confirmation = { success: false }; } else { confirmation = await this._sender.send(message); } await this._webhookDispatcher.dispatchMessageReceived(message.context.tenantId, message.context.stationId, message.context.timestamp, message.protocol, message.action, message.state, rpcMessage); return confirmation; } async _handleMessageApiCallback(message) { const url = await this._cache.get(message.context.correlationId, AbstractModule.CALLBACK_URL_CACHE_PREFIX + message.context.stationId); if (url) { const headers = { 'Content-Type': 'application/json', }; if (this._oidcTokenProvider) { try { const token = await this._oidcTokenProvider.getToken(); headers['Authorization'] = `Bearer ${token}`; } catch (error) { this._logger.error('Failed to get OIDC token for callback:', error); return; } } await fetch(url, { method: 'POST', headers, body: JSON.stringify(message.payload), }); } } getActionFromIncompletelyParsedRpcMessage(rpcMessage, messageTypeId) { let action; switch (messageTypeId) { case MessageTypeId.Call: action = rpcMessage && rpcMessage.length > 2 ? rpcMessage[2] : NO_ACTION; break; case MessageTypeId.CallResult: case MessageTypeId.CallError: default: action = NO_ACTION; break; } return action; } } //# sourceMappingURL=router.js.map