Add extracted tools: CitrineOS, OpenOCPP, ShapeShifter

- CitrineOS core extracted (CSMS OCPP 2.0.1)
- OpenOCPP extracted (firmware OCPP 1.6J/2.0.1)
- ShapeShifter library installed (pip install -e)
- ShapeShifter specification extracted
- EVerest extracted

TODO updated with progress
This commit is contained in:
Eric F
2026-06-08 00:38:27 -04:00
parent 468cfeaa50
commit d398a6ced2
7326 changed files with 1177561 additions and 7 deletions

View File

@@ -0,0 +1,27 @@
-----BEGIN RSA PRIVATE KEY-----
MIIEowIBAAKCAQEA8ul5QIk9qf+grKDCN1sKPPYsxT4HegfPOm4APr3pISo3rJNy
Ns8MfEryn58h8QJ8w7vSh0ZPlLpJq8oY8Y9ckaiJkNIWvRJYfFjwbMeWTLKteCOQ
lYTUzKEwxhRmQB1HlKPZYo1SFjgqF7ww4q98YFQQw67gzgs/rpLeEZ4pWzOVn4qM
+cBw2B0EgLFER6MI6dVja3t2NkNYRo6Jx0zs+UPKoYci7c9WETFcGOQwv2GjpOrm
ekkCLnp7/3/5tmkKmUFbkTZU8+Qoq+Hgj8pAqgRYik2dl9WyE21PHygL7a6psKZR
kjvOCPUqCkwjVdf/7oOfp8JAWSnWtyU4sKVU+wIDAQABAoIBAANTmjL9jighVZB3
pSE/8Gx0TJmo505PBBH/RqaVUDeBjgChhktk231qQ1dXRQ45Y/8EN/ZdSqK1SGP/
YQcR2Qkvny6qCeCt+yM8zpIWy6KiQcjm58h8aLOis3nK9rmDDSNmeQgl+k1OmJj5
nUvFbnUdQZuEbhS0R7t6zGq+WT+j9uCIt0DV3m2+qWw0uN8ObZpr16GOWBp8yo6+
3LBOwQ5+9/nqwTdOLMbpsZlJd/KOQikV9izOQkmL6tC2dxPXRqKPNST5987kYY37
H+0iIfIyvFCx5gTKpoUy2JxGvXZOlKdibaav9b4P5P663YIb4sTUVPHi1cyZPYkc
pnoLSjkCgYEA+zAuQ5i5FTLry7f5iPjGNSdWSJ8Fb8RJ6dqTBP/PDezI6K48vJJ1
gkll3JMyXfMBScd90qq1QVAUmb8Nz2mQXCIHsNBexjeuGNBnXxB21XSM3bA+1mL9
zJ5eUnBUgih60CJE/8jdo9GNUY0Ce0XEOTpl1LKYPJc2uSXC1y6kLcMCgYEA95C0
6c9hat9/VDRab+0VZrMb+FBj3f1XsHGRSWbQDuWuO7CfOEYhwTcLrE9WexTEjNL6
6IVvN3CqhcxGTHD4fgW+9QFGAXC2SoNKCsPokzC1X1ikUusPtriyn/IvUP6qYHus
eB4xsCD5ulyGfJJRbBRa5dmzlqxnKyimPfC+MGkCgYEAxoibWHQifX3k3vyHb1pp
luODkByYOHGllf9bSo1BwxjO5xGoEceUtyh6KS/ylE0YTI8vhM3GO1wnHCnkqXYf
UqLW/0qCThr+MMCvo3So6CeZmzLNR7ewMAVQOcptEP8bqtwbOyww+mULVFSmjHZl
FHJyv/101BcUepw89sT3oO8CgYB4GQI63vkCcLQDdHZfD+Ou87rg5pbcDVfp5940
fqT2ZSP2HwPOt+8OHZcTG1X31aZYLs272WePvJ9s0yFTWgailEUD9H8ymaxFT5Wu
zUVZimqie40UEKaJ3OYCw+mCYFjk/3o2t2cha43ag6JWcmD/joxeLxN5R9+wx0KG
j/Cj6QKBgF0WuXRkQwItF2F/swibuFcnqlGjz6I5k5wBuuZiRk8PcPgCPSyoWald
YzN6CLKfQSIZjO0fj7S329hc+CbKx1o+HNPjC72lP9fC/KCh8FAxMhUkcCwP7Bvt
fpRG66KTIz6EZtWWAI/VjWxUo9LOX4YVTBSVLeMI+bNmKHw4VLKI
-----END RSA PRIVATE KEY-----

View File

@@ -0,0 +1,25 @@
-----BEGIN CERTIFICATE-----
MIIB2DCCAX+gAwIBAgIUQni3EN5sA2Q87Jk7GtqIb6pm5vowCgYIKoZIzj0EAwIw
QDEjMCEGA1UEAwwaaG9zdC5kb2NrZXIuaW50ZXJuYWwgc3ViQ0ExDDAKBgNVBAoM
A1M0NDELMAkGA1UEBhMCVVMwHhcNMjUwOTE1MTkyMDAwWhcNMjYwOTE1MTkyMDAw
WjA6MR0wGwYDVQQDDBRob3N0LmRvY2tlci5pbnRlcm5hbDEMMAoGA1UECgwDUzQ0
MQswCQYDVQQGEwJVUzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABMbfvWXNwXIL
FEQlv3WKdyyu44SIjpBu7gh8ZJLhiwPh73ZeCdTYdVuMRK/MjXYdSarbFcyYx3r3
tuA2b/ETwtOjXTBbMAkGA1UdEwQCMAAwDgYDVR0PAQH/BAQDAgWgMB0GA1UdDgQW
BBTXX4f/xHDVTWWmoaxDhH5yJ7g31TAfBgNVHSMEGDAWgBSyPWWAFNKPvUUqo4Zv
EsaDP9FhijAKBggqhkjOPQQDAgNHADBEAiBG2Px9xGFh99S3e8+LAF5oLxOR/+Kd
pLmHIr0697N3wgIgSa5bHc9g3VYamhOlyW3ayqdvyiRKrXmZmNT9gxpInDA=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIB5jCCAY2gAwIBAgIUUyG64WSxxu4aeQpcTtZVlG2I4MUwCgYIKoZIzj0EAwIw
PzEiMCAGA1UEAwwZaG9zdC5kb2NrZXIuaW50ZXJuYWwgcm9vdDEMMAoGA1UECgwD
UzQ0MQswCQYDVQQGEwJVUzAeFw0yNTA5MTUxOTIwMDBaFw0yNjA5MTUxOTIwMDBa
MEAxIzAhBgNVBAMMGmhvc3QuZG9ja2VyLmludGVybmFsIHN1YkNBMQwwCgYDVQQK
DANTNDQxCzAJBgNVBAYTAlVTMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEavrF
mkwSpVhlLvFx23EcamEPzAZIVjOiqRoVKSfWRGnHRW7GClYHa89NINwwIAQZHRb+
LnYGamI7OWCs2LKLkaNmMGQwEgYDVR0TAQH/BAgwBgEB/wIBADAOBgNVHQ8BAf8E
BAMCAYYwHQYDVR0OBBYEFLI9ZYAU0o+9RSqjhm8SxoM/0WGKMB8GA1UdIwQYMBaA
FFCnxtBjFhTUJpJmL3scRJ3udfqMMAoGCCqGSM49BAMCA0cAMEQCIH3PQWW0To/T
CqHBbRKXqMAaOH9Vu/+aX/Ka3dEt0H/rAiAxtOqJR6/XNNKGr7Ngss7m8bDquph9
CcGBegQ+U2jW1w==
-----END CERTIFICATE-----

View File

@@ -0,0 +1,5 @@
-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgV77VRtKNNXcJ/I+/
iAfPlhPnl85Ba9fdZazc6wLNRtGhRANCAATG371lzcFyCxREJb91incsruOEiI6Q
bu4IfGSS4YsD4e92XgnU2HVbjESvzI12HUmq2xXMmMd697bgNm/xE8LT
-----END PRIVATE KEY-----

View File

