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.

514 lines 28 kB
"use strict"; // Copyright Contributors to the CitrineOS Project // // SPDX-License-Identifier: Apache 2.0 /* eslint-disable */ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; Object.defineProperty(exports, "__esModule", { value: true }); exports.MessageRouterImpl = void 0; const base_1 = require("@citrineos/base"); const uuid_1 = require("uuid"); const data_1 = require("@citrineos/data"); /** * Implementation of the ocpp router */ class MessageRouterImpl extends base_1.AbstractMessageRouter { /** * Constructor for the class. * * @param {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 sequelize.LocationRepository} instance is created and used. * * @param {ISubscriptionRepository} [subscriptionRepository] - the subscription repository * @param {Logger<ILogObj>} [logger] - the logger object (optional) * @param {Ajv} [ajv] - the Ajv object, for message validation (optional) */ constructor(config, cache, sender, handler, dispatcher, networkHook, logger, ajv, locationRepository, subscriptionRepository) { super(config, cache, handler, sender, networkHook, logger, ajv); this._cache = cache; this._sender = sender; this._handler = handler; this._webhookDispatcher = dispatcher; this._networkHook = networkHook; this._locationRepository = locationRepository || new data_1.sequelize.SequelizeLocationRepository(config, logger); this.subscriptionRepository = subscriptionRepository || new data_1.sequelize.SequelizeSubscriptionRepository(config, this._logger); } // TODO: Below method should lock these tables so that a rapid connect-disconnect cannot result in race condition. registerConnection(connectionIdentifier, protocol) { return __awaiter(this, void 0, void 0, function* () { const dispatcherRegistration = this._webhookDispatcher.register(connectionIdentifier); const requestSubscription = this._handler.subscribe(connectionIdentifier, undefined, { stationId: connectionIdentifier, state: base_1.MessageState.Request.toString(), origin: base_1.MessageOrigin.ChargingStationManagementSystem.toString(), }); const responseSubscription = this._handler.subscribe(connectionIdentifier, undefined, { stationId: connectionIdentifier, state: base_1.MessageState.Response.toString(), origin: base_1.MessageOrigin.ChargingStationManagementSystem.toString(), }); const onlineCharger = this._locationRepository.setChargingStationIsOnlineAndOCPPVersion(connectionIdentifier, 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; }); }); } deregisterConnection(connectionIdentifier) { return __awaiter(this, void 0, void 0, function* () { this._webhookDispatcher.deregister(connectionIdentifier); const offlineCharger = yield this._locationRepository.setChargingStationIsOnlineAndOCPPVersion(connectionIdentifier, false, null); // 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 yield this._handler.unsubscribe(connectionIdentifier); }); } // TODO: identifier may not be unique, may require combination of tenantId and identifier. // find way to include tenantId here onMessage(identifier, message, timestamp, protocol) { return __awaiter(this, void 0, void 0, function* () { 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 base_1.MessageTypeId.Call: yield this._onCall(identifier, rpcMessage, timestamp, protocol); break; case base_1.MessageTypeId.CallResult: yield this._onCallResult(identifier, rpcMessage, timestamp, protocol); break; case base_1.MessageTypeId.CallError: yield this._onCallError(identifier, rpcMessage, timestamp, protocol); break; default: let errorCode; switch (protocol) { case 'ocpp1.6': errorCode = base_1.ErrorCode.FormationViolation; break; case 'ocpp2.0.1': errorCode = base_1.ErrorCode.FormatViolation; break; default: throw new Error('Unknown protocol: ' + protocol); } throw new base_1.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); if (messageTypeId != base_1.MessageTypeId.CallResult && messageTypeId != base_1.MessageTypeId.CallError) { let callError = error instanceof base_1.OcppError ? error.asCallError() : [ base_1.MessageTypeId.CallError, messageId, base_1.ErrorCode.InternalError, 'Unable to process message', { error: error }, ]; callError = this.removeNulls(callError); const rawMessage = JSON.stringify(callError); this._sendMessage(identifier, protocol, rawMessage, callError); } } yield this._webhookDispatcher.dispatchMessageReceived(identifier, message, timestamp.toISOString(), protocol, rpcMessage); return success; }); } /** * Sends a Call message to a charging station with given identifier. * * @param {string} identifier - The identifier of the charging station. * @param {Call} message - The Call message to send. * @return {Promise<boolean>} A promise that resolves to a boolean indicating if the call was sent successfully. */ sendCall(identifier_1, tenantId_1, protocol_1, action_1, payload_1) { return __awaiter(this, arguments, void 0, function* (identifier, tenantId, protocol, action, payload, correlationId = (0, uuid_1.v4)(), origin) { let message = [base_1.MessageTypeId.Call, correlationId, action, payload]; if (yield this._sendCallIsAllowed(identifier, protocol, message)) { if (yield this._cache.setIfNotExist(identifier, `${action}:${correlationId}`, base_1.CacheNamespace.Transactions, this._config.maxCallLengthSeconds)) { message = this.removeNulls(message); const rawMessage = JSON.stringify(message); const success = yield this._sendMessage(identifier, protocol, rawMessage, message); return { success }; } else { this._logger.info('Call already in progress, throwing retry exception', identifier, message); throw new base_1.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} identifier - The identifier of the charging station. * @param {CallResult} message - The CallResult message to send. * @return {Promise<boolean>} A promise that resolves to true if the call result was sent successfully, or false otherwise. */ sendCallResult(correlationId, identifier, tenantId, protocol, action, payload, origin) { return __awaiter(this, void 0, void 0, function* () { let message = [base_1.MessageTypeId.CallResult, correlationId, payload]; const cachedActionMessageId = yield this._cache.get(identifier, base_1.CacheNamespace.Transactions); if (!cachedActionMessageId) { this._logger.error('Failed to send callResult due to missing message id', identifier, message); return { success: false }; } let [cachedAction, cachedMessageId] = cachedActionMessageId === null || cachedActionMessageId === void 0 ? void 0 : cachedActionMessageId.split(/:(.*)/); // Returns all characters after first ':' in case ':' is used in messageId if (cachedAction === action && cachedMessageId === correlationId) { message = this.removeNulls(message); const rawMessage = JSON.stringify(message); const success = yield Promise.all([ this._sendMessage(identifier, protocol, rawMessage, message), this._cache.remove(identifier, base_1.CacheNamespace.Transactions), ]).then((successes) => successes.every(Boolean)); return { success }; } else { this._logger.error('Failed to send callResult due to mismatch in message id', identifier, cachedActionMessageId, message); return { success: false }; } }); } /** * Sends a CallError message to a charging station with given identifier. * * @param {string} identifier - The identifier of the charging station. * @param {CallError} message - The CallError message to send. * @return {Promise<boolean>} - A promise that resolves to true if the message was sent successfully. */ sendCallError(correlationId, identifier, tenantId, protocol, action, error, origin) { return __awaiter(this, void 0, void 0, function* () { let message = error.asCallError(); const cachedActionMessageId = yield this._cache.get(identifier, base_1.CacheNamespace.Transactions); if (!cachedActionMessageId) { this._logger.error('Failed to send callError due to missing message id', identifier, message); return { success: false }; } let [cachedAction, cachedMessageId] = cachedActionMessageId === null || cachedActionMessageId === void 0 ? void 0 : cachedActionMessageId.split(/:(.*)/); // Returns all characters after first ':' in case ':' is used in messageId if (cachedMessageId === correlationId) { message = this.removeNulls(message); const rawMessage = JSON.stringify(message); const success = yield Promise.all([ this._sendMessage(identifier, protocol, rawMessage, message), this._cache.remove(identifier, base_1.CacheNamespace.Transactions), ]).then((successes) => successes.every(Boolean)); return { success }; } else { this._logger.error('Failed to send callError due to mismatch in message id', identifier, cachedActionMessageId, message); return { success: false }; } }); } shutdown() { return __awaiter(this, void 0, void 0, function* () { yield this._sender.shutdown(); yield 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} */ _onCall(identifier, message, timestamp, protocol) { return __awaiter(this, void 0, void 0, function* () { const messageId = message[1]; let action = null; try { action = (0, base_1.mapToCallAction)(protocol, message[2]); const isAllowed = yield this._onCallIsAllowed(action, identifier); if (!isAllowed) { throw new base_1.OcppError(messageId, base_1.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 base_1.OcppError(messageId, base_1.ErrorCode.FormatViolation, 'Invalid message format', { errors: errors, }); } // Ensure only one call is processed at a time const successfullySet = yield this._cache.setIfNotExist(identifier, `${action}:${messageId}`, base_1.CacheNamespace.Transactions, this._config.maxCallLengthSeconds); if (!successfullySet) { throw new base_1.OcppError(messageId, base_1.ErrorCode.RpcFrameworkError, 'Call already in progress', {}); } } catch (error) { this._logger.error('Failed to process Call message', identifier, message, error); // Send manual reply since cache was unable to be set let callError = error instanceof base_1.OcppError ? error.asCallError() : [ base_1.MessageTypeId.CallError, messageId, base_1.ErrorCode.InternalError, 'Unable to process message', { error: error.message }, ]; callError = this.removeNulls(callError); const rawMessage = JSON.stringify(callError); yield this._sendMessage(identifier, protocol, rawMessage, callError); return; } try { // Route call const confirmation = yield this._routeCall(identifier, message, timestamp, protocol); if (!confirmation.success) { throw new base_1.OcppError(messageId, base_1.ErrorCode.InternalError, 'Call failed', { details: confirmation.payload, }); } } catch (error) { const callError = error instanceof base_1.OcppError ? error : new base_1.OcppError(messageId, base_1.ErrorCode.InternalError, 'Call failed', { details: error, }); // TODO: identifier may not be unique, may require combination of tenantId and identifier. // find way to include tenantId here this.sendCallError(messageId, identifier, 'undefined', protocol, action, callError).finally(() => { this._cache.remove(identifier, base_1.CacheNamespace.Transactions); }); } }); } /** * 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 * @return {void} */ _onCallResult(identifier, message, timestamp, protocol) { const messageId = message[1]; const payload = message[2]; this._logger.debug('Process CallResult', identifier, messageId, payload); this._cache .get(identifier, base_1.CacheNamespace.Transactions) .then((cachedActionMessageId) => { this._cache.remove(identifier, base_1.CacheNamespace.Transactions); // Always remove pending call transaction if (!cachedActionMessageId) { throw new base_1.OcppError(messageId, base_1.ErrorCode.InternalError, 'MessageId not found, call may have timed out', { maxCallLengthSeconds: this._config.maxCallLengthSeconds }); } const [action, cachedMessageId] = cachedActionMessageId.split(/:(.*)/); // Returns all characters after first ':' in case ':' is used in messageId if (messageId !== cachedMessageId) { throw new base_1.OcppError(messageId, base_1.ErrorCode.InternalError, "MessageId doesn't match", { expectedMessageId: cachedMessageId, }); } return Object.assign({ action }, this._validateCallResult(identifier, (0, base_1.mapToCallAction)(protocol, action), message, protocol)); // Run schema validation for incoming CallResult message }) .then(({ action, isValid, errors }) => { if (!isValid || errors) { throw new base_1.OcppError(messageId, base_1.ErrorCode.FormatViolation, 'Invalid message format', { errors: errors, }); } // Route call result return this._routeCallResult(identifier, message, (0, base_1.mapToCallAction)(protocol, action), timestamp, protocol); }) .then((confirmation) => { if (!confirmation.success) { throw new base_1.OcppError(messageId, base_1.ErrorCode.InternalError, 'CallResult failed', { details: confirmation.payload, }); } }) .catch((error) => { // TODO: There's no such thing as a CallError in response to a CallResult. The above call error exceptions should be replaced. // TODO: Ideally the error log is also stored in the database in a failed invocations table to ensure these are visible outside of a log file. this._logger.error('Failed processing call result: ', error); }); } /** * 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 * @return {void} This function doesn't return anything. */ _onCallError(identifier, message, timestamp, protocol) { const messageId = message[1]; this._logger.debug('Process CallError', identifier, message); this._cache .get(identifier, base_1.CacheNamespace.Transactions) .then((cachedActionMessageId) => { this._cache.remove(identifier, base_1.CacheNamespace.Transactions); // Always remove pending call transaction if (!cachedActionMessageId) { throw new base_1.OcppError(messageId, base_1.ErrorCode.InternalError, 'MessageId not found, call may have timed out', { maxCallLengthSeconds: this._config.maxCallLengthSeconds }); } const [action, cachedMessageId] = cachedActionMessageId.split(/:(.*)/); // Returns all characters after first ':' in case ':' is used in messageId if (messageId !== cachedMessageId) { throw new base_1.OcppError(messageId, base_1.ErrorCode.InternalError, "MessageId doesn't match", { expectedMessageId: cachedMessageId, }); } return this._routeCallError(identifier, message, (0, base_1.mapToCallAction)(protocol, action), timestamp, protocol); }) .then((confirmation) => { if (!confirmation.success) { this._logger.warn('Unable to route call error: ', confirmation); } }) .catch((error) => { // TODO: Ideally the error log is also stored in the database in a failed invocations table to ensure these are visible outside of a log file. this._logger.error('Failed processing call error: ', error); }); } /** * 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); } _sendMessage(identifier, protocol, rawMessage, rpcMessage) { return __awaiter(this, void 0, void 0, function* () { try { yield 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 false; } this._webhookDispatcher.dispatchMessageSent(identifier, rawMessage, new Date().toISOString(), protocol, rpcMessage); return true; }); } _sendCallIsAllowed(identifier, protocol, message) { return __awaiter(this, void 0, void 0, function* () { const status = yield this._cache.get(base_1.BOOT_STATUS, identifier); if (status === base_1.OCPP2_0_1.RegistrationStatusEnumType.Rejected && // TriggerMessage<BootNotification> is the only message allowed to be sent during Rejected BootStatus B03.FR.08 !((0, base_1.mapToCallAction)(protocol, message[2]) === base_1.OCPP2_0_1_CallAction.TriggerMessage && message[3].requestedMessage == base_1.OCPP2_0_1.MessageTriggerEnumType.BootNotification)) { return false; } return true; }); } _routeCall(connectionIdentifier, message, timestamp, protocol) { return __awaiter(this, void 0, void 0, function* () { const messageId = message[1]; const action = (0, base_1.mapToCallAction)(protocol, message[2]); const payload = message[3]; const _message = base_1.RequestBuilder.buildCall(connectionIdentifier, messageId, '', // TODO: Add tenantId to method action, payload, base_1.EventGroup.General, // TODO: Change to appropriate event group base_1.MessageOrigin.ChargingStation, protocol, timestamp); return this._sender.send(_message); }); } _routeCallResult(connectionIdentifier, message, action, timestamp, protocol) { return __awaiter(this, void 0, void 0, function* () { const messageId = message[1]; const payload = message[2]; const _message = base_1.RequestBuilder.buildCallResult(connectionIdentifier, messageId, '', // TODO: Add tenantId to method action, payload, base_1.EventGroup.General, base_1.MessageOrigin.ChargingStation, protocol, timestamp); return this._sender.send(_message); }); } _routeCallError(connectionIdentifier, message, action, timestamp, protocol) { return __awaiter(this, void 0, void 0, function* () { const messageId = message[1]; const payload = new base_1.OcppError(messageId, message[2], message[3], message[4]); const _message = base_1.RequestBuilder.buildCallError(connectionIdentifier, messageId, '', // TODO: Add tenantId to method action, payload, base_1.EventGroup.General, base_1.MessageOrigin.ChargingStation, protocol, timestamp); // Fulfill callback for api, if needed this._handleMessageApiCallback(_message); // No error routing currently done this._logger.warn('Error routing not implemented'); return { success: false }; }); } _handleMessageApiCallback(message) { return __awaiter(this, void 0, void 0, function* () { const url = yield this._cache.get(message.context.correlationId, base_1.AbstractModule.CALLBACK_URL_CACHE_PREFIX + message.context.stationId); if (url) { yield fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(message.payload), }); } }); } // Intentionally removing NULL values from object for OCPP conformity removeNulls(obj) { if (obj === null) return undefined; if (typeof obj !== 'object') return obj; if (Array.isArray(obj)) { return obj.filter((item) => item !== null).map((item) => this.removeNulls(item)); } const result = {}; for (const [key, value] of Object.entries(obj)) { result[key] = this.removeNulls(value); } return result; } } exports.MessageRouterImpl = MessageRouterImpl; //# sourceMappingURL=router.js.map