@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
JavaScript
"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