@@ -0,0 +1,15 @@
-----BEGIN CERTIFICATE-----
MIICQjCCAemgAwIBAgIUZskYnMrPMTD8Y7xWa+OtNNtEZ0wwCgYIKoZIzj0EAwIw
PzEiMCAGA1UEAwwZaG9zdC5kb2NrZXIuaW50ZXJuYWwgcm9vdDEMMAoGA1UECgwD
UzQ0MQswCQYDVQQGEwJVUzAeFw0yNTA5MTUxOTIwMDBaFw0yNjA5MTUxOTIwMDBa
MD8xIjAgBgNVBAMMGWhvc3QuZG9ja2VyLmludGVybmFsIHJvb3QxDDAKBgNVBAoM
A1M0NDELMAkGA1UEBhMCVVMwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAATC3k2b
EozYv5BShaWRz1gkUV12qFlhT+fRYBHXxojziBTBgy+skq/Mg+QC9WvAGLvwAwMZ
sekkQrqpOk7VhGvEo4HCMIG/MBIGA1UdEwEB/wQIMAYBAf8CAQEwDgYDVR0PAQH/
BAQDAgGGMB0GA1UdDgQWBBRQp8bQYxYU1CaSZi97HESd7nX6jDB6BgNVHSMEczBx
gBRQp8bQYxYU1CaSZi97HESd7nX6jKFDpEEwPzEiMCAGA1UEAwwZaG9zdC5kb2Nr
ZXIuaW50ZXJuYWwgcm9vdDEMMAoGA1UECgwDUzQ0MQswCQYDVQQGEwJVU4IUZskY
nMrPMTD8Y7xWa+OtNNtEZ0wwCgYIKoZIzj0EAwIDRwAwRAIgeWgobfOZaHGwW12R
MhT+Nax9KPeTXYIsanAcOz/9hpECIF8bGUBgl1DBsUtQs8vjOcPbmEeauKXrLq06
XoEnt+Ig
-----END CERTIFICATE-----

View File

@@ -0,0 +1,5 @@
-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgv50+aVWvqbqBIxIh
UOu0IF0Gmyn+bycKwZSNqntDp3WhRANCAATC3k2bEozYv5BShaWRz1gkUV12qFlh
T+fRYBHXxojziBTBgy+skq/Mg+QC9WvAGLvwAwMZsekkQrqpOk7VhGvE
-----END PRIVATE KEY-----

View File

@@ -0,0 +1,5 @@
-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgdAgB+w3+nvkiW2Uk
7bji/syJjVr8hJv1OfdMkKakL5ShRANCAARq+sWaTBKlWGUu8XHbcRxqYQ/MBkhW
M6KpGhUpJ9ZEacdFbsYKVgdrz00g3DAgBBkdFv4udgZqYjs5YKzYsouR
-----END PRIVATE KEY-----

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

View File

@@ -0,0 +1,3 @@
Copyright Contributors to the CitrineOS Project
SPDX-License-Identifier: Apache-2.0

View File

@@ -0,0 +1,839 @@
// SPDX-FileCopyrightText: 2026 Contributors to the CitrineOS Project
//
// SPDX-License-Identifier: Apache-2.0
import type {
AbstractModule,
BootstrapConfig,
IApiAuthProvider,
IAuthorizer,
ICache,
IFileStorage,
IMessageHandler,
IMessageRouter,
IModule,
IModuleApi,
SystemConfig,
} from '@citrineos/base';
import {
Ajv,
ConfigStoreFactory,
EventGroup,
eventGroupFromString,
type IAuthenticator,
OCPPValidator,
OCPPVersion,
} from '@citrineos/base';
import type { ISmartCharging } from '@citrineos/core';
import {
AdminApi,
apiAuthPluginFp,
Authenticator,
BasicAuthenticationFilter,
BrokerAwareMessageSender,
CertificateAuthorityService,
CertificatesDataApi,
CertificatesModule,
CertificatesOcpp2Api,
ConfigurationDataApi,
ConfigurationModule,
ConfigurationOcpp16Api,
ConfigurationOcpp2Api,
ConnectedStationFilter,
DefaultDrizzleInstance,
EVDriverDataApi,
EVDriverModule,
EVDriverOcpp16Api,
EVDriverOcpp2Api,
IdGenerator,
initSwagger,
InternalSmartCharging,
LocalBypassAuthProvider,
MemoryCache,
MessageRouterImpl,
MonitoringDataApi,
MonitoringModule,
MonitoringOcpp2Api,
NetworkProfileFilter,
OIDCAuthProvider,
RabbitMQChannelManager,
RabbitMQConnectionManager,
RabbitMqReceiver,
RabbitMqSender,
RealTimeAuthorizer,
RedisCache,
ReportingModule,
ReportingOcpp16Api,
ReportingOcpp2Api,
RepositoryStore,
sequelize,
Sequelize,
SmartChargingModule,
SmartChargingOcpp16Api,
SmartChargingOcpp2Api,
TenantDataApi,
TenantModule,
TransactionsDataApi,
TransactionsModule,
TransactionsOcpp2Api,
UnknownStationFilter,
WebhookDispatcher,
WebsocketNetworkConnection,
} from '@citrineos/core';
import cors from '@fastify/cors';
import { type JsonSchemaToTsProvider } from '@fastify/type-provider-json-schema-to-ts';
import type { FastifyInstance, FastifyReply } from 'fastify';
import fastify from 'fastify';
import type {
FastifyRouteSchemaDef,
FastifySchemaCompiler,
FastifyValidationResult,
} from 'fastify/types/schema.js';
import type { RedisClientOptions } from 'redis';
import { type ILogObj, Logger } from 'tslog';
import { type HealthCheckResult, HealthCheckService } from './health/HealthCheckService.js';
export class CitrineOSServer {
/**
* Fields
*/
protected readonly _config: BootstrapConfig & SystemConfig;
protected readonly _logger: Logger<ILogObj>;
protected readonly _server: FastifyInstance;
protected readonly _cache: ICache;
protected readonly _ajv: Ajv.Ajv;
protected readonly _ocppValidator: OCPPValidator;
protected readonly _fileStorage: IFileStorage;
protected readonly modules: IModule[] = [];
protected readonly apis: IModuleApi[] = [];
protected _sequelizeInstance!: Sequelize;
protected host?: string;
protected port?: number;
protected eventGroup?: EventGroup;
protected _authenticator?: IAuthenticator;
protected _router?: IMessageRouter;
protected _networkConnection?: WebsocketNetworkConnection;
protected _repositoryStore!: RepositoryStore;
protected _idGenerator!: IdGenerator;
protected _certificateAuthorityService!: CertificateAuthorityService;
protected _smartChargingService!: ISmartCharging;
protected _realTimeAuthorizer!: IAuthorizer;
protected readonly appName: string;
protected _isShuttingDown = false;
protected _connectionManager?: RabbitMQConnectionManager;
protected _channelManager?: RabbitMQChannelManager;
protected _healthCheckService?: HealthCheckService;
/**
* Constructor for the class.
*
* @param {EventGroup} appName - app type
* @param {BootstrapConfig} bootstrapConfig
* @param {SystemConfig} systemConfig - config
* @param {FastifyInstance} server - optional Fastify server instance
* @param {Ajv} ajv - optional Ajv JSON schema validator instance
* @param {ICache} cache - cache
* @param {IFileStorage} _fileStorage - file storage
*/
// todo rename event group to type
constructor(
appName: string,
bootstrapConfig: BootstrapConfig,
systemConfig: SystemConfig,
server?: FastifyInstance,
ajv?: Ajv.Ajv,
cache?: ICache,
_fileStorage?: IFileStorage,
) {
// TODO: Create and export config schemas for each util module, such as amqp, redis, etc, to avoid passing them possibly invalid configuration
if (!systemConfig.util.messageBroker.amqp) {
throw new Error('This server implementation requires amqp configuration for rabbitMQ.');
}
this.appName = appName;
this._config = { ...bootstrapConfig, ...systemConfig };
this._server = server || fastify().withTypeProvider<JsonSchemaToTsProvider>();
// enable cors
(this._server as any).register(cors, {
origin: true, // This can be customized to specify allowed origins
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], // Specify allowed HTTP methods
});
console.log('Bootstrap configuration loaded');
// Add health check
this.initHealthCheck();
// Create Ajv JSON schema validator instance
this._ajv = OCPPValidator.createServerAjvInstance(ajv);
// Initialize parent logger
this._logger = this.initLogger();
// Create a separate OCPPValidator with its own Ajv instance for OCPP message validation.
// This must be distinct from _ajv: OCPP messages are parsed JSON (no coercion needed),
// whereas _ajv coerces types for Fastify's HTTP schema compilation.
this._ocppValidator = new OCPPValidator(this._logger);
// Set cache implementation
this._cache = this.initCache(cache);
// Initialize Swagger if enabled
this.initSwagger()
.then()
.catch((error) => this._logger.error('Could not initialize swagger', { error }));
// Register API authentication
this.registerApiAuth();
// Initialize File Access Implementation
this._fileStorage = ConfigStoreFactory.getInstance();
// Register AJV for schema validation
this.registerAjv();
// Initialize repository store
this.initRepositoryStore();
this.initIdGenerator();
this.initCertificateAuthorityService();
this.initSmartChargingService();
this.initRealTimeAuthorizer();
}
async initialize(): Promise<void> {
await this.initMessageBrokerConnection();
// Initialize module & API
// Always initialize API after SwaggerUI
await this.initSystem();
// Initialize database
await this.initDb();
this.initHealthCheckService();
// Set up shutdown handlers
for (const event of ['SIGINT', 'SIGTERM', 'SIGQUIT']) {
process.on(event, () => {
this._logger.info(`Received ${event}`);
this.shutdown().catch((err) => {
console.error('Shutdown error:', err);
process.exit(1);
});
});
}
}
async shutdown() {
if (this._isShuttingDown) return;
this._isShuttingDown = true;
this._logger.info('Shutdown initiated');
this._healthCheckService?.shutdown();
const forceExit = setTimeout(() => {
console.log('Shutdown timed out, forcing exit');
process.exit(1);
}, this._config.shutdownGracePeriodSeconds * 1000); // Default is 30 seconds
forceExit.unref();
this._logger.info('Closing HTTP server...');
await new Promise<void>((resolve, reject) => {
try {
this._server.close(() => resolve());
} catch (error) {
reject(error);
}
});
this._logger.info('Closing WebSocket servers...');
await this._networkConnection?.shutdown();
this._logger.info('Closing RabbitMQ connections...');
await this._channelManager?.closeAll();
await this._connectionManager?.close();
this._logger.info('Closing PostgreSQL connections...');
await this._sequelizeInstance.connectionManager.close();
this._logger.info('Shutdown complete');
process.exitCode = 0;
}
async run(): Promise<void> {
try {
await this.initialize();
await this._syncWebsocketConfig();
await this._server
.listen({
host: this.host,
port: this.port,
})
.then((address) => {
this._logger?.info(`Server listening at ${address}`);
})
.catch((error) => {
this._logger?.error(error);
process.exit(1);
});
// TODO Push config to microservices
} catch (error) {
await Promise.reject(error);
}
}
protected async _syncWebsocketConfig() {
for (const websocketServerConfig of this._config.util.networkConnection.websocketServers) {
await this._repositoryStore.serverNetworkProfileRepository.upsertServerNetworkProfile(
websocketServerConfig,
this._config.maxCallLengthSeconds,
);
}
}
async initMessageBrokerConnection(): Promise<any> {
const url = this._config.util.messageBroker.amqp?.url;
if (!url) {
throw new Error('RabbitMQ URL is not configured');
}
this._connectionManager = new RabbitMQConnectionManager(this._config.maxReconnectDelay, url);
this._channelManager = new RabbitMQChannelManager(this._connectionManager);
await this._connectionManager.connect();
}
protected _createSender(): BrokerAwareMessageSender {
const exchange = this._config.util.messageBroker.amqp?.exchange;
if (!exchange) {
throw new Error('RabbitMQ exchange is not configured');
}
if (!this._connectionManager || !this._channelManager) {
throw new Error('RabbitMQ connection or channel manager is not initialized');
}
return new BrokerAwareMessageSender(
new RabbitMqSender(exchange, this._connectionManager, this._channelManager, this._logger),
this._connectionManager,
this._config.maxCallLengthSeconds,
this._logger,
);
}
protected _createHandler(): IMessageHandler {
return new RabbitMqReceiver(this._config, this._channelManager!, this._logger);
}
protected initHealthCheck() {
const respond = (reply: FastifyReply, result: HealthCheckResult) =>
reply
.code(result.status === 'pass' ? 200 : 503)
.header('Content-Type', 'application/health+json')
.send(result);
const liveness = async (_req: any, reply: FastifyReply) =>
respond(
reply,
this._healthCheckService
? this._healthCheckService.checkLiveness()
: { status: 'pass', checks: {} },
);
const readiness = async (_req: any, reply: FastifyReply) => {
if (!this._healthCheckService) {
return respond(reply, {
status: 'fail',
checks: { init: { status: 'fail', error: 'not yet initialized' } },
});
}
return respond(reply, await this._healthCheckService.checkReadiness());
};
this._server.get('/health', liveness);
this._server.get('/health/live', liveness);
this._server.get('/health/ready', readiness);
}
protected initHealthCheckService() {
this._healthCheckService = new HealthCheckService(
this._networkConnection,
this._connectionManager,
this._cache,
this._sequelizeInstance,
this._config.notReadyThresholdSeconds,
this._logger,
);
}
protected initLogger() {
const isCloud = process.env.DEPLOYMENT_TARGET === 'cloud';
const loggerSettings = {
name: 'CitrineOS Logger',
minLevel: this._config.logLevel,
hideLogPositionForProduction: this._config.env === 'production',
type: isCloud ? ('json' as const) : ('pretty' as const),
};
return new Logger<ILogObj>(loggerSettings);
}
protected async initDb() {
await sequelize.DefaultSequelizeInstance.initializeSequelize();
if (process.env.CITRINEOS_USE_DRIZZLE_SECURITY_EVENT === 'true') {
await DefaultDrizzleInstance.initialize();
}
}
protected initCache(cache?: ICache): ICache {
if (cache) return cache;
if (this._config.util.cache.redis) {
const redisClientOptions: RedisClientOptions =
'url' in this._config.util.cache.redis
? { url: this._config.util.cache.redis.url }
: {
socket: {
host: this._config.util.cache.redis.host,
port: this._config.util.cache.redis.port,
},
};
return new RedisCache(redisClientOptions, this._logger);
}
return new MemoryCache();
}
protected async initSwagger() {
if (this._config.util.swagger) {
await initSwagger(this._config, this._server);
}
}
protected registerAjv() {
// todo type schema instead of any
const fastifySchemaCompiler: FastifySchemaCompiler<any> = (
routeSchema: FastifyRouteSchemaDef<any>,
) => this._ajv?.compile(routeSchema.schema) as FastifyValidationResult;
this._server.setValidatorCompiler(fastifySchemaCompiler);
}
protected registerApiAuth() {
const authProvider = this.initApiAuthProvider();
this._server.register(apiAuthPluginFp, {
provider: authProvider,
options: {
excludedRoutes: [
'/health',
'/health/live',
'/health/ready',
'/docs', // API documentation
],
debug: this._config.logLevel <= 2, // Enable debug logs in dev mode
},
logger: this._logger,
});
}
protected initNetworkConnection() {
this._authenticator = new Authenticator(
new UnknownStationFilter(
new sequelize.SequelizeLocationRepository(this._config, this._logger),
this._logger,
),
new ConnectedStationFilter(this._cache, this._logger),
new NetworkProfileFilter(
new sequelize.SequelizeDeviceModelRepository(this._config, this._logger),
this._logger,
),
new BasicAuthenticationFilter(
new sequelize.SequelizeDeviceModelRepository(this._config, this._logger),
this._logger,
),
this._logger,
);
const webhookDispatcher = new WebhookDispatcher(
this._repositoryStore.ocppMessageRepository,
this._repositoryStore.subscriptionRepository,
this._logger,
);
const routerSender = this._createSender();
this._router = new MessageRouterImpl(
this._config,
this._cache,
routerSender,
this._createHandler(),
webhookDispatcher,
async (_identifier: string, _message: string) => {},
this._logger,
this._ocppValidator,
this._repositoryStore.locationRepository,
);
this._networkConnection = new WebsocketNetworkConnection(
this._config,
this._cache,
this._authenticator,
this._router,
this._fileStorage,
this._logger,
this._repositoryStore.locationRepository.doesChargingStationExistByStationId.bind(
this._repositoryStore.locationRepository,
),
async (tenantId: number) => {
const tenant = await this._repositoryStore.tenantRepository.readByKey(tenantId, tenantId);
return tenant?.maxChargingStations ?? null;
},
this._connectionManager,
);
routerSender.onCallTimeout = (ocppConnectionName, tenantId) =>
this._networkConnection!.disconnect(tenantId, ocppConnectionName).then(() => undefined);
this._router.networkHook = this._networkConnection.bindNetworkHook();
this.apis.push(
new AdminApi(
this._router,
this._networkConnection,
this._server,
this._config,
this._logger,
this._repositoryStore.subscriptionRepository,
this._repositoryStore.serverNetworkProfileRepository,
),
);
}
protected async initHandlersAndAddModule(module: AbstractModule) {
await module.initHandlers();
this.modules.push(module);
}
protected async initAllModules() {
if (this._config.modules.certificates) {
await this.initCertificatesModule();
}
if (this._config.modules.configuration) {
await this.initConfigurationModule();
}
if (this._config.modules.evdriver) {
await this.initEVDriverModule();
}
if (this._config.modules.monitoring) {
await this.initMonitoringModule();
}
if (this._config.modules.reporting) {
await this.initReportingModule();
}
if (this._config.modules.smartcharging) {
await this.initSmartChargingModule();
}
if (this._config.modules.transactions) {
await this.initTransactionsModule();
}
if (this._config.modules.tenant) {
await this.initTenantModule();
}
}
protected initApiAuthProvider(): IApiAuthProvider {
this._logger.info('Initializing API authentication provider,', this._config.util.authProvider);
if (this._config.util.authProvider.oidc) {
return new OIDCAuthProvider(this._config.util.authProvider.oidc, this._logger);
} else if (this._config.util.authProvider.localByPass) {
return new LocalBypassAuthProvider(this._logger);
} else {
throw new Error('No valid API authentication provider configured');
}
}
protected async initCertificatesModule() {
const module = new CertificatesModule(
this._config,
this._cache,
this._createSender(),
this._createHandler(),
this._fileStorage,
this._networkConnection!,
this._logger,
this._ocppValidator,
this._repositoryStore.deviceModelRepository,
this._repositoryStore.certificateRepository,
this._repositoryStore.installedCertificateRepository,
this._repositoryStore.installCertificateAttemptRepository,
this._repositoryStore.deleteCertificateAttemptRepository,
this._repositoryStore.ocppMessageRepository,
);
await this.initHandlersAndAddModule(module);
this.apis.push(
new CertificatesOcpp2Api(module, this._server, OCPPVersion.OCPP2_0_1, this._logger),
new CertificatesOcpp2Api(module, this._server, OCPPVersion.OCPP2_1, this._logger),
new CertificatesDataApi(
module,
this._server,
this._fileStorage,
this._config.util.networkConnection.websocketServers,
this._logger,
),
);
}
protected async initConfigurationModule() {
const module = new ConfigurationModule(
this._config,
this._cache,
this._createSender(),
this._createHandler(),
this._logger,
this._ocppValidator,
this._repositoryStore.bootRepository,
this._repositoryStore.deviceModelRepository,
this._repositoryStore.messageInfoRepository,
this._repositoryStore.locationRepository,
this._repositoryStore.changeConfigurationRepository,
this._repositoryStore.ocppMessageRepository,
this._idGenerator,
);
await this.initHandlersAndAddModule(module);
this.apis.push(
new ConfigurationOcpp2Api(module, this._server, OCPPVersion.OCPP2_0_1, this._logger),
new ConfigurationOcpp2Api(module, this._server, OCPPVersion.OCPP2_1, this._logger),
new ConfigurationOcpp16Api(module, this._server, this._logger),
new ConfigurationDataApi(module, this._server, this._logger),
);
}
protected async initEVDriverModule() {
const module = new EVDriverModule(
this._config,
this._cache,
this._createSender(),
this._createHandler(),
this._logger,
this._ocppValidator,
this._repositoryStore.authorizationRepository,
this._repositoryStore.localAuthListRepository,
this._repositoryStore.deviceModelRepository,
this._repositoryStore.tariffRepository,
this._repositoryStore.transactionEventRepository,
this._repositoryStore.chargingProfileRepository,
this._repositoryStore.reservationRepository,
this._repositoryStore.ocppMessageRepository,
this._repositoryStore.locationRepository,
this._certificateAuthorityService,
this._realTimeAuthorizer,
[],
this._idGenerator,
);
await this.initHandlersAndAddModule(module);
this.apis.push(
new EVDriverOcpp2Api(module, this._server, OCPPVersion.OCPP2_0_1, this._logger),
new EVDriverOcpp2Api(module, this._server, OCPPVersion.OCPP2_1, this._logger),
new EVDriverOcpp16Api(module, this._server, this._logger),
new EVDriverDataApi(module, this._server, this._logger),
);
}
protected async initMonitoringModule() {
const module = new MonitoringModule(
this._config,
this._cache,
this._createSender(),
this._createHandler(),
this._logger,
this._ocppValidator,
this._repositoryStore.deviceModelRepository,
this._repositoryStore.variableMonitoringRepository,
this._repositoryStore.ocppMessageRepository,
this._idGenerator,
);
await this.initHandlersAndAddModule(module);
this.apis.push(
new MonitoringOcpp2Api(module, this._server, OCPPVersion.OCPP2_0_1, this._logger),
new MonitoringOcpp2Api(module, this._server, OCPPVersion.OCPP2_1, this._logger),
new MonitoringDataApi(module, this._server, this._logger),
);
}
protected async initReportingModule() {
const module = new ReportingModule(
this._config,
this._cache,
this._createSender(),
this._createHandler(),
this._logger,
this._ocppValidator,
this._repositoryStore.deviceModelRepository,
this._repositoryStore.securityEventRepository,
this._repositoryStore.variableMonitoringRepository,
);
await this.initHandlersAndAddModule(module);
this.apis.push(
new ReportingOcpp2Api(module, this._server, OCPPVersion.OCPP2_0_1, this._logger),
new ReportingOcpp2Api(module, this._server, OCPPVersion.OCPP2_1, this._logger),
new ReportingOcpp16Api(module, this._server, this._logger),
);
}
protected async initSmartChargingModule() {
const module = new SmartChargingModule(
this._config,
this._cache,
this._createSender(),
this._createHandler(),
this._logger,
this._ocppValidator,
this._repositoryStore.transactionEventRepository,
this._repositoryStore.deviceModelRepository,
this._repositoryStore.chargingProfileRepository,
this._smartChargingService,
this._idGenerator,
);
await this.initHandlersAndAddModule(module);
this.apis.push(
new SmartChargingOcpp2Api(module, this._server, OCPPVersion.OCPP2_0_1, this._logger),
new SmartChargingOcpp2Api(module, this._server, OCPPVersion.OCPP2_1, this._logger),
new SmartChargingOcpp16Api(module, this._server, this._logger),
);
}
protected async initTransactionsModule() {
const module = new TransactionsModule(
this._config,
this._cache,
this._fileStorage,
this._createSender(),
this._createHandler(),
this._logger,
this._ocppValidator,
this._repositoryStore.transactionEventRepository,
this._repositoryStore.authorizationRepository,
this._repositoryStore.deviceModelRepository,
this._repositoryStore.componentRepository,
this._repositoryStore.locationRepository,
this._repositoryStore.tariffRepository,
this._repositoryStore.reservationRepository,
this._repositoryStore.ocppMessageRepository,
this._realTimeAuthorizer,
);
await this.initHandlersAndAddModule(module);
this.apis.push(
new TransactionsOcpp2Api(module, this._server, OCPPVersion.OCPP2_0_1, this._logger),
new TransactionsOcpp2Api(module, this._server, OCPPVersion.OCPP2_1, this._logger),
new TransactionsDataApi(module, this._server, this._logger),
);
}
protected async initTenantModule() {
const module = new TenantModule(
this._config,
this._cache,
this._createSender(),
this._createHandler(),
this._logger,
this._ocppValidator,
this._repositoryStore.tenantRepository,
);
await this.initHandlersAndAddModule(module);
this.apis.push(new TenantDataApi(module, this._server, this._logger));
this._logger.info('Tenant module initialized');
}
protected async initModule(eventGroup = this.eventGroup) {
this._logger.info(`Initializing module: ${this.appName}`);
switch (eventGroup) {
case EventGroup.Certificates:
await this.initCertificatesModule();
break;
case EventGroup.Configuration:
await this.initConfigurationModule();
break;
case EventGroup.EVDriver:
await this.initEVDriverModule();
break;
case EventGroup.Monitoring:
await this.initMonitoringModule();
break;
case EventGroup.Reporting:
await this.initReportingModule();
break;
case EventGroup.SmartCharging:
await this.initSmartChargingModule();
break;
case EventGroup.Transactions:
await this.initTransactionsModule();
break;
case EventGroup.Tenant:
await this.initTenantModule();
break;
default:
throw new Error('Unhandled module type: ' + this.appName);
}
}
protected async initSystem() {
this.eventGroup = eventGroupFromString(this.appName);
this.host = this._config.centralSystem.host;
this.port = this._config.centralSystem.port;
if (this.eventGroup === EventGroup.All) {
this._logger.info('Initializing in ALL mode: WebSocket server and all modules');
this.initNetworkConnection();
await this.initAllModules();
} else if (this.eventGroup === EventGroup.Router) {
this._logger.info('Initializing in ROUTER mode: WebSocket server, no modules');
// OCPP Router only: WebSocket server, no modules
this.initNetworkConnection();
} else if (this.eventGroup === EventGroup.Modules) {
// All modules, no WebSocket server
this._logger.info('Initializing in MODULES mode: all modules without NetworkConnection');
await this.initAllModules();
} else {
await this.initModule();
}
}
protected initRepositoryStore() {
this._sequelizeInstance = sequelize.DefaultSequelizeInstance.getInstance(
this._config,
this._logger,
);
this._repositoryStore = new RepositoryStore(
this._config,
this._logger,
this._sequelizeInstance,
);
}
protected initIdGenerator() {
this._idGenerator = new IdGenerator(this._repositoryStore.chargingStationSequenceRepository);
}
protected initCertificateAuthorityService() {
this._certificateAuthorityService = new CertificateAuthorityService(
this._config,
this._cache,
this._logger,
undefined,
undefined,
this._fileStorage,
);
}
protected initSmartChargingService() {
this._smartChargingService = new InternalSmartCharging(
this._repositoryStore.chargingProfileRepository,
);
}
protected initRealTimeAuthorizer() {
this._realTimeAuthorizer = new RealTimeAuthorizer(
this._repositoryStore.locationRepository,
this._config,
this._logger,
);
}
}

View File

@@ -0,0 +1,80 @@
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
//
// SPDX-License-Identifier: Apache-2.0
import type { BootstrapConfig, ConfigStore, SystemConfig } from '@citrineos/base';
import { ConfigStoreFactory, defineConfig } from '@citrineos/base';
import { GcpCloudStorage, LocalStorage, S3Storage } from '@citrineos/core';
/**
* Helper function to create the appropriate ConfigStore based on bootstrap config
*/
function createConfigStore(bootstrapConfig: BootstrapConfig): ConfigStore {
switch (bootstrapConfig.fileAccess.type) {
case 'local':
return new LocalStorage(
bootstrapConfig.fileAccess.local!.defaultFilePath,
bootstrapConfig.configFileName,
bootstrapConfig.configDir,
);
case 's3':
return new S3Storage(
bootstrapConfig.fileAccess.s3!,
bootstrapConfig.configFileName,
bootstrapConfig.configDir,
);
case 'gcp':
return new GcpCloudStorage(
bootstrapConfig.fileAccess.gcp!,
bootstrapConfig.configFileName,
bootstrapConfig.configDir!,
);
default:
throw new Error(`Unsupported file access type: ${bootstrapConfig.fileAccess.type}`);
}
}
/**
* Loads the system configuration
* 1. Loads bootstrap config from environment variables
* 2. Uses bootstrap config to create a ConfigStore
* 3. Loads full config from storage or creates default if none exists
* 4. Applies environment variable overrides for secrets and other values
* 5. Validates the final config
* @param defaultConfig Optional default config to use if no config exists in storage
* @returns Promise resolving to the validated SystemConfig
*/
export async function loadSystemConfig(
bootstrapConfig: BootstrapConfig,
defaultConfig?: SystemConfig,
): Promise<SystemConfig> {
try {
const configStore = createConfigStore(bootstrapConfig);
ConfigStoreFactory.setConfigStore(configStore);
console.log('Config store initialized');
let config: SystemConfig | null =
process.env.CONFIG_CITRINEOS_WIPE_FILE_ON_START?.toLowerCase() === 'true'
? null
: await configStore.fetchConfig();
if (!config) {
if (!defaultConfig) {
throw new Error('No configuration found in storage and no default config provided');
}
console.warn('No config found in storage. Creating default config...');
config = defaultConfig;
await configStore.saveConfig(config);
console.log('Default config saved to storage');
} else {
console.log('Configuration loaded from storage');
}
const validatedConfig = defineConfig(config);
return validatedConfig;
} catch (error) {
console.error('Failed to load system configuration:', error);
throw error;
}
}

View File

@@ -0,0 +1,304 @@
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
//
// SPDX-License-Identifier: Apache-2.0
import {
DEFAULT_TENANT_ID,
defineConfig,
HUBJECT_DEFAULT_BASEURL,
HUBJECT_DEFAULT_CLIENTID,
HUBJECT_DEFAULT_CLIENTSECRET,
HUBJECT_DEFAULT_TOKENURL,
OCPP1_6,
OCPP2_0_1,
OCPP_CallAction,
OCPP_VERSION_LIST,
} from '@citrineos/base';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
export function createDockerConfig() {
return defineConfig({
env: 'development',
centralSystem: {
host: '0.0.0.0',
port: 8080,
},
modules: {
certificates: {
endpointPrefix: '/certificates',
responses: [
OCPP_CallAction.CertificateSigned,
OCPP_CallAction.DeleteCertificate,
OCPP_CallAction.GetInstalledCertificateIds,
OCPP_CallAction.InstallCertificate,
],
requests: [
OCPP_CallAction.Get15118EVCertificate,
OCPP_CallAction.GetCertificateStatus,
OCPP_CallAction.SignCertificate,
],
},
configuration: {
responses: [
OCPP_CallAction.ChangeAvailability,
OCPP_CallAction.ClearDisplayMessage,
OCPP_CallAction.DataTransfer,
OCPP_CallAction.GetDisplayMessages,
OCPP_CallAction.PublishFirmware,
OCPP_CallAction.Reset,
OCPP_CallAction.SetDisplayMessage,
OCPP_CallAction.SetNetworkProfile,
OCPP_CallAction.TriggerMessage,
OCPP_CallAction.UnpublishFirmware,
OCPP_CallAction.UpdateFirmware,
OCPP_CallAction.ChangeConfiguration,
OCPP_CallAction.GetConfiguration,
],
requests: [
OCPP_CallAction.BootNotification,
OCPP_CallAction.DataTransfer,
OCPP_CallAction.FirmwareStatusNotification,
OCPP_CallAction.Heartbeat,
OCPP_CallAction.NotifyDisplayMessages,
OCPP_CallAction.PublishFirmwareStatusNotification,
],
heartbeatInterval: 60,
bootRetryInterval: 15,
ocpp2_0_1: {
unknownChargerStatus: OCPP2_0_1.RegistrationStatusEnumType.Accepted,
getBaseReportOnPending: true,
bootWithRejectedVariables: true,
autoAccept: true,
},
ocpp1_6: {
unknownChargerStatus: OCPP1_6.BootNotificationResponseStatus.Accepted,
},
endpointPrefix: '/configuration',
},
evdriver: {
endpointPrefix: '/evdriver',
enableGetChargingProfilesOnStartTransaction: true,
responses: [
OCPP_CallAction.CancelReservation,
OCPP_CallAction.ClearCache,
OCPP_CallAction.GetLocalListVersion,
OCPP_CallAction.RequestStartTransaction,
OCPP_CallAction.RequestStopTransaction,
OCPP_CallAction.ReserveNow,
OCPP_CallAction.SendLocalList,
OCPP_CallAction.UnlockConnector,
OCPP_CallAction.RemoteStopTransaction,
OCPP_CallAction.RemoteStartTransaction,
OCPP_CallAction.NotifyWebPaymentStarted,
],
requests: [
OCPP_CallAction.Authorize,
OCPP_CallAction.ReservationStatusUpdate,
OCPP_CallAction.VatNumberValidation,
],
},
monitoring: {
endpointPrefix: '/monitoring',
responses: [
OCPP_CallAction.ClearVariableMonitoring,
OCPP_CallAction.GetVariables,
OCPP_CallAction.SetMonitoringBase,
OCPP_CallAction.SetMonitoringLevel,
OCPP_CallAction.GetMonitoringReport,
OCPP_CallAction.SetVariableMonitoring,
OCPP_CallAction.SetVariables,
],
requests: [OCPP_CallAction.NotifyEvent],
},
reporting: {
endpointPrefix: '/reporting',
responses: [
OCPP_CallAction.CustomerInformation,
OCPP_CallAction.GetLog,
OCPP_CallAction.GetReport,
OCPP_CallAction.GetBaseReport,
OCPP_CallAction.GetMonitoringReport,
],
requests: [
OCPP_CallAction.LogStatusNotification,
OCPP_CallAction.NotifyCustomerInformation,
OCPP_CallAction.NotifyReport,
OCPP_CallAction.SecurityEventNotification,
OCPP_CallAction.NotifyMonitoringReport,
],
},
smartcharging: {
endpointPrefix: '/smartcharging',
responses: [
OCPP_CallAction.ClearChargingProfile,
OCPP_CallAction.GetChargingProfiles,
OCPP_CallAction.GetCompositeSchedule,
OCPP_CallAction.SetChargingProfile,
],
requests: [
OCPP_CallAction.ClearedChargingLimit,
OCPP_CallAction.NotifyChargingLimit,
OCPP_CallAction.NotifyEVChargingNeeds,
OCPP_CallAction.NotifyEVChargingSchedule,
OCPP_CallAction.ReportChargingProfiles,
],
},
tenant: {
endpointPrefix: '/tenant',
responses: [],
requests: [],
},
transactions: {
endpointPrefix: '/transactions',
costUpdatedInterval: 60,
responses: [
OCPP_CallAction.CostUpdated,
OCPP_CallAction.GetTransactionStatus,
OCPP_CallAction.SetDefaultTariff,
],
requests: [
OCPP_CallAction.MeterValues,
OCPP_CallAction.StatusNotification,
OCPP_CallAction.TransactionEvent,
OCPP_CallAction.StatusNotification,
OCPP_CallAction.StartTransaction,
OCPP_CallAction.StopTransaction,
OCPP_CallAction.NotifySettlement,
],
},
},
util: {
cache: {
memory: true,
},
messageBroker: {
amqp: {
url: 'amqp://guest:guest@amqp-broker:5672',
exchange: 'citrineos',
},
},
authProvider: {
localByPass: true,
},
swagger: {
path: '/docs',
logoPath: path.resolve(path.dirname(__filename), '../../assets/logo.png'),
exposeData: true,
exposeMessage: true,
},
networkConnection: {
websocketServers: [
{
id: '0',
securityProfile: 0,
allowUnknownChargingStations: true,
pingInterval: 60,
host: '0.0.0.0',
port: 8081,
protocols: OCPP_VERSION_LIST,
tenantId: DEFAULT_TENANT_ID,
dynamicTenantResolution: false,
//Uncomment to debug or use a specific port
//forceProtocol: OCPPVersion.OCPP2_0_1
},
{
id: '1',
securityProfile: 1,
allowUnknownChargingStations: false,
pingInterval: 60,
host: '0.0.0.0',
port: 8082,
protocols: OCPP_VERSION_LIST,
tenantId: DEFAULT_TENANT_ID,
dynamicTenantResolution: false,
},
{
id: '2',
securityProfile: 2,
allowUnknownChargingStations: false,
pingInterval: 60,
host: '0.0.0.0',
port: 8443,
protocols: OCPP_VERSION_LIST,
tlsKeyFilePath: path.resolve(
path.dirname(__filename),
'../../assets/certificates/leafKey.pem',
),
tlsCertificateChainFilePath: path.resolve(
path.dirname(__filename),
'../../assets/certificates/certChain.pem',
),
rootCACertificateFilePath: path.resolve(
path.dirname(__filename),
'../../assets/certificates/rootCertificate.pem',
),
tenantId: DEFAULT_TENANT_ID,
dynamicTenantResolution: false,
},
{
id: '3',
securityProfile: 3,
allowUnknownChargingStations: false,
pingInterval: 60,
host: '0.0.0.0',
port: 8444,
protocols: OCPP_VERSION_LIST,
tlsKeyFilePath: path.resolve(
path.dirname(__filename),
'../../assets/certificates/leafKey.pem',
),
tlsCertificateChainFilePath: path.resolve(
path.dirname(__filename),
'../../assets/certificates/certChain.pem',
),
mtlsCertificateAuthorityKeyFilePath: path.resolve(
path.dirname(__filename),
'../../assets/certificates/subCAKey.pem',
),
rootCACertificateFilePath: path.resolve(
path.dirname(__filename),
'../../assets/certificates/rootCertificate.pem',
),
tenantId: DEFAULT_TENANT_ID,
dynamicTenantResolution: false,
},
],
},
certificateAuthority: {
v2gCA: {
name: 'hubject',
hubject: {
baseUrl: HUBJECT_DEFAULT_BASEURL,
tokenUrl: HUBJECT_DEFAULT_TOKENURL,
clientId: HUBJECT_DEFAULT_CLIENTID,
clientSecret: HUBJECT_DEFAULT_CLIENTSECRET,
},
},
chargingStationCA: {
name: 'acme',
acme: {
env: 'staging',
accountKeyFilePath: path.resolve(
path.dirname(__filename),
'../../assets/certificates/acme_account_key.pem',
),
email: 'test@citrineos.com',
},
},
},
},
logLevel: 2, // debug
maxCallLengthSeconds: 20,
maxCachingSeconds: 30,
ocpiServer: {
host: '0.0.0.0',
port: 8085,
},
userPreferences: {
// None by default
},
});
}

View File

@@ -0,0 +1,254 @@
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
//
// SPDX-License-Identifier: Apache-2.0
import {
DEFAULT_TENANT_ID,
defineConfig,
HUBJECT_DEFAULT_BASEURL,
HUBJECT_DEFAULT_CLIENTID,
HUBJECT_DEFAULT_CLIENTSECRET,
HUBJECT_DEFAULT_TOKENURL,
OCPP1_6,
OCPP2_0_1,
OCPP_CallAction,
OCPP_VERSION_LIST,
} from '@citrineos/base';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
export function createLocalConfig() {
return defineConfig({
env: 'development',
centralSystem: {
host: '::',
port: 8080,
},
modules: {
certificates: {
endpointPrefix: '/certificates',
responses: [
OCPP_CallAction.CertificateSigned,
OCPP_CallAction.DeleteCertificate,
OCPP_CallAction.GetInstalledCertificateIds,
OCPP_CallAction.InstallCertificate,
],
requests: [
OCPP_CallAction.Get15118EVCertificate,
OCPP_CallAction.GetCertificateStatus,
OCPP_CallAction.SignCertificate,
],
},
configuration: {
responses: [
OCPP_CallAction.ChangeAvailability,
OCPP_CallAction.ClearDisplayMessage,
OCPP_CallAction.DataTransfer,
OCPP_CallAction.GetDisplayMessages,
OCPP_CallAction.PublishFirmware,
OCPP_CallAction.Reset,
OCPP_CallAction.SetDisplayMessage,
OCPP_CallAction.SetNetworkProfile,
OCPP_CallAction.TriggerMessage,
OCPP_CallAction.UnpublishFirmware,
OCPP_CallAction.UpdateFirmware,
OCPP_CallAction.ChangeConfiguration,
OCPP_CallAction.GetConfiguration,
],
requests: [
OCPP_CallAction.BootNotification,
OCPP_CallAction.DataTransfer,
OCPP_CallAction.FirmwareStatusNotification,
OCPP_CallAction.Heartbeat,
OCPP_CallAction.NotifyDisplayMessages,
OCPP_CallAction.PublishFirmwareStatusNotification,
],
heartbeatInterval: 60,
bootRetryInterval: 15,
ocpp2_0_1: {
unknownChargerStatus: OCPP2_0_1.RegistrationStatusEnumType.Accepted,
getBaseReportOnPending: true,
bootWithRejectedVariables: true,
autoAccept: true,
},
ocpp1_6: {
unknownChargerStatus: OCPP1_6.BootNotificationResponseStatus.Accepted,
},
endpointPrefix: '/configuration',
},
evdriver: {
endpointPrefix: '/evdriver',
enableGetChargingProfilesOnStartTransaction: true,
responses: [
OCPP_CallAction.CancelReservation,
OCPP_CallAction.ClearCache,
OCPP_CallAction.GetLocalListVersion,
OCPP_CallAction.RequestStartTransaction,
OCPP_CallAction.RequestStopTransaction,
OCPP_CallAction.ReserveNow,
OCPP_CallAction.SendLocalList,
OCPP_CallAction.UnlockConnector,
OCPP_CallAction.RemoteStopTransaction,
OCPP_CallAction.RemoteStartTransaction,
OCPP_CallAction.NotifyWebPaymentStarted,
],
requests: [
OCPP_CallAction.Authorize,
OCPP_CallAction.ReservationStatusUpdate,
OCPP_CallAction.VatNumberValidation,
],
},
monitoring: {
endpointPrefix: '/monitoring',
responses: [
OCPP_CallAction.ClearVariableMonitoring,
OCPP_CallAction.GetVariables,
OCPP_CallAction.SetMonitoringBase,
OCPP_CallAction.SetMonitoringLevel,
OCPP_CallAction.GetMonitoringReport,
OCPP_CallAction.SetVariableMonitoring,
OCPP_CallAction.SetVariables,
],
requests: [OCPP_CallAction.NotifyEvent],
},
reporting: {
endpointPrefix: '/reporting',
responses: [
OCPP_CallAction.CustomerInformation,
OCPP_CallAction.GetLog,
OCPP_CallAction.GetReport,
OCPP_CallAction.GetBaseReport,
OCPP_CallAction.GetMonitoringReport,
OCPP_CallAction.GetDiagnostics,
],
requests: [
OCPP_CallAction.LogStatusNotification,
OCPP_CallAction.NotifyCustomerInformation,
OCPP_CallAction.NotifyReport,
OCPP_CallAction.SecurityEventNotification,
OCPP_CallAction.NotifyMonitoringReport,
OCPP_CallAction.DiagnosticsStatusNotification,
],
},
smartcharging: {
endpointPrefix: '/smartcharging',
responses: [
OCPP_CallAction.ClearChargingProfile,
OCPP_CallAction.GetChargingProfiles,
OCPP_CallAction.GetCompositeSchedule,
OCPP_CallAction.SetChargingProfile,
],
requests: [
OCPP_CallAction.ClearedChargingLimit,
OCPP_CallAction.NotifyChargingLimit,
OCPP_CallAction.NotifyEVChargingNeeds,
OCPP_CallAction.NotifyEVChargingSchedule,
OCPP_CallAction.ReportChargingProfiles,
],
},
tenant: {
endpointPrefix: '/tenant',
responses: [],
requests: [],
},
transactions: {
endpointPrefix: '/transactions',
costUpdatedInterval: 60,
responses: [
OCPP_CallAction.CostUpdated,
OCPP_CallAction.GetTransactionStatus,
OCPP_CallAction.SetDefaultTariff,
],
requests: [
OCPP_CallAction.MeterValues,
OCPP_CallAction.StatusNotification,
OCPP_CallAction.TransactionEvent,
OCPP_CallAction.MeterValues,
OCPP_CallAction.StartTransaction,
OCPP_CallAction.StopTransaction,
OCPP_CallAction.NotifySettlement,
],
},
},
util: {
cache: {
memory: true,
},
messageBroker: {
amqp: {
url: 'amqp://guest:guest@localhost:5672',
exchange: 'citrineos',
},
},
authProvider: {
localByPass: true,
},
swagger: {
path: '/docs',
logoPath: path.resolve(path.dirname(__filename), '../../assets/logo.png'),
exposeData: true,
exposeMessage: true,
},
networkConnection: {
websocketServers: [
{
id: '0',
securityProfile: 0,
allowUnknownChargingStations: true,
pingInterval: 60,
host: '0.0.0.0',
port: 8081,
protocols: OCPP_VERSION_LIST,
tenantId: DEFAULT_TENANT_ID,
dynamicTenantResolution: false,
},
{
id: '1',
securityProfile: 1,
allowUnknownChargingStations: false,
pingInterval: 60,
host: '0.0.0.0',
port: 8082,
protocols: OCPP_VERSION_LIST,
tenantId: DEFAULT_TENANT_ID,
dynamicTenantResolution: false,
},
],
},
certificateAuthority: {
v2gCA: {
name: 'hubject',
hubject: {
baseUrl: HUBJECT_DEFAULT_BASEURL,
tokenUrl: HUBJECT_DEFAULT_TOKENURL,
clientId: HUBJECT_DEFAULT_CLIENTID,
clientSecret: HUBJECT_DEFAULT_CLIENTSECRET,
},
},
chargingStationCA: {
name: 'acme',
acme: {
env: 'staging',
accountKeyFilePath: path.resolve(
path.dirname(__filename),
'../../assets/certificates/acme_account_key.pem',
),
email: 'test@citrineos.com',
},
},
},
},
logLevel: 2, // debug
maxCallLengthSeconds: 30,
maxCachingSeconds: 30,
ocpiServer: {
host: '0.0.0.0',
port: 8085,
},
userPreferences: {
// None by default
},
});
}

View File

@@ -0,0 +1,269 @@
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
//
// SPDX-License-Identifier: Apache-2.0
import {
DEFAULT_TENANT_ID,
defineConfig,
HUBJECT_DEFAULT_BASEURL,
HUBJECT_DEFAULT_CLIENTID,
HUBJECT_DEFAULT_CLIENTSECRET,
HUBJECT_DEFAULT_TOKENURL,
OCPP1_6,
OCPP2_0_1,
OCPP2_1,
OCPP_CallAction,
OCPP_VERSION_LIST,
} from '@citrineos/base';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
export function createDockerConfig() {
return defineConfig({
env: 'development',
centralSystem: {
host: '0.0.0.0',
port: 8080,
},
modules: {
certificates: {
endpointPrefix: 'certificates',
host: '0.0.0.0',
port: 8083,
responses: [
OCPP_CallAction.CertificateSigned,
OCPP_CallAction.DeleteCertificate,
OCPP_CallAction.GetInstalledCertificateIds,
OCPP_CallAction.InstallCertificate,
],
requests: [
OCPP_CallAction.Get15118EVCertificate,
OCPP_CallAction.GetCertificateStatus,
OCPP_CallAction.SignCertificate,
],
},
configuration: {
responses: [
OCPP_CallAction.ChangeAvailability,
OCPP_CallAction.ClearDisplayMessage,
OCPP_CallAction.DataTransfer,
OCPP_CallAction.GetDisplayMessages,
OCPP_CallAction.PublishFirmware,
OCPP_CallAction.Reset,
OCPP_CallAction.SetDisplayMessage,
OCPP_CallAction.SetNetworkProfile,
OCPP_CallAction.TriggerMessage,
OCPP_CallAction.UnpublishFirmware,
OCPP_CallAction.UpdateFirmware,
OCPP_CallAction.ChangeConfiguration,
OCPP_CallAction.GetConfiguration,
],
requests: [
OCPP_CallAction.BootNotification,
OCPP_CallAction.DataTransfer,
OCPP_CallAction.FirmwareStatusNotification,
OCPP_CallAction.Heartbeat,
OCPP_CallAction.NotifyDisplayMessages,
OCPP_CallAction.PublishFirmwareStatusNotification,
],
heartbeatInterval: 60,
bootRetryInterval: 15,
ocpp2_0_1: {
unknownChargerStatus: OCPP2_0_1.RegistrationStatusEnumType.Accepted,
getBaseReportOnPending: true,
bootWithRejectedVariables: true,
autoAccept: true,
},
ocpp2_1: {
unknownChargerStatus: OCPP2_1.RegistrationStatusEnumType.Accepted,
getBaseReportOnPending: true,
bootWithRejectedVariables: true,
autoAccept: true,
},
ocpp1_6: {
unknownChargerStatus: OCPP1_6.BootNotificationResponseStatus.Accepted,
},
endpointPrefix: 'configuration',
host: '0.0.0.0',
port: 8084,
},
evdriver: {
endpointPrefix: 'evdriver',
host: '0.0.0.0',
port: 8085,
enableGetChargingProfilesOnStartTransaction: true,
responses: [
OCPP_CallAction.CancelReservation,
OCPP_CallAction.ClearCache,
OCPP_CallAction.GetLocalListVersion,
OCPP_CallAction.RequestStartTransaction,
OCPP_CallAction.RequestStopTransaction,
OCPP_CallAction.ReserveNow,
OCPP_CallAction.SendLocalList,
OCPP_CallAction.UnlockConnector,
OCPP_CallAction.RemoteStopTransaction,
OCPP_CallAction.RemoteStartTransaction,
OCPP_CallAction.NotifyWebPaymentStarted,
],
requests: [OCPP_CallAction.Authorize, OCPP_CallAction.ReservationStatusUpdate],
},
monitoring: {
endpointPrefix: 'monitoring',
host: '0.0.0.0',
port: 8086,
responses: [
OCPP_CallAction.ClearVariableMonitoring,
OCPP_CallAction.GetVariables,
OCPP_CallAction.SetMonitoringBase,
OCPP_CallAction.SetMonitoringLevel,
OCPP_CallAction.GetMonitoringReport,
OCPP_CallAction.SetVariableMonitoring,
OCPP_CallAction.SetVariables,
],
requests: [OCPP_CallAction.NotifyEvent],
},
reporting: {
endpointPrefix: 'reporting',
host: '0.0.0.0',
port: 8087,
responses: [
OCPP_CallAction.CustomerInformation,
OCPP_CallAction.GetLog,
OCPP_CallAction.GetReport,
OCPP_CallAction.GetBaseReport,
OCPP_CallAction.GetMonitoringReport,
],
requests: [
OCPP_CallAction.LogStatusNotification,
OCPP_CallAction.NotifyCustomerInformation,
OCPP_CallAction.NotifyReport,
OCPP_CallAction.SecurityEventNotification,
OCPP_CallAction.NotifyMonitoringReport,
],
},
smartcharging: {
endpointPrefix: 'smartcharging',
host: '0.0.0.0',
port: 8088,
responses: [
OCPP_CallAction.ClearChargingProfile,
OCPP_CallAction.GetChargingProfiles,
OCPP_CallAction.GetCompositeSchedule,
OCPP_CallAction.SetChargingProfile,
],
requests: [
OCPP_CallAction.ClearedChargingLimit,
OCPP_CallAction.NotifyChargingLimit,
OCPP_CallAction.NotifyEVChargingNeeds,
OCPP_CallAction.NotifyEVChargingSchedule,
OCPP_CallAction.ReportChargingProfiles,
],
},
tenant: {
endpointPrefix: 'tenant',
host: '0.0.0.0',
port: 8090,
responses: [],
requests: [],
},
transactions: {
endpointPrefix: 'transactions',
host: '0.0.0.0',
port: 8089,
responses: [OCPP_CallAction.CostUpdated, OCPP_CallAction.GetTransactionStatus],
requests: [
OCPP_CallAction.MeterValues,
OCPP_CallAction.StatusNotification,
OCPP_CallAction.TransactionEvent,
OCPP_CallAction.MeterValues,
OCPP_CallAction.StartTransaction,
OCPP_CallAction.StopTransaction,
OCPP_CallAction.NotifySettlement,
],
},
},
util: {
cache: {
redis: {
host: 'redis',
port: 6379,
},
},
messageBroker: {
amqp: {
url: 'amqp://guest:guest@amqp-broker:5672',
exchange: 'citrineos',
},
},
swagger: {
path: '/docs',
logoPath: path.resolve(path.dirname(__filename), '../../assets/certificates/logo.png'),
exposeData: true,
exposeMessage: true,
},
authProvider: {
localByPass: true,
},
networkConnection: {
websocketServers: [
{
id: '0',
securityProfile: 0,
allowUnknownChargingStations: true,
pingInterval: 60,
host: '0.0.0.0',
port: 8081,
protocols: OCPP_VERSION_LIST,
tenantId: DEFAULT_TENANT_ID,
dynamicTenantResolution: false,
},
{
id: '1',
securityProfile: 1,
allowUnknownChargingStations: false,
pingInterval: 60,
host: '0.0.0.0',
port: 8082,
protocols: OCPP_VERSION_LIST,
tenantId: DEFAULT_TENANT_ID,
dynamicTenantResolution: false,
},
],
},
certificateAuthority: {
v2gCA: {
name: 'hubject',
hubject: {
baseUrl: HUBJECT_DEFAULT_BASEURL,
tokenUrl: HUBJECT_DEFAULT_TOKENURL,
clientId: HUBJECT_DEFAULT_CLIENTID,
clientSecret: HUBJECT_DEFAULT_CLIENTSECRET,
},
},
chargingStationCA: {
name: 'acme',
acme: {
env: 'staging',
accountKeyFilePath: path.resolve(
path.dirname(__filename),
'../../assets/certificates/acme_account_key.pem',
),
email: 'test@citrineos.com',
},
},
},
},
logLevel: 2, // debug
maxCallLengthSeconds: 20,
maxCachingSeconds: 30,
ocpiServer: {
host: '0.0.0.0',
port: 8085,
},
userPreferences: {
// None by default
},
});
}

View File

@@ -0,0 +1,39 @@
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
//
// SPDX-License-Identifier: Apache-2.0
import type { BootstrapConfig, SystemConfig } from '@citrineos/base';
import { loadBootstrapConfig } from '@citrineos/base';
import { loadSystemConfig } from './config.loader.js';
import { createLocalConfig } from './envs/local.js';
import { createDockerConfig } from './envs/docker.js';
/**
* Get default config based on environment
* Note: This is only used if no config exists in storage
*/
function getDefaultConfig(): SystemConfig {
switch (process.env.APP_ENV) {
case 'local':
return createLocalConfig();
case 'docker':
return createDockerConfig();
default:
throw new Error(`Invalid APP_ENV "${process.env.APP_ENV}"`);
}
}
// Export a promise that resolves to the system configuration
export async function getSystemConfig(bootstrapConfig: BootstrapConfig): Promise<SystemConfig> {
try {
return await loadSystemConfig(bootstrapConfig, getDefaultConfig());
} catch (error) {
console.error('Failed to initialize system configuration:', error);
throw error;
}
}
export const systemConfig: Promise<SystemConfig> = (async () => {
const bootstrapConfig = loadBootstrapConfig();
return await getSystemConfig(bootstrapConfig);
})();

View File

@@ -0,0 +1,35 @@
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
//
// SPDX-License-Identifier: Apache-2.0
import { loadBootstrapConfig } from '@citrineos/base';
import 'ts-node/register';
export default (async () => {
try {
const bootstrapConfig = loadBootstrapConfig();
const { host, port, database, dialect, username, password, ssl } = bootstrapConfig.database;
console.log('[sequelize.bridge.config.ts] Loaded config for DB:', {
host,
port,
database,
dialect,
ssl,
});
return {
username,
password,
database,
host,
port,
dialect,
...(ssl && { dialectOptions: { ssl } }),
logging: true,
};
} catch (error) {
console.error('[sequelize.bridge.config.ts] Failed to load bootstrap configuration:', error);
throw error;
}
})();

View File

@@ -0,0 +1,142 @@
// SPDX-FileCopyrightText: 2026 Contributors to the CitrineOS Project
//
// SPDX-License-Identifier: Apache-2.0
import type { ICache } from '@citrineos/base';
import type { Sequelize } from '@citrineos/core';
import { RabbitMQConnectionManager, WebsocketNetworkConnection } from '@citrineos/core';
import type { ILogObj } from 'tslog';
import { Logger } from 'tslog';
// https://datatracker.ietf.org/doc/html/draft-inadarei-api-health-check
type CheckResult = { status: 'pass' | 'fail'; error?: string };
export type HealthCheckResult = { status: 'pass' | 'fail'; checks: Record<string, CheckResult> };
export class HealthCheckService {
private _isShuttingDown: boolean = false;
private _notReadyAt: number | null = null;
private readonly _notReadyThresholdMs: number;
private readonly _logger: Logger<ILogObj>;
constructor(
private readonly _networkConnection: WebsocketNetworkConnection | null | undefined,
private readonly _connectionManager: RabbitMQConnectionManager | null | undefined,
private readonly _cache: ICache,
private readonly _sequelizeInstance: Sequelize,
notReadyThresholdSeconds: number,
logger?: Logger<ILogObj>,
) {
this._notReadyThresholdMs = notReadyThresholdSeconds * 1000;
this._logger = logger
? logger.getSubLogger({ name: 'HealthCheckService' })
: new Logger<ILogObj>({ name: 'HealthCheckService' });
}
shutdown() {
this._isShuttingDown = true;
}
async checkReadiness(): Promise<HealthCheckResult> {
let checks: Record<string, CheckResult> = {};
let pass = true;
if (this._isShuttingDown) {
checks['shutdown'] = { status: 'fail', error: 'shutting down' };
pass = false;
} else {
({ checks, pass } = await this._checkConnections());
}
if (!pass) {
this._notReadyAt ??= Date.now();
} else {
this._notReadyAt = null;
}
return { status: pass ? 'pass' : 'fail', checks };
}
checkLiveness(): HealthCheckResult {
if (this._notReadyAt !== null) {
const durationMs = Date.now() - this._notReadyAt;
if (durationMs > this._notReadyThresholdMs) {
const durationSec = Math.round(durationMs / 1000);
const thresholdSec = this._notReadyThresholdMs / 1000;
return {
status: 'fail',
checks: {
readiness: {
status: 'fail',
error: `Not ready for ${durationSec}s (threshold: ${thresholdSec}s)`,
},
},
};
}
}
return { status: 'pass', checks: {} };
}
private async _checkConnections(): Promise<{
checks: Record<string, CheckResult>;
pass: boolean;
}> {
const checks: Record<string, CheckResult> = {};
let pass = true;
if (this._networkConnection) {
for (const [id, server] of this._networkConnection.getHttpServers()) {
if (server.listening) {
checks[`websocket:${id}`] = { status: 'pass' };
} else {
checks[`websocket:${id}`] = { status: 'fail', error: 'server not listening' };
pass = false;
}
}
}
if (this._connectionManager) {
if (this._connectionManager.isConnected()) {
checks['rabbitmq'] = { status: 'pass' };
} else {
checks['rabbitmq'] = { status: 'fail', error: 'not connected' };
pass = false;
}
}
checks['cache'] = await this._checkCache();
if (checks['cache'].status === 'fail') pass = false;
checks['database'] = await this._checkDatabase();
if (checks['database'].status === 'fail') pass = false;
return { checks, pass };
}
private async _checkCache(): Promise<CheckResult> {
try {
await this._cache.ping();
return { status: 'pass' };
} catch (error) {
this._logger.error('Cache health check failed', { error });
return { status: 'fail', error: 'cache unavailable' };
}
}
private _checkDatabase(): CheckResult {
// Accessing pool is not officially supported, it has been private only for years
// but Sequelize v6 does not provide any other way to check database connectivity without actually running a query, which we want to avoid in a health check
const pool = (this._sequelizeInstance.connectionManager as any).pool;
if (!pool) {
this._logger.error('Database health check failed: pool unavailable');
return { status: 'fail', error: 'pool unavailable' };
}
if (pool.size === 0 && pool.waiting > 0) {
this._logger.error('Database health check failed: connections waiting but pool empty', {
waiting: pool.waiting,
maxSize: pool.maxSize,
});
return { status: 'fail', error: 'database unavailable' };
}
return { status: 'pass' };
}
}

View File

@@ -0,0 +1,26 @@
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
//
// SPDX-License-Identifier: Apache-2.0
import { EventGroup, loadBootstrapConfig } from '@citrineos/base';
import { CitrineOSServer } from './citrineOSServer.js';
import { getSystemConfig } from './config/index.js';
async function main() {
const bootstrapConfig = loadBootstrapConfig();
const config = await getSystemConfig(bootstrapConfig);
const server = new CitrineOSServer(
process.env.APP_NAME?.toLowerCase() as EventGroup,
bootstrapConfig,
config,
);
server.run().catch((error: any) => {
console.error(error);
process.exit(1);
});
}
main().catch((error) => {
console.error('Failed to initialize server:', error);
process.exit(1);
});